diff --git a/desktop/app/src/PluginContainer.tsx b/desktop/app/src/PluginContainer.tsx index d489958e6..9bf380dbb 100644 --- a/desktop/app/src/PluginContainer.tsx +++ b/desktop/app/src/PluginContainer.tsx @@ -43,7 +43,7 @@ import {selectPlugin} from './reducers/connections'; import {State as Store, MiddlewareAPI} from './reducers/index'; import {activateMenuItems} from './MenuBar'; import {Message} from './reducers/pluginMessageQueue'; -import {Idler} from './utils/Idler'; +import {IdlerImpl} from './utils/Idler'; import {processMessageQueue} from './utils/messageQueue'; import {ToggleButton, SmallText, Layout} from './ui'; import {theme, TrackingScope, _SandyPluginRenderer} from 'flipper-plugin'; @@ -173,7 +173,7 @@ class PluginContainer extends PureComponent { } }; - idler?: Idler; + idler?: IdlerImpl; pluginBeingProcessed: string | null = null; state = { @@ -237,7 +237,7 @@ class PluginContainer extends PureComponent { pendingMessages?.length ) { const start = Date.now(); - this.idler = new Idler(); + this.idler = new IdlerImpl(); processMessageQueue( isSandyPlugin(activePlugin) ? target.sandyPluginStates.get(activePlugin.id)! diff --git a/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap b/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap index 98840bc61..8c290e6d2 100644 --- a/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap +++ b/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap @@ -21,7 +21,6 @@ Object { "deviceType": "physical", "logs": Array [], "os": "Android", - "pluginStates": Object {}, "serial": "serial", "title": "MockAndroidDevice", }, @@ -31,7 +30,6 @@ Object { "deviceType": "physical", "logs": Array [], "os": "Android", - "pluginStates": Object {}, "serial": "serial", "title": "MockAndroidDevice", }, diff --git a/desktop/app/src/chrome/ShareSheetExportFile.tsx b/desktop/app/src/chrome/ShareSheetExportFile.tsx index 5ddeeaff3..5a1545728 100644 --- a/desktop/app/src/chrome/ShareSheetExportFile.tsx +++ b/desktop/app/src/chrome/ShareSheetExportFile.tsx @@ -14,7 +14,7 @@ import {reportPlatformFailures} from '../utils/metrics'; import CancellableExportStatus from './CancellableExportStatus'; import {performance} from 'perf_hooks'; import {Logger} from '../fb-interfaces/Logger'; -import {Idler} from '../utils/Idler'; +import {IdlerImpl} from '../utils/Idler'; import { exportStoreToFile, EXPORT_FLIPPER_TRACE_EVENT, @@ -86,7 +86,7 @@ export default class ShareSheetExportFile extends Component { runInBackground: false, }; - idler = new Idler(); + idler = new IdlerImpl(); dispatchAndUpdateToolBarStatus(msg: string) { this.store.dispatch( diff --git a/desktop/app/src/chrome/ShareSheetExportUrl.tsx b/desktop/app/src/chrome/ShareSheetExportUrl.tsx index 262641781..8c5fe9e79 100644 --- a/desktop/app/src/chrome/ShareSheetExportUrl.tsx +++ b/desktop/app/src/chrome/ShareSheetExportUrl.tsx @@ -16,7 +16,7 @@ import { setExportURL, } from '../reducers/application'; import {Logger} from '../fb-interfaces/Logger'; -import {Idler} from '../utils/Idler'; +import {IdlerImpl} from '../utils/Idler'; import { shareFlipperData, DataExportResult, @@ -95,7 +95,7 @@ export default class ShareSheetExportUrl extends Component { return this.context.store; } - idler = new Idler(); + idler = new IdlerImpl(); dispatchAndUpdateToolBarStatus(msg: string) { this.store.dispatch( diff --git a/desktop/app/src/createTablePlugin.tsx b/desktop/app/src/createTablePlugin.tsx index a6baa99cd..ebe2f0a5f 100644 --- a/desktop/app/src/createTablePlugin.tsx +++ b/desktop/app/src/createTablePlugin.tsx @@ -24,7 +24,7 @@ import {List, Map as ImmutableMap} from 'immutable'; import React from 'react'; import {KeyboardActions} from './MenuBar'; import {TableBodyRow} from './ui'; -import {Idler} from './utils/Idler'; +import {Idler} from 'flipper-plugin'; type ID = string; diff --git a/desktop/app/src/devices/BaseDevice.tsx b/desktop/app/src/devices/BaseDevice.tsx index e76bee6b8..855de5810 100644 --- a/desktop/app/src/devices/BaseDevice.tsx +++ b/desktop/app/src/devices/BaseDevice.tsx @@ -14,6 +14,7 @@ import { _SandyPluginDefinition, DeviceType, DeviceLogListener, + Idler, } from 'flipper-plugin'; import type {DevicePluginDefinition, DevicePluginMap} from '../plugin'; import {getFlipperLibImplementation} from '../utils/flipperLibImplementation'; @@ -91,22 +92,31 @@ export default class BaseDevice { return this.title; } - toJSON(): DeviceExport { + async exportState( + idler: Idler, + onStatusMessage: (msg: string) => void, + ): Promise> { const pluginStates: Record = {}; for (const instance of this.sandyPluginStates.values()) { if (instance.isPersistable()) { - pluginStates[instance.definition.id] = instance.exportState(); + pluginStates[instance.definition.id] = await instance.exportState( + idler, + onStatusMessage, + ); } } + return pluginStates; + } + + toJSON() { return { os: this.os, title: this.title, deviceType: this.deviceType, serial: this.serial, logs: this.getLogs(), - pluginStates, }; } diff --git a/desktop/app/src/index.tsx b/desktop/app/src/index.tsx index 837a5666b..15ddb1ae4 100644 --- a/desktop/app/src/index.tsx +++ b/desktop/app/src/index.tsx @@ -42,7 +42,7 @@ export {connect} from 'react-redux'; export {selectPlugin, StaticView} from './reducers/connections'; export {writeBufferToFile, bufferToBlob} from './utils/screenshot'; export {getPluginKey, getPersistedState} from './utils/pluginUtils'; -export {Idler} from './utils/Idler'; +export {Idler} from 'flipper-plugin'; export {Store, MiddlewareAPI, State as ReduxState} from './reducers/index'; export {default as BaseDevice} from './devices/BaseDevice'; export {DeviceLogEntry, LogLevel, DeviceLogListener} from 'flipper-plugin'; diff --git a/desktop/app/src/plugin.tsx b/desktop/app/src/plugin.tsx index 7088d3e29..d56eb9021 100644 --- a/desktop/app/src/plugin.tsx +++ b/desktop/app/src/plugin.tsx @@ -14,13 +14,12 @@ import {Store} from './reducers/index'; import {ReactNode, Component} from 'react'; import BaseDevice from './devices/BaseDevice'; import {serialize, deserialize} from './utils/serialization'; -import {Idler} from './utils/Idler'; import {StaticView} from './reducers/connections'; import {State as ReduxState} from './reducers'; import {DEFAULT_MAX_QUEUE_SIZE} from './reducers/pluginMessageQueue'; import {ActivatablePluginDetails} from 'flipper-plugin-lib'; import {Settings} from './reducers/settings'; -import {_SandyPluginDefinition} from 'flipper-plugin'; +import {Idler, _SandyPluginDefinition} from 'flipper-plugin'; type Parameters = {[key: string]: any}; diff --git a/desktop/app/src/utils/Idler.tsx b/desktop/app/src/utils/Idler.tsx index bae403060..0d8c57f35 100644 --- a/desktop/app/src/utils/Idler.tsx +++ b/desktop/app/src/utils/Idler.tsx @@ -9,15 +9,9 @@ import {CancelledPromiseError} from './errors'; import {sleep} from './promiseTimeout'; +import {Idler} from 'flipper-plugin'; -export interface BaseIdler { - shouldIdle(): boolean; - idle(): Promise; - cancel(): void; - isCancelled(): boolean; -} - -export class Idler implements BaseIdler { +export class IdlerImpl implements Idler { private lastIdle = performance.now(); private kill = false; @@ -57,12 +51,16 @@ export class Idler implements BaseIdler { } // This smills like we should be using generators :) -export class TestIdler implements BaseIdler { +export class TestIdler implements Idler { private resolver?: () => void; private kill = false; private autoRun = false; private hasProgressed = false; + constructor(autorun = false) { + this.autoRun = autorun; + } + shouldIdle() { if (this.kill) { return true; diff --git a/desktop/app/src/utils/__tests__/Idler.node.js b/desktop/app/src/utils/__tests__/Idler.node.js index 4c9b7bd35..3795403fb 100644 --- a/desktop/app/src/utils/__tests__/Idler.node.js +++ b/desktop/app/src/utils/__tests__/Idler.node.js @@ -7,11 +7,11 @@ * @format */ -import {Idler, TestIdler} from '../Idler.tsx'; +import {IdlerImpl, TestIdler} from '../Idler.tsx'; import {sleep} from '../promiseTimeout.tsx'; test('Idler should interrupt', async () => { - const idler = new Idler(); + const idler = new IdlerImpl(); let i = 0; try { for (; i < 500; i++) { @@ -30,7 +30,7 @@ test('Idler should interrupt', async () => { }); test('Idler should want to idle', async () => { - const idler = new Idler(100); + const idler = new IdlerImpl(100); expect(idler.shouldIdle()).toBe(false); await sleep(10); expect(idler.shouldIdle()).toBe(false); diff --git a/desktop/app/src/utils/__tests__/exportData.node.tsx b/desktop/app/src/utils/__tests__/exportData.node.tsx index c1199e0ca..71dc15cc0 100644 --- a/desktop/app/src/utils/__tests__/exportData.node.tsx +++ b/desktop/app/src/utils/__tests__/exportData.node.tsx @@ -28,8 +28,16 @@ import { createState, PluginClient, DevicePluginClient, + sleep, } from 'flipper-plugin'; import {selectPlugin} from '../../reducers/connections'; +import {TestIdler} from '../Idler'; + +const testIdler = new TestIdler(); + +function testOnStatusMessage() { + // emtpy stub +} class TestPlugin extends FlipperPlugin {} TestPlugin.title = 'TestPlugin'; @@ -1103,7 +1111,20 @@ const sandyTestPlugin = new _SandyPluginDefinition( draft.testCount -= 1; }); }); - return {}; + return { + enableCustomExport() { + client.onExport(async (idler, onStatus) => { + if (idler.shouldIdle()) { + await idler.idle(); + } + await sleep(100); + onStatus('hi'); + return { + customExport: true, + }; + }); + }, + }; }, Component() { return null; @@ -1140,6 +1161,39 @@ test('Sandy plugins are exported properly', async () => { }); }); +test('Sandy plugins with custom export are exported properly', async () => { + const {client, sendMessage, store} = await renderMockFlipperWithPlugin( + sandyTestPlugin, + ); + + // We do select another plugin, to verify that pending message queues are indeed processed before exporting + store.dispatch( + selectPlugin({ + selectedPlugin: 'DeviceLogs', + selectedApp: client.id, + deepLinkPayload: null, + }), + ); + + client.sandyPluginStates + .get(sandyTestPlugin.id) + ?.instanceApi.enableCustomExport(); + + // Deliberately not using 'act' here, to verify that exportStore itself makes sure buffers are flushed first + sendMessage('inc', {}); + sendMessage('inc', {}); + sendMessage('inc', {}); + + const storeExport = await exportStore(store); + const serial = storeExport.exportStoreData.device!.serial; + expect(serial).not.toBeFalsy(); + expect(storeExport.exportStoreData.pluginStates2).toEqual({ + [`TestApp#Android#MockAndroidDevice#${serial}`]: { + TestPlugin: {customExport: true}, + }, + }); +}); + test('Sandy plugins are imported properly', async () => { const data = { clients: [ @@ -1190,7 +1244,7 @@ test('Sandy plugins are imported properly', async () => { expect(client2).not.toBe(client); expect(client2.plugins).toEqual([TestPlugin.id]); - expect(client.sandyPluginStates.get(TestPlugin.id)!.exportState()) + expect(client.sandyPluginStates.get(TestPlugin.id)!.exportStateSync()) .toMatchInlineSnapshot(` Object { "counter": 0, @@ -1199,7 +1253,7 @@ test('Sandy plugins are imported properly', async () => { }, } `); - expect(client2.sandyPluginStates.get(TestPlugin.id)!.exportState()) + expect(client2.sandyPluginStates.get(TestPlugin.id)!.exportStateSync()) .toMatchInlineSnapshot(` Object { "counter": 3, @@ -1227,6 +1281,18 @@ const sandyDeviceTestPlugin = new _SandyPluginDefinition( }); return { counter, + enableCustomExport() { + client.onExport(async (idler, onStatus) => { + if (idler.shouldIdle()) { + await idler.idle(); + } + onStatus('hi'); + await sleep(100); + return { + customExport: true, + }; + }); + }, }; }, Component() { @@ -1277,7 +1343,9 @@ test('Sandy device plugins are exported / imported properly', async () => { const {counter} = device2.sandyPluginStates.get(TestPlugin.id)?.instanceApi; counter.set(counter.get() + 1); - expect(device.toJSON().pluginStates[TestPlugin.id]).toMatchInlineSnapshot(` + expect( + (await device.exportState(testIdler, testOnStatusMessage))[TestPlugin.id], + ).toMatchInlineSnapshot(` Object { "counter": 0, "otherState": Object { @@ -1285,21 +1353,30 @@ test('Sandy device plugins are exported / imported properly', async () => { }, } `); - expect(device2.toJSON()).toMatchInlineSnapshot(` + expect(await device2.exportState(testIdler, testOnStatusMessage)) + .toMatchInlineSnapshot(` Object { - "deviceType": "archivedPhysical", - "logs": Array [], - "os": "Android", - "pluginStates": Object { - "TestPlugin": Object { - "counter": 4, - "otherState": Object { - "testCount": -3, - }, + "TestPlugin": Object { + "counter": 4, + "otherState": Object { + "testCount": -3, }, }, - "serial": "2e52cea6-94b0-4ea1-b9a8-c9135ede14ca-serial", - "title": "MockAndroidDevice", } `); }); + +test('Sandy device plugins with custom export are export properly', async () => { + const {device, store} = await renderMockFlipperWithPlugin( + sandyDeviceTestPlugin, + ); + + device.sandyPluginStates + .get(sandyDeviceTestPlugin.id) + ?.instanceApi.enableCustomExport(); + + const storeExport = await exportStore(store); + expect(storeExport.exportStoreData.device!.pluginStates).toEqual({ + [sandyDeviceTestPlugin.id]: {customExport: true}, + }); +}); diff --git a/desktop/app/src/utils/exportData.tsx b/desktop/app/src/utils/exportData.tsx index a35b34c2e..9f652d48d 100644 --- a/desktop/app/src/utils/exportData.tsx +++ b/desktop/app/src/utils/exportData.tsx @@ -35,7 +35,7 @@ import {readCurrentRevision} from './packageMetadata'; import {tryCatchReportPlatformFailures} from './metrics'; import {promisify} from 'util'; import promiseTimeout from './promiseTimeout'; -import {Idler} from './Idler'; +import {TestIdler} from './Idler'; import {setStaticView} from '../reducers/connections'; import { resetSupportFormV2State, @@ -48,6 +48,7 @@ import {processMessageQueue} from './messageQueue'; import {getPluginTitle} from './pluginUtils'; import {capture} from './screenshot'; import {uploadFlipperMedia} from '../fb-stubs/user'; +import {Idler} from 'flipper-plugin'; export const IMPORT_FLIPPER_TRACE_EVENT = 'import-flipper-trace'; export const EXPORT_FLIPPER_TRACE_EVENT = 'export-flipper-trace'; @@ -109,9 +110,11 @@ type AddSaltToDeviceSerialOptions = { clients: Array; pluginStates: PluginStatesExportState; pluginStates2: SandyPluginStates; + devicePluginStates: Record; pluginNotification: Array; selectedPlugins: Array; - statusUpdate?: (msg: string) => void; + statusUpdate: (msg: string) => void; + idler: Idler; }; export function displayFetchMetadataErrors( @@ -251,20 +254,23 @@ const serializePluginStates = async ( return pluginExportState; }; -function exportSandyPluginStates( +async function exportSandyPluginStates( pluginsToProcess: PluginsToProcess, -): SandyPluginStates { + idler: Idler, + statusUpdate: (msg: string) => void, +): Promise { const res: SandyPluginStates = {}; - pluginsToProcess.forEach(({pluginId, client, pluginClass}) => { + for (const key in pluginsToProcess) { + const {pluginId, client, pluginClass} = pluginsToProcess[key]; if (isSandyPlugin(pluginClass) && client.sandyPluginStates.has(pluginId)) { if (!res[client.id]) { res[client.id] = {}; } - res[client.id][pluginId] = client.sandyPluginStates + res[client.id][pluginId] = await client.sandyPluginStates .get(pluginId)! - .exportState(); + .exportState(idler, statusUpdate); } - }); + } return res; } @@ -322,6 +328,8 @@ async function addSaltToDeviceSerial({ statusUpdate, selectedPlugins, pluginStates2, + devicePluginStates, + idler, }: AddSaltToDeviceSerialOptions): Promise { const {serial} = device; const newSerial = salt + '-' + serial; @@ -379,7 +387,7 @@ async function addSaltToDeviceSerial({ fileVersion: remote.app.getVersion(), flipperReleaseRevision: revision, clients: updatedClients, - device: newDevice.toJSON(), + device: {...newDevice.toJSON(), pluginStates: devicePluginStates}, deviceScreenshot: deviceScreenshot, store: { pluginStates: updatedPluginStates, @@ -415,11 +423,14 @@ export async function processStore( selectedPlugins, statusUpdate, }: ProcessStoreOptions, - idler?: Idler, + idler: Idler = new TestIdler(true), ): Promise { if (device) { const {serial} = device; - statusUpdate && statusUpdate('Capturing screenshot...'); + if (!statusUpdate) { + statusUpdate = () => {}; + } + statusUpdate('Capturing screenshot...'); const deviceScreenshot = await capture(device).catch((e) => { console.warn('Failed to capture device screenshot when exporting. ' + e); return null; @@ -449,7 +460,9 @@ export async function processStore( idler, ); - statusUpdate && statusUpdate('Uploading screenshot...'); + const devicePluginStates = await device.exportState(idler, statusUpdate); + + statusUpdate('Uploading screenshot...'); const deviceScreenshotLink = deviceScreenshot && (await uploadFlipperMedia(deviceScreenshot, 'Image').catch((e) => { @@ -467,6 +480,8 @@ export async function processStore( statusUpdate, selectedPlugins, pluginStates2, + devicePluginStates, + idler, }); return exportFlipperData; @@ -478,8 +493,8 @@ export async function fetchMetadata( pluginsToProcess: PluginsToProcess, pluginStates: PluginStatesState, state: ReduxState, - statusUpdate?: (msg: string) => void, - idler?: Idler, + statusUpdate: (msg: string) => void, + idler: Idler, ): Promise<{ pluginStates: PluginStatesState; errors: {[plugin: string]: Error} | null; @@ -626,8 +641,8 @@ export function determinePluginsToProcess( async function getStoreExport( store: MiddlewareAPI, - statusUpdate?: (msg: string) => void, - idler?: Idler, + statusUpdate: (msg: string) => void = () => {}, + idler: Idler, ): Promise<{ exportData: ExportType; fetchMetaDataErrors: {[plugin: string]: Error} | null; @@ -657,10 +672,8 @@ async function getStoreExport( ); const newPluginState = metadata.pluginStates; - // TODO: support async export like fetchMetaData T68683476 - // TODO: support device plugins T70582933 const pluginStates2 = pluginsToProcess - ? exportSandyPluginStates(pluginsToProcess) + ? await exportSandyPluginStates(pluginsToProcess, idler, statusUpdate) : {}; getLogger().trackTimeSince(fetchMetaDataMarker, fetchMetaDataMarker, { @@ -691,8 +704,8 @@ async function getStoreExport( export async function exportStore( store: MiddlewareAPI, includeSupportDetails?: boolean, - idler?: Idler, - statusUpdate?: (msg: string) => void, + idler: Idler = new TestIdler(true), + statusUpdate: (msg: string) => void = () => {}, ): Promise<{ serializedString: string; fetchMetaDataErrors: { diff --git a/desktop/app/src/utils/messageQueue.tsx b/desktop/app/src/utils/messageQueue.tsx index 18a629227..4d5ec8b18 100644 --- a/desktop/app/src/utils/messageQueue.tsx +++ b/desktop/app/src/utils/messageQueue.tsx @@ -20,11 +20,11 @@ import { Message, DEFAULT_MAX_QUEUE_SIZE, } from '../reducers/pluginMessageQueue'; -import {Idler, BaseIdler} from './Idler'; +import {IdlerImpl} from './Idler'; import {pluginIsStarred, getSelectedPluginKey} from '../reducers/connections'; import {deconstructPluginKey} from './clientUtils'; import {defaultEnabledBackgroundPlugins} from './pluginUtils'; -import {batch, _SandyPluginInstance} from 'flipper-plugin'; +import {batch, Idler, _SandyPluginInstance} from 'flipper-plugin'; import {addBackgroundStat} from './pluginStats'; function processMessageClassic( @@ -168,7 +168,7 @@ export async function processMessageQueue( pluginKey: string, store: MiddlewareAPI, progressCallback?: (progress: {current: number; total: number}) => void, - idler: BaseIdler = new Idler(), + idler: Idler = new IdlerImpl(), ): Promise { if (!_SandyPluginInstance.is(plugin) && !plugin.persistedStateReducer) { return true; diff --git a/desktop/app/src/utils/reduxDevToolsConfig.tsx b/desktop/app/src/utils/reduxDevToolsConfig.tsx index 192bf29fb..29d921ab9 100644 --- a/desktop/app/src/utils/reduxDevToolsConfig.tsx +++ b/desktop/app/src/utils/reduxDevToolsConfig.tsx @@ -22,13 +22,13 @@ export const stateSanitizer = (state: State) => { return { ...device.toJSON(), logs: [], - }; + } as any; }), selectedDevice: selectedDevice - ? { + ? ({ ...selectedDevice.toJSON(), logs: [], - } + } as any) : null, }, }; diff --git a/desktop/app/src/utils/serialization.tsx b/desktop/app/src/utils/serialization.tsx index f7873318a..ae6331f8c 100644 --- a/desktop/app/src/utils/serialization.tsx +++ b/desktop/app/src/utils/serialization.tsx @@ -7,7 +7,8 @@ * @format */ -import {Idler} from './Idler'; +import {Idler} from 'flipper-plugin'; + export async function serialize( obj: Object, idler?: Idler, diff --git a/desktop/flipper-plugin/src/__tests__/api.node.tsx b/desktop/flipper-plugin/src/__tests__/api.node.tsx index 177d257d1..f926e3fea 100644 --- a/desktop/flipper-plugin/src/__tests__/api.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/api.node.tsx @@ -59,6 +59,7 @@ test('Correct top level API exposed', () => { "DeviceType", "Draft", "FlipperLib", + "Idler", "LogLevel", "LogTypes", "Logger", diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index e94a18059..1d0ebdb47 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -69,6 +69,7 @@ export { useLogger, _LoggerContext, } from './utils/Logger'; +export {Idler} from './utils/Idler'; // It's not ideal that this exists in flipper-plugin sources directly, // but is the least pain for plugin authors. diff --git a/desktop/flipper-plugin/src/plugin/PluginBase.tsx b/desktop/flipper-plugin/src/plugin/PluginBase.tsx index ad0ba53d6..26b69432a 100644 --- a/desktop/flipper-plugin/src/plugin/PluginBase.tsx +++ b/desktop/flipper-plugin/src/plugin/PluginBase.tsx @@ -14,6 +14,12 @@ import {MenuEntry, NormalizedMenuEntry, normalizeMenuEntry} from './MenuEntry'; import {FlipperLib} from './FlipperLib'; import {Device, RealFlipperDevice} from './DevicePlugin'; import {batched} from '../state/batch'; +import {Idler} from '../utils/Idler'; + +type StateExportHandler = ( + idler: Idler, + onStatusMessage: (msg: string) => void, +) => Promise>; export interface BasePluginClient { readonly device: Device; @@ -38,6 +44,12 @@ export interface BasePluginClient { */ onDeepLink(cb: (deepLink: unknown) => void): void; + /** + * Triggered when the current plugin is being exported and should create a snapshot of the state exported. + * Overrides the default export behavior and ignores any 'persist' flags of state. + */ + onExport(exporter: StateExportHandler): void; + /** * Register menu entries in the Flipper toolbar */ @@ -88,6 +100,8 @@ export abstract class BasePluginInstance { rootStates: Record> = {}; // last seen deeplink lastDeeplink?: any; + // export handler + exportHandler?: StateExportHandler; menuEntries: NormalizedMenuEntry[] = []; @@ -145,6 +159,12 @@ export abstract class BasePluginInstance { onDestroy: (cb) => { this.events.on('destroy', batched(cb)); }, + onExport: (cb) => { + if (this.exportHandler) { + throw new Error('onExport handler already set'); + } + this.exportHandler = cb; + }, addMenuEntry: (...entries) => { for (const entry of entries) { const normalized = normalizeMenuEntry(entry); @@ -204,14 +224,30 @@ export abstract class BasePluginInstance { } } - exportState() { + exportStateSync() { + // This method is mainly intended for unit testing + if (this.exportHandler) { + throw new Error( + 'Cannot export sync a plugin that does have an export handler', + ); + } return Object.fromEntries( Object.entries(this.rootStates).map(([key, atom]) => [key, atom.get()]), ); } + async exportState( + idler: Idler, + onStatusMessage: (msg: string) => void, + ): Promise> { + if (this.exportHandler) { + return await this.exportHandler(idler, onStatusMessage); + } + return this.exportStateSync(); + } + isPersistable(): boolean { - return Object.keys(this.rootStates).length > 0; + return !!this.exportHandler || Object.keys(this.rootStates).length > 0; } protected assertNotDestroyed() { diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index ad80231a5..2967c3790 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -37,6 +37,7 @@ import { import {BasePluginInstance} from '../plugin/PluginBase'; import {FlipperLib} from '../plugin/FlipperLib'; import {stubLogger} from '../utils/Logger'; +import {Idler} from '../utils/Idler'; type Renderer = RenderResult; @@ -95,9 +96,14 @@ interface BasePluginResult { triggerDeepLink(deeplink: unknown): void; /** - * Grab all the persistable state + * Grab all the persistable state, but will ignore any onExport handler */ - exportState(): any; + exportState(): Record; + + /** + * Grab all the persistable state, respecting onExport handlers + */ + exportStateAsync(): Promise>; /** * Trigger menu entry by label @@ -367,7 +373,9 @@ function createBasePluginResult( flipperLib: pluginInstance.flipperLib, activate: () => pluginInstance.activate(), deactivate: () => pluginInstance.deactivate(), - exportState: () => pluginInstance.exportState(), + exportStateAsync: () => + pluginInstance.exportState(createStubIdler(), () => {}), + exportState: () => pluginInstance.exportStateSync(), triggerDeepLink: (deepLink: unknown) => { pluginInstance.triggerDeepLink(deepLink); }, @@ -422,3 +430,18 @@ function createMockDevice(options?: StartPluginOptions): RealFlipperDevice { }, }; } + +function createStubIdler(): Idler { + return { + shouldIdle() { + return false; + }, + idle() { + return Promise.resolve(); + }, + cancel() {}, + isCancelled() { + return false; + }, + }; +} diff --git a/desktop/flipper-plugin/src/utils/Idler.tsx b/desktop/flipper-plugin/src/utils/Idler.tsx new file mode 100644 index 000000000..a916dda95 --- /dev/null +++ b/desktop/flipper-plugin/src/utils/Idler.tsx @@ -0,0 +1,15 @@ +/** + * 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 + */ + +export interface Idler { + shouldIdle(): boolean; + idle(): Promise; + cancel(): void; + isCancelled(): boolean; +} diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index aca1f174c..22bc0e68a 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -135,6 +135,13 @@ Usage: `client.onDeepLink(callback: (payload: unknown) => void)` Trigger when the users navigates to this plugin using a deeplink, either from an external `flipper://` plugin URL, or because the user was linked here from another plugin. +#### `onExport` + +Usage: `client.onExport(callback: (idler, onStatusMessage) => Promise)` + +Overrides the default serialization behavior of this plugin. Should return a promise with state that is to be stored. +This process is async, so it is possible to first fetch some additional state from the device. + ### Methods #### `send` @@ -277,6 +284,14 @@ See the similarly named event under [`PluginClient`](#pluginclient). See the similarly named event under [`PluginClient`](#pluginclient). +#### `onExport` + +See the similarly named event under [`PluginClient`](#pluginclient). + +#### `onImport` + +See the similarly named event under [`PluginClient`](#pluginclient). + ### Methods #### `addMenuEntry`