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:
committed by
Facebook GitHub Bot
parent
32bde8cace
commit
34c915a739
@@ -43,7 +43,7 @@ import {selectPlugin} from './reducers/connections';
|
|||||||
import {State as Store, MiddlewareAPI} from './reducers/index';
|
import {State as Store, MiddlewareAPI} from './reducers/index';
|
||||||
import {activateMenuItems} from './MenuBar';
|
import {activateMenuItems} from './MenuBar';
|
||||||
import {Message} from './reducers/pluginMessageQueue';
|
import {Message} from './reducers/pluginMessageQueue';
|
||||||
import {Idler} from './utils/Idler';
|
import {IdlerImpl} from './utils/Idler';
|
||||||
import {processMessageQueue} from './utils/messageQueue';
|
import {processMessageQueue} from './utils/messageQueue';
|
||||||
import {ToggleButton, SmallText, Layout} from './ui';
|
import {ToggleButton, SmallText, Layout} from './ui';
|
||||||
import {theme, TrackingScope, _SandyPluginRenderer} from 'flipper-plugin';
|
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;
|
pluginBeingProcessed: string | null = null;
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
@@ -237,7 +237,7 @@ class PluginContainer extends PureComponent<Props, State> {
|
|||||||
pendingMessages?.length
|
pendingMessages?.length
|
||||||
) {
|
) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
this.idler = new Idler();
|
this.idler = new IdlerImpl();
|
||||||
processMessageQueue(
|
processMessageQueue(
|
||||||
isSandyPlugin(activePlugin)
|
isSandyPlugin(activePlugin)
|
||||||
? target.sandyPluginStates.get(activePlugin.id)!
|
? target.sandyPluginStates.get(activePlugin.id)!
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ Object {
|
|||||||
"deviceType": "physical",
|
"deviceType": "physical",
|
||||||
"logs": Array [],
|
"logs": Array [],
|
||||||
"os": "Android",
|
"os": "Android",
|
||||||
"pluginStates": Object {},
|
|
||||||
"serial": "serial",
|
"serial": "serial",
|
||||||
"title": "MockAndroidDevice",
|
"title": "MockAndroidDevice",
|
||||||
},
|
},
|
||||||
@@ -31,7 +30,6 @@ Object {
|
|||||||
"deviceType": "physical",
|
"deviceType": "physical",
|
||||||
"logs": Array [],
|
"logs": Array [],
|
||||||
"os": "Android",
|
"os": "Android",
|
||||||
"pluginStates": Object {},
|
|
||||||
"serial": "serial",
|
"serial": "serial",
|
||||||
"title": "MockAndroidDevice",
|
"title": "MockAndroidDevice",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {reportPlatformFailures} from '../utils/metrics';
|
|||||||
import CancellableExportStatus from './CancellableExportStatus';
|
import CancellableExportStatus from './CancellableExportStatus';
|
||||||
import {performance} from 'perf_hooks';
|
import {performance} from 'perf_hooks';
|
||||||
import {Logger} from '../fb-interfaces/Logger';
|
import {Logger} from '../fb-interfaces/Logger';
|
||||||
import {Idler} from '../utils/Idler';
|
import {IdlerImpl} from '../utils/Idler';
|
||||||
import {
|
import {
|
||||||
exportStoreToFile,
|
exportStoreToFile,
|
||||||
EXPORT_FLIPPER_TRACE_EVENT,
|
EXPORT_FLIPPER_TRACE_EVENT,
|
||||||
@@ -86,7 +86,7 @@ export default class ShareSheetExportFile extends Component<Props, State> {
|
|||||||
runInBackground: false,
|
runInBackground: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
idler = new Idler();
|
idler = new IdlerImpl();
|
||||||
|
|
||||||
dispatchAndUpdateToolBarStatus(msg: string) {
|
dispatchAndUpdateToolBarStatus(msg: string) {
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
setExportURL,
|
setExportURL,
|
||||||
} from '../reducers/application';
|
} from '../reducers/application';
|
||||||
import {Logger} from '../fb-interfaces/Logger';
|
import {Logger} from '../fb-interfaces/Logger';
|
||||||
import {Idler} from '../utils/Idler';
|
import {IdlerImpl} from '../utils/Idler';
|
||||||
import {
|
import {
|
||||||
shareFlipperData,
|
shareFlipperData,
|
||||||
DataExportResult,
|
DataExportResult,
|
||||||
@@ -95,7 +95,7 @@ export default class ShareSheetExportUrl extends Component<Props, State> {
|
|||||||
return this.context.store;
|
return this.context.store;
|
||||||
}
|
}
|
||||||
|
|
||||||
idler = new Idler();
|
idler = new IdlerImpl();
|
||||||
|
|
||||||
dispatchAndUpdateToolBarStatus(msg: string) {
|
dispatchAndUpdateToolBarStatus(msg: string) {
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {List, Map as ImmutableMap} from 'immutable';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {KeyboardActions} from './MenuBar';
|
import {KeyboardActions} from './MenuBar';
|
||||||
import {TableBodyRow} from './ui';
|
import {TableBodyRow} from './ui';
|
||||||
import {Idler} from './utils/Idler';
|
import {Idler} from 'flipper-plugin';
|
||||||
|
|
||||||
type ID = string;
|
type ID = string;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
_SandyPluginDefinition,
|
_SandyPluginDefinition,
|
||||||
DeviceType,
|
DeviceType,
|
||||||
DeviceLogListener,
|
DeviceLogListener,
|
||||||
|
Idler,
|
||||||
} from 'flipper-plugin';
|
} from 'flipper-plugin';
|
||||||
import type {DevicePluginDefinition, DevicePluginMap} from '../plugin';
|
import type {DevicePluginDefinition, DevicePluginMap} from '../plugin';
|
||||||
import {getFlipperLibImplementation} from '../utils/flipperLibImplementation';
|
import {getFlipperLibImplementation} from '../utils/flipperLibImplementation';
|
||||||
@@ -91,22 +92,31 @@ export default class BaseDevice {
|
|||||||
return this.title;
|
return this.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON(): DeviceExport {
|
async exportState(
|
||||||
|
idler: Idler,
|
||||||
|
onStatusMessage: (msg: string) => void,
|
||||||
|
): Promise<Record<string, any>> {
|
||||||
const pluginStates: Record<string, any> = {};
|
const pluginStates: Record<string, any> = {};
|
||||||
|
|
||||||
for (const instance of this.sandyPluginStates.values()) {
|
for (const instance of this.sandyPluginStates.values()) {
|
||||||
if (instance.isPersistable()) {
|
if (instance.isPersistable()) {
|
||||||
pluginStates[instance.definition.id] = instance.exportState();
|
pluginStates[instance.definition.id] = await instance.exportState(
|
||||||
|
idler,
|
||||||
|
onStatusMessage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return pluginStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
os: this.os,
|
os: this.os,
|
||||||
title: this.title,
|
title: this.title,
|
||||||
deviceType: this.deviceType,
|
deviceType: this.deviceType,
|
||||||
serial: this.serial,
|
serial: this.serial,
|
||||||
logs: this.getLogs(),
|
logs: this.getLogs(),
|
||||||
pluginStates,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export {connect} from 'react-redux';
|
|||||||
export {selectPlugin, StaticView} from './reducers/connections';
|
export {selectPlugin, StaticView} from './reducers/connections';
|
||||||
export {writeBufferToFile, bufferToBlob} from './utils/screenshot';
|
export {writeBufferToFile, bufferToBlob} from './utils/screenshot';
|
||||||
export {getPluginKey, getPersistedState} from './utils/pluginUtils';
|
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 {Store, MiddlewareAPI, State as ReduxState} from './reducers/index';
|
||||||
export {default as BaseDevice} from './devices/BaseDevice';
|
export {default as BaseDevice} from './devices/BaseDevice';
|
||||||
export {DeviceLogEntry, LogLevel, DeviceLogListener} from 'flipper-plugin';
|
export {DeviceLogEntry, LogLevel, DeviceLogListener} from 'flipper-plugin';
|
||||||
|
|||||||
@@ -14,13 +14,12 @@ import {Store} from './reducers/index';
|
|||||||
import {ReactNode, Component} from 'react';
|
import {ReactNode, Component} from 'react';
|
||||||
import BaseDevice from './devices/BaseDevice';
|
import BaseDevice from './devices/BaseDevice';
|
||||||
import {serialize, deserialize} from './utils/serialization';
|
import {serialize, deserialize} from './utils/serialization';
|
||||||
import {Idler} from './utils/Idler';
|
|
||||||
import {StaticView} from './reducers/connections';
|
import {StaticView} from './reducers/connections';
|
||||||
import {State as ReduxState} from './reducers';
|
import {State as ReduxState} from './reducers';
|
||||||
import {DEFAULT_MAX_QUEUE_SIZE} from './reducers/pluginMessageQueue';
|
import {DEFAULT_MAX_QUEUE_SIZE} from './reducers/pluginMessageQueue';
|
||||||
import {ActivatablePluginDetails} from 'flipper-plugin-lib';
|
import {ActivatablePluginDetails} from 'flipper-plugin-lib';
|
||||||
import {Settings} from './reducers/settings';
|
import {Settings} from './reducers/settings';
|
||||||
import {_SandyPluginDefinition} from 'flipper-plugin';
|
import {Idler, _SandyPluginDefinition} from 'flipper-plugin';
|
||||||
|
|
||||||
type Parameters = {[key: string]: any};
|
type Parameters = {[key: string]: any};
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,9 @@
|
|||||||
|
|
||||||
import {CancelledPromiseError} from './errors';
|
import {CancelledPromiseError} from './errors';
|
||||||
import {sleep} from './promiseTimeout';
|
import {sleep} from './promiseTimeout';
|
||||||
|
import {Idler} from 'flipper-plugin';
|
||||||
|
|
||||||
export interface BaseIdler {
|
export class IdlerImpl implements Idler {
|
||||||
shouldIdle(): boolean;
|
|
||||||
idle(): Promise<void>;
|
|
||||||
cancel(): void;
|
|
||||||
isCancelled(): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Idler implements BaseIdler {
|
|
||||||
private lastIdle = performance.now();
|
private lastIdle = performance.now();
|
||||||
private kill = false;
|
private kill = false;
|
||||||
|
|
||||||
@@ -57,12 +51,16 @@ export class Idler implements BaseIdler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This smills like we should be using generators :)
|
// This smills like we should be using generators :)
|
||||||
export class TestIdler implements BaseIdler {
|
export class TestIdler implements Idler {
|
||||||
private resolver?: () => void;
|
private resolver?: () => void;
|
||||||
private kill = false;
|
private kill = false;
|
||||||
private autoRun = false;
|
private autoRun = false;
|
||||||
private hasProgressed = false;
|
private hasProgressed = false;
|
||||||
|
|
||||||
|
constructor(autorun = false) {
|
||||||
|
this.autoRun = autorun;
|
||||||
|
}
|
||||||
|
|
||||||
shouldIdle() {
|
shouldIdle() {
|
||||||
if (this.kill) {
|
if (this.kill) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Idler, TestIdler} from '../Idler.tsx';
|
import {IdlerImpl, TestIdler} from '../Idler.tsx';
|
||||||
import {sleep} from '../promiseTimeout.tsx';
|
import {sleep} from '../promiseTimeout.tsx';
|
||||||
|
|
||||||
test('Idler should interrupt', async () => {
|
test('Idler should interrupt', async () => {
|
||||||
const idler = new Idler();
|
const idler = new IdlerImpl();
|
||||||
let i = 0;
|
let i = 0;
|
||||||
try {
|
try {
|
||||||
for (; i < 500; i++) {
|
for (; i < 500; i++) {
|
||||||
@@ -30,7 +30,7 @@ test('Idler should interrupt', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Idler should want to idle', async () => {
|
test('Idler should want to idle', async () => {
|
||||||
const idler = new Idler(100);
|
const idler = new IdlerImpl(100);
|
||||||
expect(idler.shouldIdle()).toBe(false);
|
expect(idler.shouldIdle()).toBe(false);
|
||||||
await sleep(10);
|
await sleep(10);
|
||||||
expect(idler.shouldIdle()).toBe(false);
|
expect(idler.shouldIdle()).toBe(false);
|
||||||
|
|||||||
@@ -28,8 +28,16 @@ import {
|
|||||||
createState,
|
createState,
|
||||||
PluginClient,
|
PluginClient,
|
||||||
DevicePluginClient,
|
DevicePluginClient,
|
||||||
|
sleep,
|
||||||
} from 'flipper-plugin';
|
} from 'flipper-plugin';
|
||||||
import {selectPlugin} from '../../reducers/connections';
|
import {selectPlugin} from '../../reducers/connections';
|
||||||
|
import {TestIdler} from '../Idler';
|
||||||
|
|
||||||
|
const testIdler = new TestIdler();
|
||||||
|
|
||||||
|
function testOnStatusMessage() {
|
||||||
|
// emtpy stub
|
||||||
|
}
|
||||||
|
|
||||||
class TestPlugin extends FlipperPlugin<any, any, any> {}
|
class TestPlugin extends FlipperPlugin<any, any, any> {}
|
||||||
TestPlugin.title = 'TestPlugin';
|
TestPlugin.title = 'TestPlugin';
|
||||||
@@ -1103,7 +1111,20 @@ const sandyTestPlugin = new _SandyPluginDefinition(
|
|||||||
draft.testCount -= 1;
|
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() {
|
Component() {
|
||||||
return null;
|
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 () => {
|
test('Sandy plugins are imported properly', async () => {
|
||||||
const data = {
|
const data = {
|
||||||
clients: [
|
clients: [
|
||||||
@@ -1190,7 +1244,7 @@ test('Sandy plugins are imported properly', async () => {
|
|||||||
expect(client2).not.toBe(client);
|
expect(client2).not.toBe(client);
|
||||||
expect(client2.plugins).toEqual([TestPlugin.id]);
|
expect(client2.plugins).toEqual([TestPlugin.id]);
|
||||||
|
|
||||||
expect(client.sandyPluginStates.get(TestPlugin.id)!.exportState())
|
expect(client.sandyPluginStates.get(TestPlugin.id)!.exportStateSync())
|
||||||
.toMatchInlineSnapshot(`
|
.toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
"counter": 0,
|
"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(`
|
.toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
"counter": 3,
|
"counter": 3,
|
||||||
@@ -1227,6 +1281,18 @@ const sandyDeviceTestPlugin = new _SandyPluginDefinition(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
counter,
|
counter,
|
||||||
|
enableCustomExport() {
|
||||||
|
client.onExport(async (idler, onStatus) => {
|
||||||
|
if (idler.shouldIdle()) {
|
||||||
|
await idler.idle();
|
||||||
|
}
|
||||||
|
onStatus('hi');
|
||||||
|
await sleep(100);
|
||||||
|
return {
|
||||||
|
customExport: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
Component() {
|
Component() {
|
||||||
@@ -1277,7 +1343,9 @@ test('Sandy device plugins are exported / imported properly', async () => {
|
|||||||
const {counter} = device2.sandyPluginStates.get(TestPlugin.id)?.instanceApi;
|
const {counter} = device2.sandyPluginStates.get(TestPlugin.id)?.instanceApi;
|
||||||
counter.set(counter.get() + 1);
|
counter.set(counter.get() + 1);
|
||||||
|
|
||||||
expect(device.toJSON().pluginStates[TestPlugin.id]).toMatchInlineSnapshot(`
|
expect(
|
||||||
|
(await device.exportState(testIdler, testOnStatusMessage))[TestPlugin.id],
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
"counter": 0,
|
"counter": 0,
|
||||||
"otherState": Object {
|
"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 {
|
Object {
|
||||||
"deviceType": "archivedPhysical",
|
"TestPlugin": Object {
|
||||||
"logs": Array [],
|
"counter": 4,
|
||||||
"os": "Android",
|
"otherState": Object {
|
||||||
"pluginStates": 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},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {readCurrentRevision} from './packageMetadata';
|
|||||||
import {tryCatchReportPlatformFailures} from './metrics';
|
import {tryCatchReportPlatformFailures} from './metrics';
|
||||||
import {promisify} from 'util';
|
import {promisify} from 'util';
|
||||||
import promiseTimeout from './promiseTimeout';
|
import promiseTimeout from './promiseTimeout';
|
||||||
import {Idler} from './Idler';
|
import {TestIdler} from './Idler';
|
||||||
import {setStaticView} from '../reducers/connections';
|
import {setStaticView} from '../reducers/connections';
|
||||||
import {
|
import {
|
||||||
resetSupportFormV2State,
|
resetSupportFormV2State,
|
||||||
@@ -48,6 +48,7 @@ import {processMessageQueue} from './messageQueue';
|
|||||||
import {getPluginTitle} from './pluginUtils';
|
import {getPluginTitle} from './pluginUtils';
|
||||||
import {capture} from './screenshot';
|
import {capture} from './screenshot';
|
||||||
import {uploadFlipperMedia} from '../fb-stubs/user';
|
import {uploadFlipperMedia} from '../fb-stubs/user';
|
||||||
|
import {Idler} from 'flipper-plugin';
|
||||||
|
|
||||||
export const IMPORT_FLIPPER_TRACE_EVENT = 'import-flipper-trace';
|
export const IMPORT_FLIPPER_TRACE_EVENT = 'import-flipper-trace';
|
||||||
export const EXPORT_FLIPPER_TRACE_EVENT = 'export-flipper-trace';
|
export const EXPORT_FLIPPER_TRACE_EVENT = 'export-flipper-trace';
|
||||||
@@ -109,9 +110,11 @@ type AddSaltToDeviceSerialOptions = {
|
|||||||
clients: Array<ClientExport>;
|
clients: Array<ClientExport>;
|
||||||
pluginStates: PluginStatesExportState;
|
pluginStates: PluginStatesExportState;
|
||||||
pluginStates2: SandyPluginStates;
|
pluginStates2: SandyPluginStates;
|
||||||
|
devicePluginStates: Record<string, any>;
|
||||||
pluginNotification: Array<PluginNotification>;
|
pluginNotification: Array<PluginNotification>;
|
||||||
selectedPlugins: Array<string>;
|
selectedPlugins: Array<string>;
|
||||||
statusUpdate?: (msg: string) => void;
|
statusUpdate: (msg: string) => void;
|
||||||
|
idler: Idler;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function displayFetchMetadataErrors(
|
export function displayFetchMetadataErrors(
|
||||||
@@ -251,20 +254,23 @@ const serializePluginStates = async (
|
|||||||
return pluginExportState;
|
return pluginExportState;
|
||||||
};
|
};
|
||||||
|
|
||||||
function exportSandyPluginStates(
|
async function exportSandyPluginStates(
|
||||||
pluginsToProcess: PluginsToProcess,
|
pluginsToProcess: PluginsToProcess,
|
||||||
): SandyPluginStates {
|
idler: Idler,
|
||||||
|
statusUpdate: (msg: string) => void,
|
||||||
|
): Promise<SandyPluginStates> {
|
||||||
const res: 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 (isSandyPlugin(pluginClass) && client.sandyPluginStates.has(pluginId)) {
|
||||||
if (!res[client.id]) {
|
if (!res[client.id]) {
|
||||||
res[client.id] = {};
|
res[client.id] = {};
|
||||||
}
|
}
|
||||||
res[client.id][pluginId] = client.sandyPluginStates
|
res[client.id][pluginId] = await client.sandyPluginStates
|
||||||
.get(pluginId)!
|
.get(pluginId)!
|
||||||
.exportState();
|
.exportState(idler, statusUpdate);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +328,8 @@ async function addSaltToDeviceSerial({
|
|||||||
statusUpdate,
|
statusUpdate,
|
||||||
selectedPlugins,
|
selectedPlugins,
|
||||||
pluginStates2,
|
pluginStates2,
|
||||||
|
devicePluginStates,
|
||||||
|
idler,
|
||||||
}: AddSaltToDeviceSerialOptions): Promise<ExportType> {
|
}: AddSaltToDeviceSerialOptions): Promise<ExportType> {
|
||||||
const {serial} = device;
|
const {serial} = device;
|
||||||
const newSerial = salt + '-' + serial;
|
const newSerial = salt + '-' + serial;
|
||||||
@@ -379,7 +387,7 @@ async function addSaltToDeviceSerial({
|
|||||||
fileVersion: remote.app.getVersion(),
|
fileVersion: remote.app.getVersion(),
|
||||||
flipperReleaseRevision: revision,
|
flipperReleaseRevision: revision,
|
||||||
clients: updatedClients,
|
clients: updatedClients,
|
||||||
device: newDevice.toJSON(),
|
device: {...newDevice.toJSON(), pluginStates: devicePluginStates},
|
||||||
deviceScreenshot: deviceScreenshot,
|
deviceScreenshot: deviceScreenshot,
|
||||||
store: {
|
store: {
|
||||||
pluginStates: updatedPluginStates,
|
pluginStates: updatedPluginStates,
|
||||||
@@ -415,11 +423,14 @@ export async function processStore(
|
|||||||
selectedPlugins,
|
selectedPlugins,
|
||||||
statusUpdate,
|
statusUpdate,
|
||||||
}: ProcessStoreOptions,
|
}: ProcessStoreOptions,
|
||||||
idler?: Idler,
|
idler: Idler = new TestIdler(true),
|
||||||
): Promise<ExportType> {
|
): Promise<ExportType> {
|
||||||
if (device) {
|
if (device) {
|
||||||
const {serial} = device;
|
const {serial} = device;
|
||||||
statusUpdate && statusUpdate('Capturing screenshot...');
|
if (!statusUpdate) {
|
||||||
|
statusUpdate = () => {};
|
||||||
|
}
|
||||||
|
statusUpdate('Capturing screenshot...');
|
||||||
const deviceScreenshot = await capture(device).catch((e) => {
|
const deviceScreenshot = await capture(device).catch((e) => {
|
||||||
console.warn('Failed to capture device screenshot when exporting. ' + e);
|
console.warn('Failed to capture device screenshot when exporting. ' + e);
|
||||||
return null;
|
return null;
|
||||||
@@ -449,7 +460,9 @@ export async function processStore(
|
|||||||
idler,
|
idler,
|
||||||
);
|
);
|
||||||
|
|
||||||
statusUpdate && statusUpdate('Uploading screenshot...');
|
const devicePluginStates = await device.exportState(idler, statusUpdate);
|
||||||
|
|
||||||
|
statusUpdate('Uploading screenshot...');
|
||||||
const deviceScreenshotLink =
|
const deviceScreenshotLink =
|
||||||
deviceScreenshot &&
|
deviceScreenshot &&
|
||||||
(await uploadFlipperMedia(deviceScreenshot, 'Image').catch((e) => {
|
(await uploadFlipperMedia(deviceScreenshot, 'Image').catch((e) => {
|
||||||
@@ -467,6 +480,8 @@ export async function processStore(
|
|||||||
statusUpdate,
|
statusUpdate,
|
||||||
selectedPlugins,
|
selectedPlugins,
|
||||||
pluginStates2,
|
pluginStates2,
|
||||||
|
devicePluginStates,
|
||||||
|
idler,
|
||||||
});
|
});
|
||||||
|
|
||||||
return exportFlipperData;
|
return exportFlipperData;
|
||||||
@@ -478,8 +493,8 @@ export async function fetchMetadata(
|
|||||||
pluginsToProcess: PluginsToProcess,
|
pluginsToProcess: PluginsToProcess,
|
||||||
pluginStates: PluginStatesState,
|
pluginStates: PluginStatesState,
|
||||||
state: ReduxState,
|
state: ReduxState,
|
||||||
statusUpdate?: (msg: string) => void,
|
statusUpdate: (msg: string) => void,
|
||||||
idler?: Idler,
|
idler: Idler,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
pluginStates: PluginStatesState;
|
pluginStates: PluginStatesState;
|
||||||
errors: {[plugin: string]: Error} | null;
|
errors: {[plugin: string]: Error} | null;
|
||||||
@@ -626,8 +641,8 @@ export function determinePluginsToProcess(
|
|||||||
|
|
||||||
async function getStoreExport(
|
async function getStoreExport(
|
||||||
store: MiddlewareAPI,
|
store: MiddlewareAPI,
|
||||||
statusUpdate?: (msg: string) => void,
|
statusUpdate: (msg: string) => void = () => {},
|
||||||
idler?: Idler,
|
idler: Idler,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
exportData: ExportType;
|
exportData: ExportType;
|
||||||
fetchMetaDataErrors: {[plugin: string]: Error} | null;
|
fetchMetaDataErrors: {[plugin: string]: Error} | null;
|
||||||
@@ -657,10 +672,8 @@ async function getStoreExport(
|
|||||||
);
|
);
|
||||||
const newPluginState = metadata.pluginStates;
|
const newPluginState = metadata.pluginStates;
|
||||||
|
|
||||||
// TODO: support async export like fetchMetaData T68683476
|
|
||||||
// TODO: support device plugins T70582933
|
|
||||||
const pluginStates2 = pluginsToProcess
|
const pluginStates2 = pluginsToProcess
|
||||||
? exportSandyPluginStates(pluginsToProcess)
|
? await exportSandyPluginStates(pluginsToProcess, idler, statusUpdate)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
getLogger().trackTimeSince(fetchMetaDataMarker, fetchMetaDataMarker, {
|
getLogger().trackTimeSince(fetchMetaDataMarker, fetchMetaDataMarker, {
|
||||||
@@ -691,8 +704,8 @@ async function getStoreExport(
|
|||||||
export async function exportStore(
|
export async function exportStore(
|
||||||
store: MiddlewareAPI,
|
store: MiddlewareAPI,
|
||||||
includeSupportDetails?: boolean,
|
includeSupportDetails?: boolean,
|
||||||
idler?: Idler,
|
idler: Idler = new TestIdler(true),
|
||||||
statusUpdate?: (msg: string) => void,
|
statusUpdate: (msg: string) => void = () => {},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
serializedString: string;
|
serializedString: string;
|
||||||
fetchMetaDataErrors: {
|
fetchMetaDataErrors: {
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ import {
|
|||||||
Message,
|
Message,
|
||||||
DEFAULT_MAX_QUEUE_SIZE,
|
DEFAULT_MAX_QUEUE_SIZE,
|
||||||
} from '../reducers/pluginMessageQueue';
|
} from '../reducers/pluginMessageQueue';
|
||||||
import {Idler, BaseIdler} from './Idler';
|
import {IdlerImpl} from './Idler';
|
||||||
import {pluginIsStarred, getSelectedPluginKey} from '../reducers/connections';
|
import {pluginIsStarred, getSelectedPluginKey} from '../reducers/connections';
|
||||||
import {deconstructPluginKey} from './clientUtils';
|
import {deconstructPluginKey} from './clientUtils';
|
||||||
import {defaultEnabledBackgroundPlugins} from './pluginUtils';
|
import {defaultEnabledBackgroundPlugins} from './pluginUtils';
|
||||||
import {batch, _SandyPluginInstance} from 'flipper-plugin';
|
import {batch, Idler, _SandyPluginInstance} from 'flipper-plugin';
|
||||||
import {addBackgroundStat} from './pluginStats';
|
import {addBackgroundStat} from './pluginStats';
|
||||||
|
|
||||||
function processMessageClassic(
|
function processMessageClassic(
|
||||||
@@ -168,7 +168,7 @@ export async function processMessageQueue(
|
|||||||
pluginKey: string,
|
pluginKey: string,
|
||||||
store: MiddlewareAPI,
|
store: MiddlewareAPI,
|
||||||
progressCallback?: (progress: {current: number; total: number}) => void,
|
progressCallback?: (progress: {current: number; total: number}) => void,
|
||||||
idler: BaseIdler = new Idler(),
|
idler: Idler = new IdlerImpl(),
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!_SandyPluginInstance.is(plugin) && !plugin.persistedStateReducer) {
|
if (!_SandyPluginInstance.is(plugin) && !plugin.persistedStateReducer) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ export const stateSanitizer = (state: State) => {
|
|||||||
return {
|
return {
|
||||||
...device.toJSON(),
|
...device.toJSON(),
|
||||||
logs: [],
|
logs: [],
|
||||||
};
|
} as any;
|
||||||
}),
|
}),
|
||||||
selectedDevice: selectedDevice
|
selectedDevice: selectedDevice
|
||||||
? {
|
? ({
|
||||||
...selectedDevice.toJSON(),
|
...selectedDevice.toJSON(),
|
||||||
logs: [],
|
logs: [],
|
||||||
}
|
} as any)
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Idler} from './Idler';
|
import {Idler} from 'flipper-plugin';
|
||||||
|
|
||||||
export async function serialize(
|
export async function serialize(
|
||||||
obj: Object,
|
obj: Object,
|
||||||
idler?: Idler,
|
idler?: Idler,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ test('Correct top level API exposed', () => {
|
|||||||
"DeviceType",
|
"DeviceType",
|
||||||
"Draft",
|
"Draft",
|
||||||
"FlipperLib",
|
"FlipperLib",
|
||||||
|
"Idler",
|
||||||
"LogLevel",
|
"LogLevel",
|
||||||
"LogTypes",
|
"LogTypes",
|
||||||
"Logger",
|
"Logger",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export {
|
|||||||
useLogger,
|
useLogger,
|
||||||
_LoggerContext,
|
_LoggerContext,
|
||||||
} from './utils/Logger';
|
} from './utils/Logger';
|
||||||
|
export {Idler} from './utils/Idler';
|
||||||
|
|
||||||
// It's not ideal that this exists in flipper-plugin sources directly,
|
// It's not ideal that this exists in flipper-plugin sources directly,
|
||||||
// but is the least pain for plugin authors.
|
// but is the least pain for plugin authors.
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import {MenuEntry, NormalizedMenuEntry, normalizeMenuEntry} from './MenuEntry';
|
|||||||
import {FlipperLib} from './FlipperLib';
|
import {FlipperLib} from './FlipperLib';
|
||||||
import {Device, RealFlipperDevice} from './DevicePlugin';
|
import {Device, RealFlipperDevice} from './DevicePlugin';
|
||||||
import {batched} from '../state/batch';
|
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 {
|
export interface BasePluginClient {
|
||||||
readonly device: Device;
|
readonly device: Device;
|
||||||
@@ -38,6 +44,12 @@ export interface BasePluginClient {
|
|||||||
*/
|
*/
|
||||||
onDeepLink(cb: (deepLink: unknown) => void): void;
|
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
|
* Register menu entries in the Flipper toolbar
|
||||||
*/
|
*/
|
||||||
@@ -88,6 +100,8 @@ export abstract class BasePluginInstance {
|
|||||||
rootStates: Record<string, Atom<any>> = {};
|
rootStates: Record<string, Atom<any>> = {};
|
||||||
// last seen deeplink
|
// last seen deeplink
|
||||||
lastDeeplink?: any;
|
lastDeeplink?: any;
|
||||||
|
// export handler
|
||||||
|
exportHandler?: StateExportHandler;
|
||||||
|
|
||||||
menuEntries: NormalizedMenuEntry[] = [];
|
menuEntries: NormalizedMenuEntry[] = [];
|
||||||
|
|
||||||
@@ -145,6 +159,12 @@ export abstract class BasePluginInstance {
|
|||||||
onDestroy: (cb) => {
|
onDestroy: (cb) => {
|
||||||
this.events.on('destroy', batched(cb));
|
this.events.on('destroy', batched(cb));
|
||||||
},
|
},
|
||||||
|
onExport: (cb) => {
|
||||||
|
if (this.exportHandler) {
|
||||||
|
throw new Error('onExport handler already set');
|
||||||
|
}
|
||||||
|
this.exportHandler = cb;
|
||||||
|
},
|
||||||
addMenuEntry: (...entries) => {
|
addMenuEntry: (...entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const normalized = normalizeMenuEntry(entry);
|
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(
|
return Object.fromEntries(
|
||||||
Object.entries(this.rootStates).map(([key, atom]) => [key, atom.get()]),
|
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 {
|
isPersistable(): boolean {
|
||||||
return Object.keys(this.rootStates).length > 0;
|
return !!this.exportHandler || Object.keys(this.rootStates).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected assertNotDestroyed() {
|
protected assertNotDestroyed() {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
import {BasePluginInstance} from '../plugin/PluginBase';
|
import {BasePluginInstance} from '../plugin/PluginBase';
|
||||||
import {FlipperLib} from '../plugin/FlipperLib';
|
import {FlipperLib} from '../plugin/FlipperLib';
|
||||||
import {stubLogger} from '../utils/Logger';
|
import {stubLogger} from '../utils/Logger';
|
||||||
|
import {Idler} from '../utils/Idler';
|
||||||
|
|
||||||
type Renderer = RenderResult<typeof queries>;
|
type Renderer = RenderResult<typeof queries>;
|
||||||
|
|
||||||
@@ -95,9 +96,14 @@ interface BasePluginResult {
|
|||||||
triggerDeepLink(deeplink: unknown): void;
|
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
|
* Trigger menu entry by label
|
||||||
@@ -367,7 +373,9 @@ function createBasePluginResult(
|
|||||||
flipperLib: pluginInstance.flipperLib,
|
flipperLib: pluginInstance.flipperLib,
|
||||||
activate: () => pluginInstance.activate(),
|
activate: () => pluginInstance.activate(),
|
||||||
deactivate: () => pluginInstance.deactivate(),
|
deactivate: () => pluginInstance.deactivate(),
|
||||||
exportState: () => pluginInstance.exportState(),
|
exportStateAsync: () =>
|
||||||
|
pluginInstance.exportState(createStubIdler(), () => {}),
|
||||||
|
exportState: () => pluginInstance.exportStateSync(),
|
||||||
triggerDeepLink: (deepLink: unknown) => {
|
triggerDeepLink: (deepLink: unknown) => {
|
||||||
pluginInstance.triggerDeepLink(deepLink);
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
15
desktop/flipper-plugin/src/utils/Idler.tsx
Normal file
15
desktop/flipper-plugin/src/utils/Idler.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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.
|
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
|
### Methods
|
||||||
|
|
||||||
#### `send`
|
#### `send`
|
||||||
@@ -277,6 +284,14 @@ See the similarly named event under [`PluginClient`](#pluginclient).
|
|||||||
|
|
||||||
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
|
### Methods
|
||||||
|
|
||||||
#### `addMenuEntry`
|
#### `addMenuEntry`
|
||||||
|
|||||||
Reference in New Issue
Block a user