From af317eed2b1b16f88d1673c2c32be2f522252bd9 Mon Sep 17 00:00:00 2001 From: Pritesh Nandgaonkar Date: Thu, 28 Feb 2019 09:38:03 -0800 Subject: [PATCH] Export and import all the nodes Summary: This diff does the following - Support to export the entire view hierarchy for iOS - Android is not supported yet - This diff adds a call `getAllNodes` to the client side of iOS, which returns the entire view hierarchy - Currently the search doesn't work on the imported layout plugin data. Also the imported layout plugin data doesn't expand the way it does when component is mounted, reason being the client passed to the plugin is not functional for the archived case I will work on fixing the last points in the next diffs stacked on the current one. For Android: - Currently the export function will export whatever is currently displayed on the Flipper app, not the entire view hierarchy Support for Android will also come up in later diffs. Reviewed By: jknoxville Differential Revision: D14209157 fbshipit-source-id: 3ad3e39edfd994913dc19cc239bfbbe011a9757c --- headless/index.js | 6 +- .../FlipperKitLayoutPlugin.mm | 31 ++++++++ src/Client.js | 25 +++++-- src/PluginContainer.js | 6 +- src/index.js | 2 + src/plugin.js | 16 +++- src/plugins/layout/layout2/index.js | 23 ++++-- src/reducers/pluginStates.js | 4 + src/utils/exportData.js | 74 ++++++++++++++----- 9 files changed, 146 insertions(+), 41 deletions(-) diff --git a/headless/index.js b/headless/index.js index 5852737cc..f74614d2b 100644 --- a/headless/index.js +++ b/headless/index.js @@ -97,10 +97,8 @@ function startFlipper({ const logger = initLogger(store, {isHeadless: true}); dispatcher(store, logger); - process.on('SIGINT', () => { - originalConsole.log( - JSON.stringify(serializeStore(store.getState()), null, 2), - ); + process.on('SIGINT', async () => { + originalConsole.log(JSON.stringify(await serializeStore(store), null, 2)); process.exit(); }); } diff --git a/iOS/Plugins/FlipperKitLayoutPlugin/FlipperKitLayoutPlugin/FlipperKitLayoutPlugin.mm b/iOS/Plugins/FlipperKitLayoutPlugin/FlipperKitLayoutPlugin/FlipperKitLayoutPlugin.mm index f7d35a3da..30af5dc7f 100644 --- a/iOS/Plugins/FlipperKitLayoutPlugin/FlipperKitLayoutPlugin/FlipperKitLayoutPlugin.mm +++ b/iOS/Plugins/FlipperKitLayoutPlugin/FlipperKitLayoutPlugin/FlipperKitLayoutPlugin.mm @@ -86,6 +86,10 @@ [connection receive:@"getRoot" withBlock:^(NSDictionary *params, id responder) { FlipperPerformBlockOnMainThread(^{ [weakSelf onCallGetRoot: responder]; }, responder); }]; + + [connection receive:@"getAllNodes" withBlock:^(NSDictionary *params, id responder) { + FlipperPerformBlockOnMainThread(^{ [weakSelf onCallGetAllNodesWithResponder: responder]; }, responder); + }]; [connection receive:@"getNodes" withBlock:^(NSDictionary *params, id responder) { FlipperPerformBlockOnMainThread(^{ [weakSelf onCallGetNodes: params[@"ids"] withResponder: responder]; }, responder); @@ -134,6 +138,29 @@ [responder success: rootNode]; } +- (void)populateAllNodesFromNode:(nonnull NSString *)identifier inDictionary:(nonnull NSMutableDictionary *)mutableDict { + NSDictionary *nodeDict = [self getNode:identifier]; + mutableDict[identifier] = nodeDict; + NSArray *arr = nodeDict[@"children"]; + for (NSString *childIdentifier in arr) { + [self populateAllNodesFromNode:childIdentifier inDictionary:mutableDict]; + } + return; +} + +- (void)onCallGetAllNodesWithResponder:(nonnull id)responder { + NSMutableArray *allNodes = @[].mutableCopy; + NSString *identifier = [self trackObject: _rootNode]; + NSDictionary *rootNode = [self getNode: identifier]; + if (!rootNode) { + return [responder error:@{@"error": [NSString stringWithFormat:@"getNode returned nil for the rootNode %@, while getting all the nodes", identifier]}]; + } + [allNodes addObject:rootNode]; + NSMutableDictionary *allNodesDict = @{}.mutableCopy; + [self populateAllNodesFromNode:identifier inDictionary:allNodesDict]; + [responder success:@{@"allNodes": @{@"rootElement": identifier, @"elements": allNodesDict}}]; +} + - (void)onCallGetNodes:(NSArray *)nodeIds withResponder:(id)responder { NSMutableArray *elements = [NSMutableArray new]; @@ -417,6 +444,10 @@ return objectIdentifier; } +- (BOOL)runInBackground { + return true; +} + @end #endif diff --git a/src/Client.js b/src/Client.js index 71081191d..19cc47139 100644 --- a/src/Client.js +++ b/src/Client.js @@ -171,7 +171,9 @@ export default class Client extends EventEmitter { // get the supported plugins async getPlugins(): Promise { - const plugins = await this.rawCall('getPlugins').then(data => data.plugins); + const plugins = await this.rawCall('getPlugins', false).then( + data => data.plugins, + ); this.plugins = plugins; return plugins; } @@ -345,7 +347,11 @@ export default class Client extends EventEmitter { methodCallbacks.delete(callback); } - rawCall(method: string, params?: Object): Promise { + rawCall( + method: string, + fromPlugin: boolean, + params?: Object, + ): Promise { return new Promise((resolve, reject) => { const id = this.messageIdCounter++; const metadata: RequestMetadata = { @@ -378,13 +384,13 @@ export default class Client extends EventEmitter { const mark = this.getPerformanceMark(metadata); performance.mark(mark); - if (this.isAcceptingMessagesFromPlugin(plugin)) { + if (!fromPlugin || this.isAcceptingMessagesFromPlugin(plugin)) { this.connection && this.connection .requestResponse({data: JSON.stringify(data)}) .subscribe({ onComplete: payload => { - if (this.isAcceptingMessagesFromPlugin(plugin)) { + if (!fromPlugin || this.isAcceptingMessagesFromPlugin(plugin)) { const logEventName = this.getLogEventName(data); this.logger.trackTimeSince(mark, logEventName); const response: {| @@ -452,9 +458,14 @@ export default class Client extends EventEmitter { } } - call(api: string, method: string, params?: Object): Promise { + call( + api: string, + method: string, + fromPlugin: boolean, + params?: Object, + ): Promise { return reportPluginFailures( - this.rawCall('execute', {api, method, params}), + this.rawCall('execute', fromPlugin, {api, method, params}), `Call-${method}`, api, ); @@ -474,7 +485,7 @@ export default class Client extends EventEmitter { if (this.sdkVersion < 2) { return Promise.resolve(false); } - return this.rawCall('isMethodSupported', {api, method}).then( + return this.rawCall('isMethodSupported', true, {api, method}).then( response => response.isSupported, ); } diff --git a/src/PluginContainer.js b/src/PluginContainer.js index 241c1d55c..7b5f8e82a 100644 --- a/src/PluginContainer.js +++ b/src/PluginContainer.js @@ -8,7 +8,7 @@ import type {FlipperPlugin, FlipperDevicePlugin} from './plugin.js'; import type {Logger} from './fb-interfaces/Logger'; import BaseDevice from './devices/BaseDevice.js'; import type {Props as PluginProps} from './plugin'; - +import {pluginKey as getPluginKey} from './reducers/pluginStates'; import Client from './Client.js'; import { ErrorBoundary, @@ -168,7 +168,7 @@ export default connect( } target = selectedDevice; if (activePlugin) { - pluginKey = `${selectedDevice.serial}#${activePlugin.id}`; + pluginKey = getPluginKey(selectedDevice.serial, activePlugin.id); } else { target = clients.find((client: Client) => client.id === selectedApp); activePlugin = clientPlugins.get(selectedPlugin); @@ -177,7 +177,7 @@ export default connect( `Plugin "${selectedPlugin || ''}" could not be found.`, ); } - pluginKey = `${target.id}#${activePlugin.id}`; + pluginKey = getPluginKey(target.id, activePlugin.id); } } diff --git a/src/index.js b/src/index.js index 172172e5a..dc8084338 100644 --- a/src/index.js +++ b/src/index.js @@ -14,8 +14,10 @@ export { FlipperBasePlugin, FlipperPlugin, FlipperDevicePlugin, + callClient, } from './plugin.js'; export type {PluginClient} from './plugin.js'; +export {default as Client} from './Client.js'; export {clipboard} from 'electron'; export * from './fb-stubs/constants.js'; export * from './fb-stubs/createPaste.js'; diff --git a/src/plugin.js b/src/plugin.js index 5e9978da4..aa062ed53 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -19,6 +19,15 @@ import IOSDevice from './devices/IOSDevice'; const invariant = require('invariant'); +// This function is intended to be called from outside of the plugin. +// If you want to `call` from the plugin use, this.client.call +export function callClient( + client: Client, + id: string, +): (string, ?Object) => Promise { + return (method, params) => client.call(id, method, false, params); +} + export type PluginClient = {| send: (method: string, params?: Object) => void, call: (method: string, params?: Object) => Promise, @@ -69,6 +78,11 @@ export class FlipperBasePlugin< method: string, data: Object, ) => $Shape; + static exportPersistedState: ?( + callClient: (string, ?Object) => Promise, + persistedState: ?PersistedState, + store: ?Store, + ) => Promise; static getActiveNotifications: ?( persistedState: PersistedState, ) => Array; @@ -160,7 +174,7 @@ export class FlipperPlugin extends FlipperBasePlugin< // $FlowFixMe props.target will be instance of Client this.realClient = props.target; this.client = { - call: (method, params) => this.realClient.call(id, method, params), + call: (method, params) => this.realClient.call(id, method, true, params), send: (method, params) => this.realClient.send(id, method, params), subscribe: (method, callback) => { this.subscriptions.push({ diff --git a/src/plugins/layout/layout2/index.js b/src/plugins/layout/layout2/index.js index 2a301e6be..f91c11e0e 100644 --- a/src/plugins/layout/layout2/index.js +++ b/src/plugins/layout/layout2/index.js @@ -5,7 +5,7 @@ * @format */ -import type {ElementID, Element, ElementSearchResultSet} from 'flipper'; +import type {ElementID, Element, ElementSearchResultSet, Store} from 'flipper'; import { FlexColumn, @@ -51,6 +51,22 @@ const BetaBar = styled(Toolbar)({ }); export default class Layout extends FlipperPlugin { + static exportPersistedState = ( + callClient: (string, ?Object) => Promise, + persistedState: ?PersistedState, + store: ?Store, + ): Promise => { + const defaultPromise = Promise.resolve(persistedState); + if (!store) { + return defaultPromise; + } + const selectedDevice = store.getState().connections.selectedDevice; + if (selectedDevice && selectedDevice.os === 'iOS') { + return callClient('getAllNodes').then(({allNodes}) => allNodes); + } + return defaultPromise; + }; + static defaultPersistedState = { rootElement: null, rootAXElement: null, @@ -68,11 +84,6 @@ export default class Layout extends FlipperPlugin { searchResults: null, }; - componentDidMount() { - // reset the data, as it might be outdated - this.props.setPersistedState(Layout.defaultPersistedState); - } - init() { // persist searchActive state when moving between plugins to prevent multiple // TouchOverlayViews since we can't edit the view heirarchy in onDisconnect diff --git a/src/reducers/pluginStates.js b/src/reducers/pluginStates.js index 3eb2bdbf5..c08ca0cfd 100644 --- a/src/reducers/pluginStates.js +++ b/src/reducers/pluginStates.js @@ -9,6 +9,10 @@ export type State = { [pluginKey: string]: Object, }; +export const pluginKey = (serial: string, pluginName: string): string => { + return `${serial}#${pluginName}`; +}; + export type Action = | { type: 'SET_PLUGIN_STATE', diff --git a/src/utils/exportData.js b/src/utils/exportData.js index 66d7458db..4ad9a25d7 100644 --- a/src/utils/exportData.js +++ b/src/utils/exportData.js @@ -10,8 +10,8 @@ import type {State as PluginStates} from '../reducers/pluginStates'; import type {PluginNotification} from '../reducers/notifications.js'; import type {ClientExport} from '../Client.js'; import type {State as PluginStatesState} from '../reducers/pluginStates'; -import type {State} from '../reducers/index'; -import {FlipperDevicePlugin} from '../plugin.js'; +import {pluginKey} from '../reducers/pluginStates'; +import {FlipperDevicePlugin, FlipperPlugin, callClient} from '../plugin.js'; import {default as BaseDevice} from '../devices/BaseDevice'; import {default as ArchivedDevice} from '../devices/ArchivedDevice'; import {default as Client} from '../Client'; @@ -174,17 +174,51 @@ export const processStore = ( return null; }; -export function serializeStore(state: State): ?ExportType { - const {activeNotifications} = state.notifications; - const {selectedDevice, clients} = state.connections; +export async function serializeStore(store: Store): Promise { + const state = store.getState(); + const {clients} = state.connections; const {pluginStates} = state; - const {devicePlugins} = state.plugins; + const {plugins} = state; + const newPluginState = {...pluginStates}; // TODO: T39612653 Make Client mockable. Currently rsocket logic is tightly coupled. // Not passing the entire state as currently Client is not mockable. + + const pluginsMap: Map< + string, + Class | FlipperPlugin<>>, + > = new Map([]); + plugins.clientPlugins.forEach((val, key) => { + pluginsMap.set(key, val); + }); + plugins.devicePlugins.forEach((val, key) => { + pluginsMap.set(key, val); + }); + for (let client of clients) { + for (let plugin of client.plugins) { + const pluginClass: ?Class< + FlipperDevicePlugin<> | FlipperPlugin<>, + > = plugin ? pluginsMap.get(plugin) : null; + const exportState = pluginClass ? pluginClass.exportPersistedState : null; + if (exportState) { + const key = pluginKey(client.id, plugin); + const data = await exportState( + callClient(client, plugin), + newPluginState[key], + store, + ); + newPluginState[key] = data; + } + } + } + + const {activeNotifications} = store.getState().notifications; + const {selectedDevice} = store.getState().connections; + const {devicePlugins} = store.getState().plugins; + return processStore( activeNotifications, selectedDevice, - pluginStates, + newPluginState, clients.map(client => client.toJSON()), devicePlugins, uuid.v4(), @@ -193,21 +227,21 @@ export function serializeStore(state: State): ?ExportType { export const exportStoreToFile = ( exportFilePath: string, - data: Store, + store: Store, ): Promise => { - const json = serializeStore(data.getState()); - if (json) { - return new Promise((resolve, reject) => { - fs.writeFile(exportFilePath, serialize(json), err => { - if (err) { - reject(err); - } - resolve(); - }); + return new Promise(async (resolve, reject) => { + const json = await serializeStore(store); + if (!json) { + console.error('Make sure a device is connected'); + reject('No device is selected'); + } + fs.writeFile(exportFilePath, serialize(json), err => { + if (err) { + reject(err); + } + resolve(); }); - } - console.error('Make sure a device is connected'); - return new Promise.reject(new Error('No device is selected')); + }); }; export const importFileToStore = (file: string, store: Store) => {