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:
committed by
Facebook GitHub Bot
parent
2090120cda
commit
97b8b8a1c4
121
desktop/flipper-plugin-core/src/plugin/DevicePlugin.tsx
Normal file
121
desktop/flipper-plugin-core/src/plugin/DevicePlugin.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
208
desktop/flipper-plugin-core/src/plugin/FlipperLib.tsx
Normal file
208
desktop/flipper-plugin-core/src/plugin/FlipperLib.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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 type {ReactElement} from 'react';
|
||||
import {Logger} from '../utils/Logger';
|
||||
import {Device} from './DevicePlugin';
|
||||
import {NormalizedMenuEntry} from './MenuEntry';
|
||||
import {RealFlipperClient} from './Plugin';
|
||||
import {Notification} from './Notification';
|
||||
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: {
|
||||
children: any;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
}): 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;
|
||||
}
|
||||
59
desktop/flipper-plugin-core/src/plugin/MenuEntry.tsx
Normal file
59
desktop/flipper-plugin-core/src/plugin/MenuEntry.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
20
desktop/flipper-plugin-core/src/plugin/Notification.tsx
Normal file
20
desktop/flipper-plugin-core/src/plugin/Notification.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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 type {ReactNode} from 'react';
|
||||
export type Notification = {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string | ReactNode;
|
||||
severity: 'warning' | 'error';
|
||||
timestamp?: number;
|
||||
category?: string;
|
||||
/** The action will be available as deeplink payload when the notification is clicked. */
|
||||
action?: string;
|
||||
};
|
||||
21
desktop/flipper-plugin-core/src/plugin/Paste.tsx
Normal file
21
desktop/flipper-plugin-core/src/plugin/Paste.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
318
desktop/flipper-plugin-core/src/plugin/Plugin.tsx
Normal file
318
desktop/flipper-plugin-core/src/plugin/Plugin.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 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';
|
||||
import type {FC} from 'react';
|
||||
|
||||
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 = 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
589
desktop/flipper-plugin-core/src/plugin/PluginBase.tsx
Normal file
589
desktop/flipper-plugin-core/src/plugin/PluginBase.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* 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 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);
|
||||
this.events.emit('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);
|
||||
this.events.emit('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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
139
desktop/flipper-plugin-core/src/plugin/SandyPluginDefinition.tsx
Normal file
139
desktop/flipper-plugin-core/src/plugin/SandyPluginDefinition.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user