From f0b5e7cadb8c028f421901638bc04ef191c0d813 Mon Sep 17 00:00:00 2001 From: Andrey Goncharov Date: Tue, 10 May 2022 05:13:24 -0700 Subject: [PATCH] Copy RenderHost, FlipperLib initialization, device definition to flipper-frontend-core Reviewed By: passy Differential Revision: D36129746 fbshipit-source-id: 15e32b9482d7fe3a24567d2e6bc087095b98226e --- .../flipper-frontend-core/src/RenderHost.tsx | 158 ++++++++ .../src/devices/ArchivedDevice.tsx | 84 ++++ .../src/devices/BaseDevice.tsx | 378 ++++++++++++++++++ .../src/fb-stubs/constants.tsx | 12 + .../flipperLibImplementation/downloadFile.tsx | 72 ++++ .../src/flipperLibImplementation/index.tsx | 113 ++++++ .../src/utils/createServerAddOnControls.tsx | 99 +++++ .../src/utils/isPluginCompatible.tsx | 26 ++ .../src/utils/isPluginVersionMoreRecent.tsx | 54 +++ .../src/utils/pluginKey.tsx | 27 ++ 10 files changed, 1023 insertions(+) create mode 100644 desktop/flipper-frontend-core/src/RenderHost.tsx create mode 100644 desktop/flipper-frontend-core/src/devices/ArchivedDevice.tsx create mode 100644 desktop/flipper-frontend-core/src/devices/BaseDevice.tsx create mode 100644 desktop/flipper-frontend-core/src/fb-stubs/constants.tsx create mode 100644 desktop/flipper-frontend-core/src/flipperLibImplementation/downloadFile.tsx create mode 100644 desktop/flipper-frontend-core/src/flipperLibImplementation/index.tsx create mode 100644 desktop/flipper-frontend-core/src/utils/createServerAddOnControls.tsx create mode 100644 desktop/flipper-frontend-core/src/utils/isPluginCompatible.tsx create mode 100644 desktop/flipper-frontend-core/src/utils/isPluginVersionMoreRecent.tsx create mode 100644 desktop/flipper-frontend-core/src/utils/pluginKey.tsx diff --git a/desktop/flipper-frontend-core/src/RenderHost.tsx b/desktop/flipper-frontend-core/src/RenderHost.tsx new file mode 100644 index 000000000..4bf1bee34 --- /dev/null +++ b/desktop/flipper-frontend-core/src/RenderHost.tsx @@ -0,0 +1,158 @@ +/** + * 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 {FlipperLib, Notification} from 'flipper-plugin'; +import {FlipperServer, FlipperServerConfig} from 'flipper-common'; + +type NotificationEvents = 'show' | 'click' | 'close' | 'reply' | 'action'; +type PluginNotification = { + notification: Notification; + pluginId: string; + client: null | string; // id +}; +type Icon = { + name: string; + variant: 'outline' | 'filled'; + size: number; + density: number; +}; + +interface NotificationAction { + // Docs: https://electronjs.org/docs/api/structures/notification-action + + /** + * The label for the given action. + */ + text?: string; + /** + * The type of action, can be `button`. + */ + type: 'button'; +} + +// Subset of electron.d.ts +interface NotificationConstructorOptions { + /** + * A title for the notification, which will be shown at the top of the notification + * window when it is shown. + */ + title: string; + /** + * The body text of the notification, which will be displayed below the title or + * subtitle. + */ + body: string; + /** + * Actions to add to the notification. Please read the available actions and + * limitations in the `NotificationAction` documentation. + * + * @platform darwin + */ + actions?: NotificationAction[]; + /** + * A custom title for the close button of an alert. An empty string will cause the + * default localized text to be used. + * + * @platform darwin + */ + closeButtonText?: string; +} + +// Events that are emitted from the main.ts ovr the IPC process bridge in Electron +type MainProcessEvents = { + 'flipper-protocol-handler': [query: string]; + 'open-flipper-file': [url: string]; + notificationEvent: [ + eventName: NotificationEvents, + pluginNotification: PluginNotification, + arg: null | string | number, + ]; + trackUsage: any[]; + getLaunchTime: [launchStartTime: number]; +}; + +// Events that are emitted by the child process, to the main process +type ChildProcessEvents = { + setTheme: [theme: 'dark' | 'light' | 'system']; + sendNotification: [ + { + payload: NotificationConstructorOptions; + pluginNotification: PluginNotification; + closeAfter?: number; + }, + ]; + getLaunchTime: []; + componentDidMount: []; +}; + +/** + * Utilities provided by the render host, e.g. Electron, the Browser, etc + */ +export interface RenderHost { + readTextFromClipboard(): string | undefined; + writeTextToClipboard(text: string): void; + /** + * @deprecated + * WARNING! + * It is a low-level API call that might be removed in the future. + * It is not really deprecated yet, but we'll try to make it so. + * TODO: Remove in favor of "exportFile" + */ + showSaveDialog?(options: { + defaultPath?: string; + message?: string; + title?: string; + }): Promise; + /** + * @deprecated + * WARNING! + * It is a low-level API call that might be removed in the future. + * It is not really deprecated yet, but we'll try to make it so. + * TODO: Remove in favor of "importFile" + */ + showOpenDialog?(options: { + defaultPath?: string; + filter?: { + extensions: string[]; + name: string; + }; + }): Promise; + showSelectDirectoryDialog?(defaultPath?: string): Promise; + importFile: FlipperLib['importFile']; + exportFile: FlipperLib['exportFile']; + hasFocus(): boolean; + onIpcEvent( + event: Event, + callback: (...arg: MainProcessEvents[Event]) => void, + ): void; + sendIpcEvent( + event: Event, + ...args: ChildProcessEvents[Event] + ): void; + shouldUseDarkColors(): boolean; + restartFlipper(update?: boolean): void; + openLink(url: string): void; + loadDefaultPlugins(): Record; + GK(gatekeeper: string): boolean; + flipperServer: FlipperServer; + serverConfig: FlipperServerConfig; + requirePlugin(path: string): Promise; + getStaticResourceUrl(relativePath: string): string; + // given the requested icon and proposed public url of the icon, rewrite it to a local icon if needed + getLocalIconUrl?(icon: Icon, publicUrl: string): string; + unloadModule?(path: string): void; + getPercentCPUUsage?(): number; +} + +export function getRenderHostInstance(): RenderHost { + if (!FlipperRenderHostInstance) { + throw new Error('global FlipperRenderHostInstance was never set'); + } + return FlipperRenderHostInstance; +} diff --git a/desktop/flipper-frontend-core/src/devices/ArchivedDevice.tsx b/desktop/flipper-frontend-core/src/devices/ArchivedDevice.tsx new file mode 100644 index 000000000..63dc423bc --- /dev/null +++ b/desktop/flipper-frontend-core/src/devices/ArchivedDevice.tsx @@ -0,0 +1,84 @@ +/** + * 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 BaseDevice from './BaseDevice'; +import type {DeviceOS, DeviceType} from 'flipper-plugin'; + +export default class ArchivedDevice extends BaseDevice { + isArchived = true; + + constructor(options: { + serial: string; + deviceType: DeviceType; + title: string; + os: DeviceOS; + screenshotHandle?: string | null; + source?: string; + supportRequestDetails?: object; + }) { + super( + { + async connect() {}, + close() {}, + exec(command, ..._args: any[]) { + throw new Error( + `[Archived device] Cannot invoke command ${command} on an archived device`, + ); + }, + on(event) { + console.warn( + `Cannot subscribe to server events from an Archived device: ${event}`, + ); + }, + off() {}, + }, + { + deviceType: options.deviceType, + title: options.title, + os: options.os, + serial: options.serial, + icon: 'box', + features: { + screenCaptureAvailable: false, + screenshotAvailable: false, + }, + }, + ); + this.connected.set(false); + this.source = options.source || ''; + this.supportRequestDetails = options.supportRequestDetails; + this.archivedScreenshotHandle = options.screenshotHandle ?? null; + } + + archivedScreenshotHandle: string | null; + + displayTitle(): string { + return `${this.title} ${this.source ? '(Imported)' : '(Offline)'}`; + } + + supportRequestDetails?: object; + + getArchivedScreenshotHandle(): string | null { + return this.archivedScreenshotHandle; + } + + /** + * @override + */ + async startLogging() { + // No-op + } + + /** + * @override + */ + async stopLogging() { + // No-op + } +} diff --git a/desktop/flipper-frontend-core/src/devices/BaseDevice.tsx b/desktop/flipper-frontend-core/src/devices/BaseDevice.tsx new file mode 100644 index 000000000..a393c16a2 --- /dev/null +++ b/desktop/flipper-frontend-core/src/devices/BaseDevice.tsx @@ -0,0 +1,378 @@ +/** + * 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 { + Device, + _SandyDevicePluginInstance, + _SandyPluginDefinition, + DeviceLogListener, + Idler, + createState, + getFlipperLib, + CrashLogListener, +} from 'flipper-plugin'; +import { + DeviceLogEntry, + DeviceOS, + DeviceType, + DeviceDescription, + FlipperServer, + CrashLog, + ServerAddOnControls, +} from 'flipper-common'; +import {DeviceSpec, PluginDetails} from 'flipper-common'; +import {getPluginKey} from '../utils/pluginKey'; +import {Base64} from 'js-base64'; +import {createServerAddOnControls} from '../utils/createServerAddOnControls'; + +type PluginDefinition = _SandyPluginDefinition; +type PluginMap = Map; + +export type DeviceExport = { + os: DeviceOS; + title: string; + deviceType: DeviceType; + serial: string; + pluginStates: Record; +}; + +export default class BaseDevice implements Device { + description: DeviceDescription; + flipperServer: FlipperServer; + isArchived = false; + hasDevicePlugins = false; // true if there are device plugins for this device (not necessarily enabled) + private readonly serverAddOnControls: ServerAddOnControls; + + constructor(flipperServer: FlipperServer, description: DeviceDescription) { + this.flipperServer = flipperServer; + this.description = description; + this.serverAddOnControls = createServerAddOnControls(this.flipperServer); + } + + get isConnected(): boolean { + return this.connected.get(); + } + + // operating system of this device + get os() { + return this.description.os; + } + + // human readable name for this device + get title(): string { + return this.description.title; + } + + // type of this device + get deviceType() { + return this.description.deviceType; + } + + // serial number for this device + get serial() { + return this.description.serial; + } + + // additional device specs used for plugin compatibility checks + get specs(): DeviceSpec[] { + return this.description.specs ?? []; + } + + // possible src of icon to display next to the device title + get icon() { + return this.description.icon; + } + + logListeners: Map = new Map(); + + crashListeners: Map = new Map(); + + readonly connected = createState(true); + + // if imported, stores the original source location + source = ''; + + // TODO: ideally we don't want BasePlugin to know about the concept of plugins + sandyPluginStates: Map = new Map< + string, + _SandyDevicePluginInstance + >(); + + supportsOS(os: DeviceOS) { + return os.toLowerCase() === this.os.toLowerCase(); + } + + displayTitle(): string { + return this.connected.get() ? this.title : `${this.title} (Offline)`; + } + + async exportState( + idler: Idler, + onStatusMessage: (msg: string) => void, + selectedPlugins: string[], + ): Promise> { + const pluginStates: Record = {}; + + for (const instance of this.sandyPluginStates.values()) { + if ( + selectedPlugins.includes(instance.definition.id) && + instance.isPersistable() + ) { + pluginStates[instance.definition.id] = await instance.exportState( + idler, + onStatusMessage, + ); + } + } + + return pluginStates; + } + + toJSON() { + return { + os: this.os, + title: this.title, + deviceType: this.deviceType, + serial: this.serial, + }; + } + + private deviceLogEventHandler = (payload: { + serial: string; + entry: DeviceLogEntry; + }) => { + if (payload.serial === this.serial && this.logListeners.size > 0) { + this.addLogEntry(payload.entry); + } + }; + + addLogEntry(entry: DeviceLogEntry) { + this.logListeners.forEach((listener) => { + // prevent breaking other listeners, if one listener doesn't work. + try { + listener(entry); + } catch (e) { + console.error(`Log listener exception:`, e); + } + }); + } + + async startLogging() { + this.flipperServer.on('device-log', this.deviceLogEventHandler); + } + + stopLogging() { + this.flipperServer.off('device-log', this.deviceLogEventHandler); + } + + addLogListener(callback: DeviceLogListener): Symbol { + if (this.logListeners.size === 0) { + this.startLogging(); + } + const id = Symbol(); + this.logListeners.set(id, callback); + return id; + } + + removeLogListener(id: Symbol) { + this.logListeners.delete(id); + if (this.logListeners.size === 0) { + this.stopLogging(); + } + } + + private crashLogEventHandler = (payload: { + serial: string; + crash: CrashLog; + }) => { + if (payload.serial === this.serial && this.crashListeners.size > 0) { + this.addCrashEntry(payload.crash); + } + }; + + addCrashEntry(entry: CrashLog) { + this.crashListeners.forEach((listener) => { + // prevent breaking other listeners, if one listener doesn't work. + try { + listener(entry); + } catch (e) { + console.error(`Crash listener exception:`, e); + } + }); + } + + async startCrashWatcher() { + this.flipperServer.on('device-crash', this.crashLogEventHandler); + } + + stopCrashWatcher() { + this.flipperServer.off('device-crash', this.crashLogEventHandler); + } + + addCrashListener(callback: CrashLogListener): Symbol { + if (this.crashListeners.size === 0) { + this.startCrashWatcher(); + } + const id = Symbol(); + this.crashListeners.set(id, callback); + return id; + } + + removeCrashListener(id: Symbol) { + this.crashListeners.delete(id); + if (this.crashListeners.size === 0) { + this.stopCrashWatcher(); + } + } + + async navigateToLocation(location: string) { + return this.flipperServer.exec('device-navigate', this.serial, location); + } + + async screenshot(): Promise { + if (!this.description.features.screenshotAvailable || this.isArchived) { + return; + } + return Base64.toUint8Array( + await this.flipperServer.exec('device-take-screenshot', this.serial), + ); + } + + async startScreenCapture(destination: string): Promise { + return this.flipperServer.exec( + 'device-start-screencapture', + this.serial, + destination, + ); + } + + async stopScreenCapture(): Promise { + return this.flipperServer.exec('device-stop-screencapture', this.serial); + } + + async executeShell(command: string): Promise { + return this.flipperServer.exec('device-shell-exec', this.serial, command); + } + + async sendMetroCommand(command: string): Promise { + return this.flipperServer.exec('metro-command', this.serial, command); + } + + async forwardPort(local: string, remote: string): Promise { + return this.flipperServer.exec( + 'device-forward-port', + this.serial, + local, + remote, + ); + } + + async clearLogs() { + return this.flipperServer.exec('device-clear-logs', this.serial); + } + + supportsPlugin(plugin: PluginDefinition | PluginDetails) { + let pluginDetails: PluginDetails; + if (plugin instanceof _SandyPluginDefinition) { + pluginDetails = plugin.details; + if (!pluginDetails.pluginType && !pluginDetails.supportedDevices) { + // TODO T84453692: this branch is to support plugins defined with the legacy approach. Need to remove this branch after some transition period when + // all the plugins will be migrated to the new approach with static compatibility metadata in package.json. + if (plugin instanceof _SandyPluginDefinition) { + return ( + plugin.isDevicePlugin && + (plugin.asDevicePluginModule().supportsDevice?.(this as any) ?? + false) + ); + } else { + return (plugin as any).supportsDevice(this); + } + } + } else { + pluginDetails = plugin; + } + return ( + pluginDetails.pluginType === 'device' && + (!pluginDetails.supportedDevices || + pluginDetails.supportedDevices?.some( + (d) => + (!d.os || d.os === this.os) && + (!d.type || d.type === this.deviceType) && + (d.archived === undefined || d.archived === this.isArchived) && + (!d.specs || d.specs.every((spec) => this.specs.includes(spec))), + )) + ); + } + + loadDevicePlugins( + devicePlugins: PluginMap, + enabledDevicePlugins: Set, + pluginStates?: Record, + ) { + if (!devicePlugins) { + return; + } + const plugins = Array.from(devicePlugins.values()).filter((p) => + enabledDevicePlugins?.has(p.id), + ); + for (const plugin of plugins) { + this.loadDevicePlugin(plugin, pluginStates?.[plugin.id]); + } + } + + loadDevicePlugin(plugin: PluginDefinition, initialState?: any) { + if (!this.supportsPlugin(plugin)) { + return; + } + this.hasDevicePlugins = true; + if (plugin instanceof _SandyPluginDefinition) { + try { + this.sandyPluginStates.set( + plugin.id, + new _SandyDevicePluginInstance( + this.serverAddOnControls, + getFlipperLib(), + plugin, + this, + // break circular dep, one of those days again... + getPluginKey(undefined, {serial: this.serial}, plugin.id), + initialState, + ), + ); + } catch (e) { + console.error(`Failed to start device plugin '${plugin.id}': `, e); + } + } + } + + unloadDevicePlugin(pluginId: string) { + const instance = this.sandyPluginStates.get(pluginId); + if (instance) { + instance.destroy(); + this.sandyPluginStates.delete(pluginId); + } + } + + disconnect() { + this.logListeners.clear(); + this.stopLogging(); + this.crashListeners.clear(); + this.stopCrashWatcher(); + this.connected.set(false); + } + + destroy() { + this.disconnect(); + this.sandyPluginStates.forEach((instance) => { + instance.destroy(); + }); + this.sandyPluginStates.clear(); + this.serverAddOnControls.unsubscribe(); + } +} diff --git a/desktop/flipper-frontend-core/src/fb-stubs/constants.tsx b/desktop/flipper-frontend-core/src/fb-stubs/constants.tsx new file mode 100644 index 000000000..6b2dcd6e7 --- /dev/null +++ b/desktop/flipper-frontend-core/src/fb-stubs/constants.tsx @@ -0,0 +1,12 @@ +/** + * 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 default Object.freeze({ + IS_PUBLIC_BUILD: true, +}); diff --git a/desktop/flipper-frontend-core/src/flipperLibImplementation/downloadFile.tsx b/desktop/flipper-frontend-core/src/flipperLibImplementation/downloadFile.tsx new file mode 100644 index 000000000..2739726c0 --- /dev/null +++ b/desktop/flipper-frontend-core/src/flipperLibImplementation/downloadFile.tsx @@ -0,0 +1,72 @@ +/** + * 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 {assertNever, DownloadFileUpdate} from 'flipper-common'; +import {FlipperLib, DownloadFileResponse} from 'flipper-plugin'; +import {RenderHost} from '../RenderHost'; + +export const downloadFileFactory = + (renderHost: RenderHost): FlipperLib['remoteServerContext']['downloadFile'] => + async (url, dest, {onProgressUpdate, ...options} = {}) => { + const downloadDescriptor = (await renderHost.flipperServer.exec( + 'download-file-start', + url, + dest, + options, + // Casting to DownloadFileResponse to add `completed` field to `downloadDescriptor`. + )) as DownloadFileResponse; + + let onProgressUpdateWrapped: (progressUpdate: DownloadFileUpdate) => void; + const completed = new Promise((resolve, reject) => { + onProgressUpdateWrapped = (progressUpdate: DownloadFileUpdate) => { + if (progressUpdate.id === downloadDescriptor.id) { + const {status} = progressUpdate; + switch (status) { + case 'downloading': { + onProgressUpdate?.(progressUpdate); + break; + } + case 'success': { + resolve(progressUpdate.downloaded); + break; + } + case 'error': { + reject( + new Error( + `File download failed. Last message: ${JSON.stringify( + progressUpdate, + )}`, + ), + ); + break; + } + default: { + assertNever(status); + } + } + } + }; + renderHost.flipperServer.on( + 'download-file-update', + onProgressUpdateWrapped, + ); + }); + + // eslint-disable-next-line promise/catch-or-return + completed.finally(() => { + renderHost.flipperServer.off( + 'download-file-update', + onProgressUpdateWrapped, + ); + }); + + downloadDescriptor.completed = completed; + + return downloadDescriptor; + }; diff --git a/desktop/flipper-frontend-core/src/flipperLibImplementation/index.tsx b/desktop/flipper-frontend-core/src/flipperLibImplementation/index.tsx new file mode 100644 index 000000000..c516ae7e2 --- /dev/null +++ b/desktop/flipper-frontend-core/src/flipperLibImplementation/index.tsx @@ -0,0 +1,113 @@ +/** + * 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 {RemoteServerContext, FlipperLib} from 'flipper-plugin'; +import { + BufferEncoding, + ExecOptions, + fsConstants, + Logger, + MkdirOptions, + RmOptions, +} from 'flipper-common'; +import constants from '../fb-stubs/constants'; +import {RenderHost} from '../RenderHost'; +import {downloadFileFactory} from './downloadFile'; +import {Base64} from 'js-base64'; + +export function baseFlipperLibImplementation( + renderHost: RenderHost, + logger: Logger, +): Omit< + FlipperLib, + 'enableMenuEntries' | 'selectPlugin' | 'showNotification' | 'createPaste' +> { + return { + isFB: !constants.IS_PUBLIC_BUILD, + logger, + GK: renderHost.GK, + writeTextToClipboard: renderHost.writeTextToClipboard, + openLink: renderHost.openLink, + importFile: renderHost.importFile, + exportFile: renderHost.exportFile, + paths: { + appPath: renderHost.serverConfig.paths.appPath, + homePath: renderHost.serverConfig.paths.homePath, + staticPath: renderHost.serverConfig.paths.staticPath, + tempPath: renderHost.serverConfig.paths.tempPath, + }, + environmentInfo: { + os: renderHost.serverConfig.environmentInfo.os, + }, + remoteServerContext: { + childProcess: { + exec: async ( + command: string, + options?: ExecOptions & {encoding?: BufferEncoding}, + ) => renderHost.flipperServer.exec('node-api-exec', command, options), + }, + fs: { + access: async (path: string, mode?: number) => + renderHost.flipperServer.exec('node-api-fs-access', path, mode), + pathExists: async (path: string, mode?: number) => + renderHost.flipperServer.exec('node-api-fs-pathExists', path, mode), + unlink: async (path: string) => + renderHost.flipperServer.exec('node-api-fs-unlink', path), + mkdir: (async ( + path: string, + options?: {recursive?: boolean} & MkdirOptions, + ) => + renderHost.flipperServer.exec( + 'node-api-fs-mkdir', + path, + options, + )) as RemoteServerContext['fs']['mkdir'], + rm: async (path: string, options?: RmOptions) => + renderHost.flipperServer.exec('node-api-fs-rm', path, options), + copyFile: async (src: string, dest: string, flags?: number) => + renderHost.flipperServer.exec( + 'node-api-fs-copyFile', + src, + dest, + flags, + ), + constants: fsConstants, + stat: async (path: string) => + renderHost.flipperServer.exec('node-api-fs-stat', path), + readlink: async (path: string) => + renderHost.flipperServer.exec('node-api-fs-readlink', path), + readFile: (path, options) => + renderHost.flipperServer.exec('node-api-fs-readfile', path, options), + readFileBinary: async (path) => + Base64.toUint8Array( + await renderHost.flipperServer.exec( + 'node-api-fs-readfile-binary', + path, + ), + ), + writeFile: (path, contents, options) => + renderHost.flipperServer.exec( + 'node-api-fs-writefile', + path, + contents, + options, + ), + writeFileBinary: async (path, contents) => { + const base64contents = Base64.fromUint8Array(contents); + return await renderHost.flipperServer.exec( + 'node-api-fs-writefile-binary', + path, + base64contents, + ); + }, + }, + downloadFile: downloadFileFactory(renderHost), + }, + }; +} diff --git a/desktop/flipper-frontend-core/src/utils/createServerAddOnControls.tsx b/desktop/flipper-frontend-core/src/utils/createServerAddOnControls.tsx new file mode 100644 index 000000000..76dd0a0ec --- /dev/null +++ b/desktop/flipper-frontend-core/src/utils/createServerAddOnControls.tsx @@ -0,0 +1,99 @@ +/** + * 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 { + ExecuteMessage, + FlipperServer, + ServerAddOnControls, + deserializeRemoteError, +} from 'flipper-common'; + +type PluginName = string; +type Method = string; + +export const createServerAddOnControls = ( + flipperServer: FlipperServer, +): ServerAddOnControls => { + const methodHandlers = new Map< + PluginName, + Map void> + >(); + const catchAllHandlers = new Map< + PluginName, + (method: string, data: unknown) => void + >(); + + let subscribed = false; + const subscriptionCb = ({params}: ExecuteMessage) => { + const pluginName = params.api; + + const methodHandler = methodHandlers.get(pluginName)?.get(params.method); + + if (methodHandler) { + methodHandler(params.params); + return; + } + + const catchAllHandler = catchAllHandlers.get(pluginName); + catchAllHandler?.(params.method, params.params); + }; + + return { + start: (pluginName, details, owner) => + flipperServer.exec( + 'plugins-server-add-on-start', + pluginName, + details, + owner, + ), + stop: (pluginName, owner) => + flipperServer.exec('plugins-server-add-on-stop', pluginName, owner), + sendMessage: async (pluginName, method, params) => { + const res = await flipperServer.exec( + 'plugins-server-add-on-request-response', + { + method: 'execute', + params: { + method, + api: pluginName, + params, + }, + }, + ); + + if (res.error) { + throw deserializeRemoteError(res.error); + } + + return res.success; + }, + receiveMessage: (pluginName, method, receiver) => { + if (!methodHandlers.has(pluginName)) { + methodHandlers.set(pluginName, new Map()); + } + methodHandlers.get(pluginName)!.set(method, receiver); + + // Subscribe client/device to messages from flipper server only when the first plugin subscribes to them + if (!subscribed) { + subscribed = true; + flipperServer.on('plugins-server-add-on-message', subscriptionCb); + } + }, + receiveAnyMessage: (pluginName, receiver) => { + catchAllHandlers.set(pluginName, receiver); + }, + unsubscribePlugin: (pluginName) => { + methodHandlers.delete(pluginName); + catchAllHandlers.delete(pluginName); + }, + unsubscribe: () => { + flipperServer.off('plugins-server-add-on-message', subscriptionCb); + }, + }; +}; diff --git a/desktop/flipper-frontend-core/src/utils/isPluginCompatible.tsx b/desktop/flipper-frontend-core/src/utils/isPluginCompatible.tsx new file mode 100644 index 000000000..b5862de0e --- /dev/null +++ b/desktop/flipper-frontend-core/src/utils/isPluginCompatible.tsx @@ -0,0 +1,26 @@ +/** + * 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 {PluginDetails} from 'flipper-common'; +import semver from 'semver'; +import {getRenderHostInstance} from '../RenderHost'; + +export function isPluginCompatible( + plugin: PluginDetails, + flipperVersion: string, +) { + return ( + getRenderHostInstance().GK('flipper_disable_plugin_compatibility_checks') || + flipperVersion === '0.0.0' || + !plugin.engines?.flipper || + semver.lte(plugin.engines?.flipper, flipperVersion) + ); +} + +export default isPluginCompatible; diff --git a/desktop/flipper-frontend-core/src/utils/isPluginVersionMoreRecent.tsx b/desktop/flipper-frontend-core/src/utils/isPluginVersionMoreRecent.tsx new file mode 100644 index 000000000..657888436 --- /dev/null +++ b/desktop/flipper-frontend-core/src/utils/isPluginVersionMoreRecent.tsx @@ -0,0 +1,54 @@ +/** + * 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 {ConcretePluginDetails} from 'flipper-common'; +import semver from 'semver'; +import isPluginCompatible from './isPluginCompatible'; + +export function isPluginVersionMoreRecent( + versionDetails: ConcretePluginDetails, + otherVersionDetails: ConcretePluginDetails, + flipperVersion: string, +) { + const isPlugin1Compatible = isPluginCompatible( + versionDetails, + flipperVersion, + ); + const isPlugin2Compatible = isPluginCompatible( + otherVersionDetails, + flipperVersion, + ); + + // prefer compatible plugins + if (isPlugin1Compatible && !isPlugin2Compatible) return true; + if (!isPlugin1Compatible && isPlugin2Compatible) return false; + + // prefer plugins with greater version + if (semver.gt(versionDetails.version, otherVersionDetails.version)) { + return true; + } + if ( + semver.eq(versionDetails.version, otherVersionDetails.version) && + versionDetails.isBundled + ) { + // prefer bundled versions + return true; + } + if ( + semver.eq(versionDetails.version, otherVersionDetails.version) && + versionDetails.isActivatable && + !otherVersionDetails.isActivatable + ) { + // prefer locally available versions to the versions available remotely on marketplace + return true; + } + return false; +} + +export default isPluginVersionMoreRecent; diff --git a/desktop/flipper-frontend-core/src/utils/pluginKey.tsx b/desktop/flipper-frontend-core/src/utils/pluginKey.tsx new file mode 100644 index 000000000..54c895333 --- /dev/null +++ b/desktop/flipper-frontend-core/src/utils/pluginKey.tsx @@ -0,0 +1,27 @@ +/** + * 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 function getPluginKey( + selectedAppId: string | null | undefined, + baseDevice: {serial: string} | null | undefined, + pluginID: string, +): string { + if (selectedAppId) { + return `${selectedAppId}#${pluginID}`; + } + if (baseDevice) { + // If selected App is not defined, then the plugin is a device plugin + return `${baseDevice.serial}#${pluginID}`; + } + return `unknown#${pluginID}`; +} + +export const pluginKey = (serial: string, pluginName: string): string => { + return `${serial}#${pluginName}`; +};