Files
flipper/desktop/flipper-plugin/src/plugin/PluginBase.tsx
Michel Weststrate bb529411b5 Expose current connection status to Sandy plugins
Summary:
Introduced `isConnected` flag on device and plugin client to reflect whether a connection is still available for the plugins, or that they have been disconnected.

Potentially we could expose the (readonly) `connected` state atom for this as well, or an `onDisconnect` event for device pugins, to create a responsive UI, but there might be no need for that, in which case this suffices.

Reviewed By: nikoant

Differential Revision: D26249346

fbshipit-source-id: b8486713fdf2fcd520488ce54f771bd038fd13f8
2021-02-09 04:16:24 -08:00

315 lines
9.0 KiB
TypeScript

/**
* 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
*/
import {SandyPluginDefinition} from './SandyPluginDefinition';
import {EventEmitter} from 'events';
import {Atom} from '../state/atom';
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';
import {message} from 'antd';
type StateExportHandler<T = any> = (
idler: Idler,
onStatusMessage: (msg: string) => void,
) => Promise<T>;
type StateImportHandler<T = any> = (data: T) => void;
export interface BasePluginClient {
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.
*/
onExport<T = any>(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;
/**
* Register menu entries in the Flipper toolbar
*/
addMenuEntry(...entry: MenuEntry[]): void;
/**
* Creates a Paste (similar to a Github Gist).
* Facebook only function. Resolves to undefined if creating a paste failed.
*/
createPaste(input: string): Promise<string | undefined>;
/**
* Returns true if the user is taking part in the given gatekeeper.
* Always returns `false` in open source.
*/
GK(gkName: string): boolean;
}
let currentPluginInstance: BasePluginInstance | undefined = undefined;
export function setCurrentPluginInstance(
instance: typeof currentPluginInstance,
) {
currentPluginInstance = instance;
}
export function getCurrentPluginInstance(): typeof currentPluginInstance {
return currentPluginInstance;
}
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;
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, Atom<any>> = {};
// last seen deeplink
lastDeeplink?: any;
// export handler
exportHandler?: StateExportHandler;
// import handler
importHandler?: StateImportHandler;
menuEntries: NormalizedMenuEntry[] = [];
constructor(
flipperLib: FlipperLib,
definition: SandyPluginDefinition,
realDevice: RealFlipperDevice,
initialStates?: Record<string, any>,
) {
this.flipperLib = flipperLib;
this.definition = definition;
this.initialStates = initialStates;
if (!realDevice) {
throw new Error('Illegal State: Device has not yet been loaded');
}
this.device = {
realDevice, // TODO: temporarily, clean up T70688226
// N.B. we model OS as string, not as enum, to make custom device types possible in the future
os: realDevice.os,
get isArchived() {
return realDevice.isArchived;
},
get isConnected() {
// for now same as isArchived, in the future we might distinguish between archived/imported and disconnected/offline devices
return !realDevice.isArchived;
},
deviceType: realDevice.deviceType,
onLogEntry(cb) {
const handle = realDevice.addLogListener(cb);
return () => {
realDevice.removeLogListener(handle);
};
},
};
}
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) {
if (this.importHandler) {
try {
batched(this.importHandler)(this.initialStates);
} catch (e) {
const msg = `Error occurred when importing date for plugin '${this.definition.id}': '${e}`;
console.error(msg, e);
message.error(msg);
}
} else {
for (const key in this.rootStates) {
if (key in this.initialStates) {
this.rootStates[key].set(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?`,
);
}
}
}
}
this.initialStates = undefined;
setCurrentPluginInstance(undefined);
}
}
protected createBasePluginClient(): BasePluginClient {
return {
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;
},
addMenuEntry: (...entries) => {
for (const entry of entries) {
const normalized = normalizeMenuEntry(entry);
if (
this.menuEntries.find(
(existing) =>
existing.label === normalized.label ||
existing.action === normalized.action,
)
) {
throw new Error(`Duplicate menu entry: '${normalized.label}'`);
}
this.menuEntries.push(normalizeMenuEntry(entry));
}
},
createPaste: this.flipperLib.createPaste,
GK: this.flipperLib.GK,
};
}
// the plugin is selected in the UI
activate() {
this.assertNotDestroyed();
if (!this.activated) {
this.activated = true;
this.flipperLib.enableMenuEntries(this.menuEntries);
this.events.emit('activate');
this.flipperLib.logger.trackTimeSince(
`activePlugin-${this.definition.id}`,
);
}
}
deactivate() {
if (this.destroyed) {
return;
}
if (this.activated) {
this.activated = false;
this.lastDeeplink = undefined;
this.events.emit('deactivate');
}
}
destroy() {
this.assertNotDestroyed();
this.deactivate();
this.events.emit('destroy');
this.destroyed = true;
}
triggerDeepLink(deepLink: unknown) {
this.assertNotDestroyed();
if (deepLink !== this.lastDeeplink) {
this.lastDeeplink = deepLink;
this.events.emit('deeplink', deepLink);
}
}
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 !!this.exportHandler || Object.keys(this.rootStates).length > 0;
}
protected assertNotDestroyed() {
if (this.destroyed) {
throw new Error('Plugin has been destroyed already');
}
}
abstract toJSON(): string;
}