Add support for async / custom plugin export

Summary:
Sandy plugins can now set up an `onExport` handler to enable customizing the export format of a plugin: `client.onExport(callback: (idler, onStatusMessage) => Promise<state>)`

Import will be done in next diff

Reviewed By: nikoant

Differential Revision: D26124440

fbshipit-source-id: c787c79d929aa8fb484f15a9340d7c87545793cb
This commit is contained in:
Michel Weststrate
2021-02-01 11:40:20 -08:00
committed by Facebook GitHub Bot
parent 32bde8cace
commit 34c915a739
21 changed files with 264 additions and 77 deletions

View File

@@ -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<Props, State> {
}
};
idler?: Idler;
idler?: IdlerImpl;
pluginBeingProcessed: string | null = null;
state = {
@@ -237,7 +237,7 @@ class PluginContainer extends PureComponent<Props, State> {
pendingMessages?.length
) {
const start = Date.now();
this.idler = new Idler();
this.idler = new IdlerImpl();
processMessageQueue(
isSandyPlugin(activePlugin)
? target.sandyPluginStates.get(activePlugin.id)!

View File

@@ -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",
},

View File

@@ -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<Props, State> {
runInBackground: false,
};
idler = new Idler();
idler = new IdlerImpl();
dispatchAndUpdateToolBarStatus(msg: string) {
this.store.dispatch(

View File

@@ -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<Props, State> {
return this.context.store;
}
idler = new Idler();
idler = new IdlerImpl();
dispatchAndUpdateToolBarStatus(msg: string) {
this.store.dispatch(

View File

@@ -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;

View File

@@ -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<Record<string, any>> {
const pluginStates: Record<string, any> = {};
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,
};
}

View File

@@ -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';

View File

@@ -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};

View File

@@ -9,15 +9,9 @@
import {CancelledPromiseError} from './errors';
import {sleep} from './promiseTimeout';
import {Idler} from 'flipper-plugin';
export interface BaseIdler {
shouldIdle(): boolean;
idle(): Promise<void>;
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;

View File

@@ -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);

View File

@@ -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<any, any, any> {}
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,
},
},
},
"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},
});
});

View File

@@ -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<ClientExport>;
pluginStates: PluginStatesExportState;
pluginStates2: SandyPluginStates;
devicePluginStates: Record<string, any>;
pluginNotification: Array<PluginNotification>;
selectedPlugins: Array<string>;
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<SandyPluginStates> {
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<ExportType> {
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<ExportType> {
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: {

View File

@@ -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<boolean> {
if (!_SandyPluginInstance.is(plugin) && !plugin.persistedStateReducer) {
return true;

View File

@@ -22,13 +22,13 @@ export const stateSanitizer = (state: State) => {
return {
...device.toJSON(),
logs: [],
};
} as any;
}),
selectedDevice: selectedDevice
? {
? ({
...selectedDevice.toJSON(),
logs: [],
}
} as any)
: null,
},
};

View File

@@ -7,7 +7,8 @@
* @format
*/
import {Idler} from './Idler';
import {Idler} from 'flipper-plugin';
export async function serialize(
obj: Object,
idler?: Idler,

View File

@@ -59,6 +59,7 @@ test('Correct top level API exposed', () => {
"DeviceType",
"Draft",
"FlipperLib",
"Idler",
"LogLevel",
"LogTypes",
"Logger",

View File

@@ -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.

View File

@@ -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<Record<string, any>>;
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<string, Atom<any>> = {};
// 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<Record<string, any>> {
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() {

View File

@@ -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<typeof queries>;
@@ -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<string, any>;
/**
* Grab all the persistable state, respecting onExport handlers
*/
exportStateAsync(): Promise<Record<string, any>>;
/**
* 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;
},
};
}

View File

@@ -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<void>;
cancel(): void;
isCancelled(): boolean;
}

View File

@@ -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<state>)`
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`