Files
flipper/desktop/flipper-plugin/src/plugin/PluginBase.tsx
Andrey Goncharov b80755721c Draft communication with server add-ons from the client side
Reviewed By: nikoant

Differential Revision: D34075379

fbshipit-source-id: 09f575f5cced866ad7b9290d7739ce60f38edeee
2022-02-28 03:50:34 -08:00

528 lines
16 KiB
TypeScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {message} from 'antd';
import EventEmitter from 'eventemitter3';
import {SandyPluginDefinition} from './SandyPluginDefinition';
import {MenuEntry, NormalizedMenuEntry, normalizeMenuEntry} from './MenuEntry';
import {FlipperLib} from './FlipperLib';
import {CrashLogListener, Device, DeviceLogListener} from './DevicePlugin';
import {batched} from '../state/batch';
import {Idler} from '../utils/Idler';
import {Notification} from './Notification';
import {Logger} from '../utils/Logger';
import {CreatePasteArgs, CreatePasteResult} from './Paste';
import {ServerAddOnControls} from 'flipper-common';
export type EventsContract = Record<string, any>;
export type MethodsContract = Record<string, (params: any) => Promise<any>>;
type StateExportHandler<T = any> = (
idler: Idler,
onStatusMessage: (msg: string) => void,
) => Promise<T | undefined | void>;
type StateImportHandler<T = any> = (data: T) => void;
export interface BasePluginClient<
ServerAddOnEvents extends EventsContract = {},
ServerAddOnMethods extends MethodsContract = {},
> {
/**
* A key that uniquely identifies this plugin instance, captures the current device/client/plugin combination.
*/
readonly pluginKey: string;
readonly device: Device;
/**
* the onDestroy event is fired whenever a device is unloaded from Flipper, or a plugin is disabled.
*/
onDestroy(cb: () => void): void;
/**
* the onActivate event is fired whenever the plugin is actived in the UI
*/
onActivate(cb: () => void): void;
/**
* The counterpart of the `onActivate` handler.
*/
onDeactivate(cb: () => void): void;
/**
* Triggered when this plugin is opened through a deeplink
*/
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.
*
* If an object is returned from the handler, that will be taken as export.
* Otherwise, if nothing is returned, the handler will be run, and after the handler has finished the `persist` keys of the different states will be used as export basis.
*/
onExport<T extends object>(exporter: StateExportHandler<T>): void;
/**
* Triggered directly after the plugin instance was created, if the plugin is being restored from a snapshot.
* Should be the inverse of the onExport handler
*/
onImport<T = any>(handler: StateImportHandler<T>): void;
/**
* The `onReady` event is triggered immediately after a plugin has been initialized and any pending state was restored.
* This event fires after `onImport` / the interpretation of any `persist` flags and indicates that the initialization process has finished.
* This event does not signal that the plugin is loaded in the UI yet (see `onActivated`) and does fire before deeplinks (see `onDeepLink`) are handled.
*/
onReady(handler: () => void): void;
/**
* Register menu entries in the Flipper toolbar
*/
addMenuEntry(...entry: MenuEntry[]): void;
/**
* Listener that is triggered if the underlying device emits a log message.
* Listeners established with this mechanism will automatically be cleaned up during destroy
*/
onDeviceLogEntry(cb: DeviceLogListener): () => void;
/**
* Listener that is triggered if the underlying device crashes.
* Listeners established with this mechanism will automatically be cleaned up during destroy
*/
onDeviceCrash(cb: CrashLogListener): () => void;
/**
* Creates a Paste (similar to a Github Gist).
* Facebook only function. Resolves to undefined if creating a paste failed.
*/
createPaste(
args: string | CreatePasteArgs,
): Promise<CreatePasteResult | undefined>;
/**
* Returns true if this is an internal Facebook build.
* Always returns `false` in open source
*/
readonly isFB: boolean;
/**
* Returns true if the user is taking part in the given gatekeeper.
* Always returns `false` in open source.
*/
GK(gkName: string): boolean;
/**
* Shows an urgent, system wide notification, that will also be registered in Flipper's notification pane.
* For on-screen notifications, we recommend to use either the `message` or `notification` API from `antd` directly.
*
* Clicking the notification will open this plugin. If the `action` id is set, it will be used as deeplink.
*/
showNotification(notification: Notification): void;
/**
* Writes text to the clipboard of the Operating System
*/
writeTextToClipboard(text: string): void;
/**
* Logger instance that logs information to the console, but also to the internal logging (in FB only builds) and which can be used to track performance.
*/
logger: Logger;
/**
* Subscribe to a specific event arriving from the server add-on.
*
* Messages can only arrive if the plugin is enabled and connected.
* For background plugins messages will be batched and arrive the next time the plugin is connected.
*/
onServerAddOnMessage<Event extends keyof ServerAddOnEvents>(
event: Event,
callback: (params: ServerAddOnEvents[Event]) => void,
): void;
/**
* Subscribe to all messages arriving from the server add-ons not handled by another listener.
*
* This handler is untyped, and onMessage should be favored over using onUnhandledMessage if the event name is known upfront.
*/
onServerAddOnUnhandledMessage(
callback: (event: string, params: any) => void,
): void;
/**
* Send a message to the server add-on
*/
sendToServerAddOn<Method extends keyof ServerAddOnMethods>(
method: Method,
...params: Parameters<ServerAddOnMethods[Method]> extends []
? []
: [Parameters<ServerAddOnMethods[Method]>[0]]
): ReturnType<ServerAddOnMethods[Method]>;
}
let currentPluginInstance: BasePluginInstance | undefined = undefined;
export function setCurrentPluginInstance(
instance: typeof currentPluginInstance,
) {
currentPluginInstance = instance;
}
export function getCurrentPluginInstance(): typeof currentPluginInstance {
return currentPluginInstance;
}
export interface Persistable {
serialize(): any;
deserialize(value: any): void;
}
export function registerStorageAtom(
key: string | undefined,
persistable: Persistable,
) {
if (key && getCurrentPluginInstance()) {
const {rootStates} = getCurrentPluginInstance()!;
if (rootStates[key]) {
throw new Error(
`Some other state is already persisting with key "${key}"`,
);
}
rootStates[key] = persistable;
}
}
let staticInstanceId = 1;
export abstract class BasePluginInstance {
/** generally available Flipper APIs */
readonly flipperLib: FlipperLib;
/** the original plugin definition */
definition: SandyPluginDefinition;
/** the plugin instance api as used inside components and such */
instanceApi: any;
/** the device owning this plugin */
readonly device: Device;
/** the unique plugin key for this plugin instance, which is unique for this device/app?/pluginId combo */
readonly pluginKey: string;
activated = false;
destroyed = false;
readonly events = new EventEmitter();
// temporarily field that is used during deserialization
initialStates?: Record<string, any>;
// all the atoms that should be serialized when making an export / import
readonly rootStates: Record<string, Persistable> = {};
// last seen deeplink
lastDeeplink?: any;
// export handler
exportHandler?: StateExportHandler;
// import handler
importHandler?: StateImportHandler;
menuEntries: NormalizedMenuEntry[] = [];
logListeners: Symbol[] = [];
crashListeners: Symbol[] = [];
readonly instanceId = ++staticInstanceId;
constructor(
private readonly serverAddOnControls: ServerAddOnControls,
flipperLib: FlipperLib,
definition: SandyPluginDefinition,
device: Device,
pluginKey: string,
initialStates?: Record<string, any>,
) {
this.flipperLib = flipperLib;
this.definition = definition;
this.initialStates = initialStates;
this.pluginKey = pluginKey;
if (!device) {
throw new Error('Illegal State: Device has not yet been loaded');
}
this.device = device;
}
protected initializePlugin(factory: () => any) {
// To be called from constructory
setCurrentPluginInstance(this);
try {
this.instanceApi = batched(factory)();
} finally {
// check if we have both an import handler and rootStates; probably dev error
if (this.importHandler && Object.keys(this.rootStates).length > 0) {
throw new Error(
`A custom onImport handler was defined for plugin '${
this.definition.id
}', the 'persist' option of states ${Object.keys(
this.rootStates,
).join(', ')} should not be set.`,
);
}
if (this.initialStates) {
try {
if (this.importHandler) {
batched(this.importHandler)(this.initialStates);
} else {
for (const key in this.rootStates) {
if (key in this.initialStates) {
this.rootStates[key].deserialize(this.initialStates[key]);
} else {
console.warn(
`Tried to initialize plugin with existing data, however data for "${key}" is missing. Was the export created with a different Flipper version?`,
);
}
}
}
} catch (e) {
const msg = `An error occurred when importing data for plugin '${this.definition.id}': '${e}`;
// msg is already specific
// eslint-disable-next-line
console.error(msg, e);
message.error(msg);
}
}
this.initialStates = undefined;
setCurrentPluginInstance(undefined);
}
try {
this.events.emit('ready');
} catch (e) {
const msg = `An error occurred when initializing plugin '${this.definition.id}': '${e}`;
// msg is already specific
// eslint-disable-next-line
console.error(msg, e);
message.error(msg);
}
}
protected createBasePluginClient(): BasePluginClient<any, any> {
return {
pluginKey: this.pluginKey,
device: this.device,
onActivate: (cb) => {
this.events.on('activate', batched(cb));
},
onDeactivate: (cb) => {
this.events.on('deactivate', batched(cb));
},
onDeepLink: (cb) => {
this.events.on('deeplink', batched(cb));
},
onDestroy: (cb) => {
this.events.on('destroy', batched(cb));
},
onExport: (cb) => {
if (this.exportHandler) {
throw new Error('onExport handler already set');
}
this.exportHandler = cb;
},
onImport: (cb) => {
if (this.importHandler) {
throw new Error('onImport handler already set');
}
this.importHandler = cb;
},
onReady: (cb) => {
this.events.on('ready', batched(cb));
},
addMenuEntry: (...entries) => {
for (const entry of entries) {
const normalized = normalizeMenuEntry(entry);
const idx = this.menuEntries.findIndex(
(existing) =>
existing.label === normalized.label ||
existing.action === normalized.action,
);
if (idx !== -1) {
this.menuEntries[idx] = normalizeMenuEntry(entry);
} else {
this.menuEntries.push(normalizeMenuEntry(entry));
}
if (this.activated) {
// entries added after initial registration
this.flipperLib.enableMenuEntries(this.menuEntries);
}
}
},
onDeviceLogEntry: (cb: DeviceLogListener): (() => void) => {
const handle = this.device.addLogListener(cb);
this.logListeners.push(handle);
return () => {
this.device.removeLogListener(handle);
};
},
onDeviceCrash: (cb: CrashLogListener): (() => void) => {
const handle = this.device.addCrashListener(cb);
this.crashListeners.push(handle);
return () => {
this.device.removeCrashListener(handle);
};
},
writeTextToClipboard: this.flipperLib.writeTextToClipboard,
createPaste: this.flipperLib.createPaste,
isFB: this.flipperLib.isFB,
GK: this.flipperLib.GK,
showNotification: (notification: Notification) => {
this.flipperLib.showNotification(this.pluginKey, notification);
},
logger: this.flipperLib.logger,
sendToServerAddOn: (_method, _params): any => {
// TODO: Implement me
},
onServerAddOnMessage: (_event, _cb) => {
// TODO: Implement me
},
onServerAddOnUnhandledMessage: (_cb) => {
// TODO: Implement me
},
};
}
// the plugin is selected in the UI
activate() {
this.assertNotDestroyed();
if (!this.activated) {
this.flipperLib.enableMenuEntries(this.menuEntries);
this.activated = true;
try {
this.events.emit('activate');
} catch (e) {
console.error(`Failed to activate plugin: ${this.definition.id}`, e);
}
this.flipperLib.logger.trackTimeSince(
`activePlugin-${this.definition.id}`,
);
}
}
deactivate() {
if (this.destroyed) {
return;
}
if (this.activated) {
this.activated = false;
this.lastDeeplink = undefined;
try {
this.events.emit('deactivate');
} catch (e) {
console.error(`Failed to deactivate plugin: ${this.definition.id}`, e);
}
}
}
destroy() {
this.assertNotDestroyed();
this.deactivate();
this.logListeners.splice(0).forEach((handle) => {
this.device.removeLogListener(handle);
});
this.crashListeners.splice(0).forEach((handle) => {
this.device.removeCrashListener(handle);
});
this.events.emit('destroy');
this.destroyed = true;
}
triggerDeepLink(deepLink: unknown) {
this.assertNotDestroyed();
if (deepLink !== this.lastDeeplink) {
this.lastDeeplink = deepLink;
// we only want to trigger deeplinks after the plugin had a chance to render
setTimeout(() => {
this.events.emit('deeplink', deepLink);
}, 0);
}
}
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 this.serializeRootStates();
}
private serializeRootStates() {
return Object.fromEntries(
Object.entries(this.rootStates).map(([key, atom]) => {
try {
return [key, atom.serialize()];
} catch (e) {
throw new Error(`Failed to serialize state '${key}': ${e}`);
}
}),
);
}
async exportState(
idler: Idler,
onStatusMessage: (msg: string) => void,
): Promise<Record<string, any>> {
if (this.exportHandler) {
const result = await this.exportHandler(idler, onStatusMessage);
if (result !== undefined) {
return result;
}
// intentional fall-through, the export handler merely updated the state, but prefers the default export format
}
return this.serializeRootStates();
}
isPersistable(): boolean {
return !!this.exportHandler || Object.keys(this.rootStates).length > 0;
}
protected assertNotDestroyed() {
if (this.destroyed) {
throw new Error('Plugin has been destroyed already');
}
}
abstract toJSON(): string;
protected abstract serverAddOnOwner: string;
protected startServerAddOn() {
const {serverAddOn, name} = this.definition.details;
if (serverAddOn) {
this.serverAddOnControls.start(name, this.serverAddOnOwner).catch((e) => {
console.warn(
'Failed to start a server add on',
name,
this.serverAddOnOwner,
e,
);
});
}
}
protected stopServerAddOn() {
const {serverAddOn, name} = this.definition.details;
if (serverAddOn) {
this.serverAddOnControls.stop(name, this.serverAddOnOwner).catch((e) => {
console.warn(
'Failed to start a server add on',
name,
this.serverAddOnOwner,
e,
);
});
}
}
}