Split flipper-plugin package

Summary:
flipper-server-companion depends on flipper-plugin. flipper-plugin includes dependencies that run only in a browser. Splitting flipper-plugin into core and browser packages helps to avoid including browser-only dependencies into flipper-server bundle.
As a result, bundle size could be cut in half. Subsequently, RSS usage drops as there is twice as less code to process for V8.

Note: it currently breaks external flipper-data-source package. It will be restored in subsequent diffs

Reviewed By: lblasa

Differential Revision: D38658285

fbshipit-source-id: 751b11fa9f3a2d938ce166687b8310ba8b059dee
This commit is contained in:
Andrey Goncharov
2022-09-15 10:02:19 -07:00
committed by Facebook GitHub Bot
parent 2090120cda
commit 97b8b8a1c4
86 changed files with 813 additions and 645 deletions

View File

@@ -1,121 +0,0 @@
/**
* 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 {SandyPluginDefinition} from './SandyPluginDefinition';
import {BasePluginInstance, BasePluginClient} from './PluginBase';
import {FlipperLib} from './FlipperLib';
import {Atom, ReadOnlyAtom} from '../state/atom';
import {
DeviceOS,
DeviceType,
DeviceLogEntry,
CrashLog,
ServerAddOnControls,
EventsContract,
MethodsContract,
DeviceDescription,
} from 'flipper-common';
export type DeviceLogListener = (entry: DeviceLogEntry) => void;
export type CrashLogListener = (crash: CrashLog) => void;
export interface Device {
readonly isArchived: boolean;
readonly description: DeviceDescription;
readonly isConnected: boolean;
readonly os: DeviceOS;
readonly serial: string;
readonly deviceType: DeviceType;
readonly connected: Atom<boolean>;
executeShell(command: string): Promise<string>;
addLogListener(callback: DeviceLogListener): Symbol;
addCrashListener(callback: CrashLogListener): Symbol;
removeLogListener(id: Symbol): void;
removeCrashListener(id: Symbol): void;
executeShell(command: string): Promise<string>;
forwardPort(local: string, remote: string): Promise<boolean>;
clearLogs(): Promise<void>;
sendMetroCommand(command: string): Promise<void>;
navigateToLocation(location: string): Promise<void>;
screenshot(): Promise<Uint8Array | undefined>;
installApp(appBundlePath: string): Promise<void>;
}
export type DevicePluginPredicate = (device: Device) => boolean;
export type DevicePluginFactory = (client: DevicePluginClient) => object;
export interface DevicePluginClient<
ServerAddOnEvents extends EventsContract = {},
ServerAddOnMethods extends MethodsContract = {},
> extends BasePluginClient<ServerAddOnEvents, ServerAddOnMethods> {
/**
* opens a different plugin by id, optionally providing a deeplink to bring the plugin to a certain state
*/
selectPlugin(pluginId: string, deeplinkPayload?: unknown): void;
readonly isConnected: boolean;
readonly connected: ReadOnlyAtom<boolean>;
}
export class SandyDevicePluginInstance extends BasePluginInstance {
static is(thing: any): thing is SandyDevicePluginInstance {
return thing instanceof SandyDevicePluginInstance;
}
/** client that is bound to this instance */
readonly client: DevicePluginClient<any, any>;
constructor(
serverAddOnControls: ServerAddOnControls,
flipperLib: FlipperLib,
definition: SandyPluginDefinition,
device: Device,
pluginKey: string,
initialStates?: Record<string, any>,
) {
super(
serverAddOnControls,
flipperLib,
definition,
device,
pluginKey,
initialStates,
);
this.client = {
...this.createBasePluginClient(),
selectPlugin(pluginId: string, deeplink?: unknown) {
flipperLib.selectPlugin(device, null, pluginId, deeplink);
},
get isConnected() {
return device.connected.get();
},
connected: device.connected,
};
this.initializePlugin(() =>
definition.asDevicePluginModule().devicePlugin(this.client),
);
// Do not start server add-ons for archived devices
if (this.device.connected.get()) {
this.startServerAddOn();
}
}
toJSON() {
return '[SandyDevicePluginInstance]';
}
destroy() {
this.stopServerAddOn();
super.destroy();
}
protected get serverAddOnOwner() {
return this.device.serial;
}
}

View File

@@ -1,206 +0,0 @@
/**
* 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 {Logger} from '../utils/Logger';
import {Device} from './DevicePlugin';
import {NormalizedMenuEntry} from './MenuEntry';
import {RealFlipperClient} from './Plugin';
import {Notification} from './Notification';
import {DetailSidebarProps} from '../ui/DetailSidebar';
import {
ExecOptions,
ExecOut,
BufferEncoding,
MkdirOptions,
DownloadFileStartOptions,
DownloadFileStartResponse,
DownloadFileUpdate,
RmOptions,
fsConstants,
EnvironmentInfo,
FSStatsLike,
FlipperServerCommands,
} from 'flipper-common';
import {CreatePasteArgs, CreatePasteResult} from './Paste';
export type FileEncoding = 'utf-8' | 'base64';
export interface FileDescriptor {
data: string;
name: string;
path?: string;
}
export interface DownloadFileResponse extends DownloadFileStartResponse {
/**
* Indicates whether a download is completed. Resolves with the number of downloaded bytes. Rejects if the download has errors.
*/
completed: Promise<number>;
}
export type RemoteServerContext = {
childProcess: {
exec(
command: string,
options?: {encoding?: BufferEncoding} & ExecOptions,
): Promise<ExecOut<string>>;
};
fs: {
constants: typeof fsConstants;
access(path: string, mode?: number): Promise<void>;
pathExists(path: string, mode?: number): Promise<boolean>;
unlink(path: string): Promise<void>;
mkdir(
path: string,
options: {recursive: true} & MkdirOptions,
): Promise<string | undefined>;
mkdir(
path: string,
options?: {recursive?: false} & MkdirOptions,
): Promise<void>;
rm(path: string, options?: RmOptions): Promise<void>;
copyFile(src: string, dest: string, flags?: number): Promise<void>;
stat(path: string): Promise<FSStatsLike>;
readlink(path: string): Promise<string>;
readFile(
path: string,
options?: {encoding?: BufferEncoding},
): Promise<string>;
readFileBinary(path: string): Promise<Uint8Array>; // No Buffer, which is not a browser type
writeFile(
path: string,
contents: string,
options?: {encoding?: BufferEncoding},
): Promise<void>;
writeFileBinary(path: string, contents: Uint8Array): Promise<void>;
};
downloadFile(
url: string,
dest: string,
options?: DownloadFileStartOptions & {
onProgressUpdate?: (progressUpdate: DownloadFileUpdate) => void;
},
): Promise<DownloadFileResponse>;
};
/**
* This interface exposes all global methods for which an implementation will be provided by Flipper itself
*/
export interface FlipperLib {
isFB: boolean;
logger: Logger;
enableMenuEntries(menuEntries: NormalizedMenuEntry[]): void;
createPaste(
args: string | CreatePasteArgs,
): Promise<CreatePasteResult | undefined>;
GK(gatekeeper: string): boolean;
selectPlugin(
device: Device,
client: RealFlipperClient | null,
pluginId: string,
deeplink: unknown,
): void;
writeTextToClipboard(text: string): void;
openLink(url: string): void;
showNotification(pluginKey: string, notification: Notification): void;
DetailsSidebarImplementation?(
props: DetailSidebarProps,
): React.ReactElement | null;
/**
* @returns
* Imported file data.
* If user cancelled a file selection - undefined.
*/
importFile(options?: {
/**
* Default directory to start the file selection from
*/
defaultPath?: string;
/**
* List of allowed file extensions
*/
extensions?: string[];
/**
* Open file dialog title
*/
title?: string;
/**
* File encoding
*/
encoding?: FileEncoding;
/**
* Allow selection of multiple files
*/
multi?: false;
}): Promise<FileDescriptor | undefined>;
importFile(options?: {
defaultPath?: string;
extensions?: string[];
title?: string;
encoding?: FileEncoding;
multi: true;
}): Promise<FileDescriptor[] | undefined>;
/**
* @returns
* An exported file path (if available) or a file name.
* If user cancelled a file selection - undefined.
*/
exportFile(
/**
* New file data
*/
data: string,
options?: {
/**
* A file path suggestion for a new file.
* A dialog to save file will use it as a starting point.
* Either a complete path to the newly created file, a path to a directory containing the file, or the file name.
*/
defaultPath?: string;
/**
* File encoding
*/
encoding?: FileEncoding;
},
): Promise<string | undefined>;
paths: {
appPath: string;
homePath: string;
staticPath: string;
tempPath: string;
};
environmentInfo: {
os: EnvironmentInfo['os'];
};
remoteServerContext: RemoteServerContext;
intern: InternAPI;
}
interface InternAPI {
graphGet: FlipperServerCommands['intern-graph-get'];
graphPost: FlipperServerCommands['intern-graph-post'];
}
export let flipperLibInstance: FlipperLib | undefined;
export function tryGetFlipperLibImplementation(): FlipperLib | undefined {
return flipperLibInstance;
}
export function getFlipperLib(): FlipperLib {
if (!flipperLibInstance) {
throw new Error('Flipper lib not instantiated');
}
return flipperLibInstance;
}
export function setFlipperLibImplementation(impl: FlipperLib | undefined) {
flipperLibInstance = impl;
}

View File

@@ -1,59 +0,0 @@
/**
* 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
*/
export type MenuEntry = BuiltInMenuEntry | CustomMenuEntry;
export type DefaultKeyboardAction = keyof typeof buildInMenuEntries;
export type NormalizedMenuEntry = {
label: string;
accelerator?: string;
handler: () => void;
action: string;
};
export type CustomMenuEntry = {
label: string;
accelerator?: string;
handler: () => void;
};
export type BuiltInMenuEntry = {
action: keyof typeof buildInMenuEntries;
handler: () => void;
};
export const buildInMenuEntries = {
clear: {
label: 'Clear',
accelerator: 'CmdOrCtrl+K',
action: 'clear',
},
goToBottom: {
label: 'Go To Bottom',
accelerator: 'CmdOrCtrl+B',
action: 'goToBottom',
},
createPaste: {
label: 'Create Paste',
action: 'createPaste',
},
} as const;
export function normalizeMenuEntry(entry: MenuEntry): NormalizedMenuEntry;
export function normalizeMenuEntry(entry: any): NormalizedMenuEntry {
const builtInEntry: NormalizedMenuEntry | undefined = (
buildInMenuEntries as any
)[entry.action];
return builtInEntry
? {...builtInEntry, ...entry}
: {
...entry,
action: entry.action || entry.label,
};
}

View File

@@ -1,19 +0,0 @@
/**
* 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
*/
export type Notification = {
id: string;
title: string;
message: string | React.ReactNode;
severity: 'warning' | 'error';
timestamp?: number;
category?: string;
/** The action will be available as deeplink payload when the notification is clicked. */
action?: string;
};

View File

@@ -1,21 +0,0 @@
/**
* 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
*/
export type CreatePasteArgs = {
content: string;
title?: string;
showSuccessNotification?: boolean;
showErrorNotification?: boolean;
writeToClipboard?: boolean;
};
export type CreatePasteResult = {
number: number;
url: string;
};

View File

@@ -1,317 +0,0 @@
/**
* 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 {SandyPluginDefinition} from './SandyPluginDefinition';
import {BasePluginInstance, BasePluginClient} from './PluginBase';
import {FlipperLib} from './FlipperLib';
import {Device} from './DevicePlugin';
import {batched} from '../state/batch';
import {Atom, createState, ReadOnlyAtom} from '../state/atom';
import {
ServerAddOnControls,
EventsContract,
MethodsContract,
} from 'flipper-common';
type PreventIntersectionWith<Contract extends Record<string, any>> = {
[Key in keyof Contract]?: never;
};
type Message = {
method: string;
params?: any;
};
/**
* API available to a plugin factory
*/
export interface PluginClient<
Events extends EventsContract = {},
Methods extends MethodsContract = {},
ServerAddOnEvents extends EventsContract &
PreventIntersectionWith<Events> = {},
ServerAddOnMethods extends MethodsContract &
PreventIntersectionWith<Methods> = {},
> extends BasePluginClient<ServerAddOnEvents, ServerAddOnMethods> {
/**
* Identifier that uniquely identifies the connected application
*/
readonly appId: string;
/**
* Registered name for the connected application
*/
readonly appName: string;
readonly isConnected: boolean;
readonly connected: ReadOnlyAtom<boolean>;
/**
* the onConnect event is fired whenever the plugin is connected to it's counter part on the device.
* For most plugins this event is fired if the user selects the plugin,
* for background plugins when the initial connection is made.
*/
onConnect(cb: () => void): void;
/**
* The counterpart of the `onConnect` handler.
* Will also be fired before the plugin is cleaned up if the connection is currently active:
* - when the client disconnects
* - when the plugin is disabled
*/
onDisconnect(cb: () => void): void;
/**
* Subscribe to a specific event arriving from the device.
*
* 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.
*/
onMessage<Event extends keyof Events>(
event: Event,
callback: (params: Events[Event]) => void,
): void;
/**
* Subscribe to all messages arriving from the devices not handled by another listener.
*
* This handler is untyped, and onMessage should be favored over using onUnhandledMessage if the event name is known upfront.
*/
onUnhandledMessage(callback: (event: string, params: any) => void): void;
/**
* Send a message to the connected client
*/
send<Method extends keyof Methods>(
method: Method,
params: Parameters<Methods[Method]>[0],
): ReturnType<Methods[Method]>;
/**
* Checks if a method is available on the client implementation
*/
supportsMethod(method: keyof Methods): Promise<boolean>;
/**
* opens a different plugin by id, optionally providing a deeplink to bring the plugin to a certain state
*/
selectPlugin(pluginId: string, deeplinkPayload?: unknown): void;
}
/**
* Internal API exposed by Flipper, and wrapped by FlipperPluginInstance to be passed to the
* Plugin Factory. For internal purposes only
*/
export interface RealFlipperClient {
id: string;
connected: Atom<boolean>;
query: {
app: string;
os: string;
device: string;
device_id: string;
};
device: Device;
plugins: Set<string>;
isBackgroundPlugin(pluginId: string): boolean;
initPlugin(pluginId: string): void;
deinitPlugin(pluginId: string): void;
call(
api: string,
method: string,
fromPlugin: boolean,
params?: Object,
): Promise<Object>;
supportsMethod(api: string, method: string): Promise<boolean>;
}
export type PluginFactory<
Events extends EventsContract,
Methods extends MethodsContract,
ServerAddOnEvents extends EventsContract & PreventIntersectionWith<Events>,
ServerAddOnMethods extends MethodsContract & PreventIntersectionWith<Methods>,
> = (
client: PluginClient<Events, Methods, ServerAddOnEvents, ServerAddOnMethods>,
) => object;
export type FlipperPluginComponent = React.FC<{}>;
export class SandyPluginInstance extends BasePluginInstance {
static is(thing: any): thing is SandyPluginInstance {
return thing instanceof SandyPluginInstance;
}
/** base client provided by Flipper */
readonly realClient: RealFlipperClient;
/** client that is bound to this instance */
readonly client: PluginClient<any, any, any, any>;
/** connection alive? */
readonly connected = createState(false);
constructor(
serverAddOnControls: ServerAddOnControls,
flipperLib: FlipperLib,
definition: SandyPluginDefinition,
realClient: RealFlipperClient,
pluginKey: string,
initialStates?: Record<string, any>,
) {
super(
serverAddOnControls,
flipperLib,
definition,
realClient.device,
pluginKey,
initialStates,
);
this.realClient = realClient;
this.definition = definition;
const self = this;
this.client = {
...this.createBasePluginClient(),
get appId() {
return realClient.id;
},
get appName() {
return realClient.query.app;
},
connected: self.connected,
get isConnected() {
return self.connected.get();
},
onConnect: (cb) => {
this.events.on('connect', batched(cb));
},
onDisconnect: (cb) => {
this.events.on('disconnect', batched(cb));
},
send: async (method, params) => {
this.assertConnected();
return await realClient.call(
this.definition.id,
method as any,
true,
params as any,
);
},
onMessage: (event, cb) => {
this.events.on('event-' + event, batched(cb));
},
onUnhandledMessage: (cb) => {
this.events.on('unhandled-event', batched(cb));
},
supportsMethod: async (method) => {
return await realClient.supportsMethod(
this.definition.id,
method as any,
);
},
selectPlugin(pluginId: string, deeplink?: unknown) {
flipperLib.selectPlugin(
realClient.device,
realClient,
pluginId,
deeplink,
);
},
};
this.initializePlugin(() =>
definition.asPluginModule().plugin(this.client),
);
}
// the plugin is selected in the UI
activate() {
super.activate();
const pluginId = this.definition.id;
if (
!this.connected.get() &&
!this.realClient.isBackgroundPlugin(pluginId)
) {
this.realClient.initPlugin(pluginId); // will call connect() if needed
}
}
// the plugin is deselected in the UI
deactivate() {
super.deactivate();
const pluginId = this.definition.id;
if (this.connected.get() && !this.realClient.isBackgroundPlugin(pluginId)) {
this.realClient.deinitPlugin(pluginId);
}
}
connect() {
this.assertNotDestroyed();
if (!this.connected.get()) {
this.startServerAddOn();
this.connected.set(true);
this.events.emit('connect');
}
}
disconnect() {
this.assertNotDestroyed();
if (this.connected.get()) {
this.stopServerAddOn();
this.connected.set(false);
this.events.emit('disconnect');
}
}
destroy() {
if (this.connected.get()) {
this.realClient.deinitPlugin(this.definition.id);
}
super.destroy();
}
receiveMessages(messages: Message[]) {
messages.forEach((message) => {
if (this.events.listenerCount('event-' + message.method) > 0) {
this.events.emit('event-' + message.method, message.params);
} else {
this.events.emit('unhandled-event', message.method, message.params);
}
});
}
toJSON() {
return '[SandyPluginInstance]';
}
protected get serverAddOnOwner() {
return this.realClient.id;
}
private assertConnected() {
this.assertNotDestroyed();
// This is a better-safe-than-sorry; just the first condition should suffice
if (!this.connected.get()) {
throw new Error(
'SandyPluginInstance.assertConnected -> plugin is not connected',
);
}
if (!this.realClient.connected.get()) {
throw new Error(
'SandyPluginInstance.assertConnected -> realClient is not connected',
);
}
if (!this.device.isConnected) {
throw new Error(
'SandyPluginInstance.assertConnected -> device is not connected',
);
}
if (this.device.isArchived) {
throw new Error(
'SandyPluginInstance.assertConnected -> device is archived',
);
}
}
}

View File

@@ -1,590 +0,0 @@
/**
* 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 {
EventsContract,
MethodsContract,
ServerAddOnControls,
} from 'flipper-common';
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;
/**
* Triggered when a server add-on starts.
* You should send messages to the server add-on only after it connects.
* Do not forget to stop all communication when the add-on stops.
* See `onServerAddStop`.
*/
onServerAddOnStart(callback: () => void): void;
/**
* Triggered when a server add-on stops.
* You should stop all communication with the server add-on when the add-on stops.
*/
onServerAddOnStop(callback: () => void): void;
/**
* Subscribe to a specific event arriving from the server add-on.
* Messages can only arrive if the plugin is enabled and connected.
*/
onServerAddOnMessage<Event extends keyof ServerAddOnEvents & string>(
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 & string>(
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 plugin public api exposed over the wire via flipper-server-companion */
companionApi?: 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;
private serverAddOnStarted = false;
private serverAddOnStopped = 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)();
const apiFactory = this.definition.module.API;
if (apiFactory) {
this.companionApi = apiFactory(this.instanceApi);
}
} 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,
onServerAddOnStart: (cb) => {
this.events.on('serverAddOnStart', batched(cb));
if (this.serverAddOnStarted) {
batched(cb)();
}
},
onServerAddOnStop: (cb) => {
this.events.on('serverAddOnStop', batched(cb));
if (this.serverAddOnStopped) {
batched(cb)();
}
},
sendToServerAddOn: (method, params) =>
this.serverAddOnControls.sendMessage(
this.definition.packageName,
method,
params,
),
onServerAddOnMessage: (event, cb) => {
this.serverAddOnControls.receiveMessage(
this.definition.packageName,
event,
batched(cb),
);
},
onServerAddOnUnhandledMessage: (cb) => {
this.serverAddOnControls.receiveAnyMessage(
this.definition.packageName,
batched(cb),
);
},
};
}
// 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.serverAddOnControls.unsubscribePlugin(this.definition.packageName);
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 pluginDetails = this.definition.details;
if (pluginDetails.serverAddOn) {
this.serverAddOnControls
.start(
pluginDetails.name,
pluginDetails.isBundled
? {isBundled: true}
: {path: pluginDetails.serverAddOnEntry!},
this.serverAddOnOwner,
)
.then(() => {
this.events.emit('serverAddOnStart');
this.serverAddOnStarted = true;
})
.catch((e) => {
console.warn(
'Failed to start a server add on',
pluginDetails.name,
this.serverAddOnOwner,
e,
);
});
}
}
protected stopServerAddOn() {
const {serverAddOn, name} = this.definition.details;
if (serverAddOn) {
this.serverAddOnControls
.stop(name, this.serverAddOnOwner)
.finally(() => {
this.events.emit('serverAddOnStop');
this.serverAddOnStopped = true;
})
.catch((e) => {
console.warn(
'Failed to stop a server add on',
name,
this.serverAddOnOwner,
e,
);
});
}
}
}

View File

@@ -8,16 +8,20 @@
*/
import {createContext, useContext} from 'react';
import {SandyPluginInstance, PluginFactory} from './Plugin';
import {SandyDevicePluginInstance, DevicePluginFactory} from './DevicePlugin';
import {
_SandyDevicePluginInstance,
_DevicePluginFactory,
_SandyPluginInstance,
_PluginFactory,
} from 'flipper-plugin-core';
export const SandyPluginContext = createContext<
SandyPluginInstance | SandyDevicePluginInstance | undefined
_SandyPluginInstance | _SandyDevicePluginInstance | undefined
>(undefined);
export function usePluginInstance():
| SandyPluginInstance
| SandyDevicePluginInstance {
| _SandyPluginInstance
| _SandyDevicePluginInstance {
const pluginInstance = useContext(SandyPluginContext);
if (!pluginInstance) {
throw new Error('Sandy Plugin context not available');
@@ -26,14 +30,14 @@ export function usePluginInstance():
}
export function usePluginInstanceMaybe():
| SandyPluginInstance
| SandyDevicePluginInstance
| _SandyPluginInstance
| _SandyDevicePluginInstance
| undefined {
return useContext(SandyPluginContext);
}
export function usePlugin<
Factory extends PluginFactory<any, any, any, any> | DevicePluginFactory,
Factory extends _PluginFactory<any, any, any, any> | _DevicePluginFactory,
>(plugin: Factory): ReturnType<Factory> {
const pluginInstance = usePluginInstance();
// In principle we don't *need* the plugin, but having it passed it makes sure the

View File

@@ -9,20 +9,22 @@
import React, {memo, useEffect, createElement} from 'react';
import {SandyPluginContext} from './PluginContext';
import {SandyPluginInstance} from './Plugin';
import {SandyDevicePluginInstance} from './DevicePlugin';
import {BasePluginInstance} from './PluginBase';
import {
_SandyPluginInstance,
_SandyDevicePluginInstance,
_BasePluginInstance,
} from 'flipper-plugin-core';
import {TrackingScope} from '../ui/Tracked';
type Props = {
plugin: SandyPluginInstance | SandyDevicePluginInstance;
plugin: _SandyPluginInstance | _SandyDevicePluginInstance;
};
/**
* Component to render a Sandy plugin container
*/
export const SandyPluginRenderer = memo(({plugin}: Props) => {
if (!plugin || !(plugin instanceof BasePluginInstance)) {
if (!plugin || !(plugin instanceof _BasePluginInstance)) {
throw new Error('Expected plugin, got ' + plugin);
}
useEffect(() => {

View File

@@ -1,139 +0,0 @@
/**
* 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 {ActivatablePluginDetails} from 'flipper-common';
import {PluginFactory, FlipperPluginComponent} from './Plugin';
import {DevicePluginPredicate, DevicePluginFactory} from './DevicePlugin';
export type FlipperPluginAPI<T extends (...args: any[]) => object> = (
pluginInstance: ReturnType<T>,
) => object;
export type FlipperPluginInstance<T extends (...args: any[]) => object> =
Parameters<FlipperPluginAPI<T>>[0];
/**
* FlipperPluginModule describe the exports that are provided by a typical Flipper Desktop plugin
*/
export type FlipperDevicePluginModule = {
/** predicate that determines if this plugin applies to the currently selcted device */
supportsDevice?: DevicePluginPredicate; // TODO T84453692: remove this function after some transition period in favor of BaseDevice.supportsPlugin.
/** the factory function that exposes plugin API over the wire */
API?: FlipperPluginAPI<DevicePluginFactory>;
/** the factory function that initializes a plugin instance */
devicePlugin: DevicePluginFactory;
/** the component type that can render this plugin */
Component: FlipperPluginComponent;
};
/**
* FlipperPluginModule describe the exports that are provided by a typical Flipper Desktop plugin
*/
export type FlipperPluginModule<
Factory extends PluginFactory<any, any, any, any>,
> = {
/** the factory function that exposes plugin API over the wire */
API?: FlipperPluginAPI<Factory>;
/** the factory function that initializes a plugin instance */
plugin: Factory;
/** the component type that can render this plugin */
Component: FlipperPluginComponent;
};
/**
* A sandy plugin definition represents a loaded plugin definition, storing two things:
* the loaded JS module, and the meta data (typically coming from package.json).
*
* Also delegates some of the standard plugin functionality to have a similar public static api as FlipperPlugin
*/
export class SandyPluginDefinition {
id: string;
module: FlipperPluginModule<any> | FlipperDevicePluginModule;
details: ActivatablePluginDetails;
isDevicePlugin: boolean;
constructor(
details: ActivatablePluginDetails,
module: FlipperPluginModule<any> | FlipperDevicePluginModule,
);
constructor(details: ActivatablePluginDetails, module: any) {
this.id = details.id;
this.details = details;
if (
details.pluginType === 'device' ||
module.supportsDevice ||
module.devicePlugin
) {
// device plugin
this.isDevicePlugin = true;
if (!module.devicePlugin || typeof module.devicePlugin !== 'function') {
throw new Error(
`Flipper device plugin '${this.id}' should export named function called 'devicePlugin'`,
);
}
} else {
this.isDevicePlugin = false;
if (!module.plugin || typeof module.plugin !== 'function') {
throw new Error(
`Flipper plugin '${this.id}' should export named function called 'plugin'`,
);
}
}
if (!module.Component || typeof module.Component !== 'function') {
throw new Error(
`Flipper plugin '${this.id}' should export named function called 'Component'`,
);
}
this.module = module;
this.module.Component.displayName = `FlipperPlugin(${this.id})`;
}
asDevicePluginModule(): FlipperDevicePluginModule {
if (!this.isDevicePlugin) throw new Error('Not a device plugin');
return this.module as FlipperDevicePluginModule;
}
asPluginModule(): FlipperPluginModule<any> {
if (this.isDevicePlugin) throw new Error('Not an application plugin');
return this.module as FlipperPluginModule<any>;
}
get packageName() {
return this.details.name;
}
get title() {
return this.details.title;
}
get icon() {
return this.details.icon;
}
get category() {
return this.details.category;
}
get gatekeeper() {
return this.details.gatekeeper;
}
get version() {
return this.details.version;
}
get isBundled() {
return this.details.isBundled;
}
get keyboardActions() {
// TODO: T68882551 support keyboard actions
return [];
}
}