diff --git a/desktop/app/src/__tests__/PluginContainer.node.tsx b/desktop/app/src/__tests__/PluginContainer.node.tsx index c161b8d31..bf626cee2 100644 --- a/desktop/app/src/__tests__/PluginContainer.node.tsx +++ b/desktop/app/src/__tests__/PluginContainer.node.tsx @@ -876,3 +876,128 @@ test('PluginContainer + Sandy device plugin supports deeplink', async () => { }); expect(linksSeen).toEqual([theUniverse, 'london!', 'london!']); }); + +test('Sandy plugins support isPluginSupported + selectPlugin', async () => { + let renders = 0; + const linksSeen: any[] = []; + + function MySandyPlugin() { + renders++; + return

Plugin1

; + } + + const plugin = (client: PluginClient) => { + const activatedStub = jest.fn(); + const deactivatedStub = jest.fn(); + client.onDeepLink((link) => { + linksSeen.push(link); + }); + client.onActivate(activatedStub); + client.onDeactivate(deactivatedStub); + return { + activatedStub, + deactivatedStub, + isPluginAvailable: client.isPluginAvailable, + selectPlugin: client.selectPlugin, + }; + }; + + const definition = new _SandyPluginDefinition( + TestUtils.createMockPluginDetails({id: 'base'}), + { + plugin, + Component: MySandyPlugin, + }, + ); + const definition2 = new _SandyPluginDefinition( + TestUtils.createMockPluginDetails({id: 'other'}), + { + plugin() { + return {}; + }, + Component() { + return

Plugin2

; + }, + }, + ); + const definition3 = new _SandyPluginDefinition( + TestUtils.createMockPluginDetails({id: 'device'}), + { + supportsDevice() { + return true; + }, + devicePlugin() { + return {}; + }, + Component() { + return

Plugin3

; + }, + }, + ); + const {renderer, client, store} = await renderMockFlipperWithPlugin( + definition, + { + additionalPlugins: [definition2, definition3], + }, + ); + + expect(renderer.baseElement.querySelector('h1')).toMatchInlineSnapshot(` +

+ Plugin1 +

+ `); + expect(renders).toBe(1); + + const pluginInstance: ReturnType = client.sandyPluginStates.get( + definition.id, + )!.instanceApi; + expect(pluginInstance.isPluginAvailable(definition.id)).toBeTruthy(); + expect(pluginInstance.isPluginAvailable('nonsense')).toBeFalsy(); + expect(pluginInstance.isPluginAvailable(definition2.id)).toBeFalsy(); // not enabled yet + expect(pluginInstance.isPluginAvailable(definition3.id)).toBeTruthy(); + expect(pluginInstance.activatedStub).toBeCalledTimes(1); + expect(pluginInstance.deactivatedStub).toBeCalledTimes(0); + expect(linksSeen).toEqual([]); + + // open a device plugin + pluginInstance.selectPlugin(definition3.id); + expect(store.getState().connections.selectedPlugin).toBe(definition3.id); + expect(renderer.baseElement.querySelector('h1')).toMatchInlineSnapshot(` +

+ Plugin3 +

+ `); + expect(pluginInstance.deactivatedStub).toBeCalledTimes(1); + + // go back by opening own plugin again (funny, but why not) + pluginInstance.selectPlugin(definition.id, 'data'); + expect(store.getState().connections.selectedPlugin).toBe(definition.id); + expect(pluginInstance.activatedStub).toBeCalledTimes(2); + expect(renderer.baseElement.querySelector('h1')).toMatchInlineSnapshot(` +

+ Plugin1 +

+ `); + expect(linksSeen).toEqual(['data']); + + // try to go to plugin 2, fails (not starred, so no-op) + pluginInstance.selectPlugin(definition2.id); + expect(store.getState().connections.selectedPlugin).toBe(definition.id); + + // star plugin 2 and navigate to plugin 2 + store.dispatch( + starPlugin({ + plugin: definition2, + selectedApp: client.query.app, + }), + ); + pluginInstance.selectPlugin(definition2.id); + expect(store.getState().connections.selectedPlugin).toBe(definition2.id); + expect(pluginInstance.deactivatedStub).toBeCalledTimes(2); + expect(renderer.baseElement.querySelector('h1')).toMatchInlineSnapshot(` +

+ Plugin2 +

+ `); + expect(renders).toBe(2); +}); diff --git a/desktop/app/src/reducers/connections.tsx b/desktop/app/src/reducers/connections.tsx index c475d7e56..c844abd61 100644 --- a/desktop/app/src/reducers/connections.tsx +++ b/desktop/app/src/reducers/connections.tsx @@ -9,9 +9,9 @@ import {produce} from 'immer'; -import BaseDevice from '../devices/BaseDevice'; +import type BaseDevice from '../devices/BaseDevice'; import MacDevice from '../devices/MacDevice'; -import Client from '../Client'; +import type Client from '../Client'; import {UninitializedClient} from '../UninitializedClient'; import {isEqual} from 'lodash'; import {performance} from 'perf_hooks'; diff --git a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx index 42c912928..fa66cc84a 100644 --- a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx +++ b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx @@ -16,7 +16,6 @@ import { act as testingLibAct, } from '@testing-library/react'; import {queries} from '@testing-library/dom'; -import {TestUtils} from 'flipper-plugin'; import { selectPlugin, @@ -37,7 +36,7 @@ import {registerPlugins} from '../reducers/plugins'; import PluginContainer from '../PluginContainer'; import {getPluginKey, isDevicePluginDefinition} from '../utils/pluginUtils'; import {getInstance} from '../fb-stubs/Logger'; -import {setFlipperLibImplementation} from '../utils/flipperLibImplementation'; +import {initializeFlipperLibImplementation} from '../utils/flipperLibImplementation'; export type MockFlipperResult = { client: Client; @@ -55,7 +54,8 @@ type MockOptions = Partial<{ * can be used to intercept outgoing calls. If it returns undefined * the base implementation will be used */ - onSend(pluginId: string, method: string, params?: object): any; + onSend?: (pluginId: string, method: string, params?: object) => any; + additionalPlugins?: PluginDefinition[]; }>; export async function createMockFlipperWithPlugin( @@ -64,9 +64,10 @@ export async function createMockFlipperWithPlugin( ): Promise { const store = createStore(rootReducer); const logger = getInstance(); - setFlipperLibImplementation(TestUtils.createMockFlipperLib()); - - store.dispatch(registerPlugins([pluginClazz])); + initializeFlipperLibImplementation(store, logger); + store.dispatch( + registerPlugins([pluginClazz, ...(options?.additionalPlugins ?? [])]), + ); function createDevice(serial: string): BaseDevice { const device = new BaseDevice( diff --git a/desktop/app/src/utils/flipperLibImplementation.tsx b/desktop/app/src/utils/flipperLibImplementation.tsx index 838c3da8a..b0915b061 100644 --- a/desktop/app/src/utils/flipperLibImplementation.tsx +++ b/desktop/app/src/utils/flipperLibImplementation.tsx @@ -12,18 +12,18 @@ import type {Logger} from '../fb-interfaces/Logger'; import type {Store} from '../reducers'; import createPaste from '../fb-stubs/createPaste'; import GK from '../fb-stubs/GK'; -import {getInstance} from '../fb-stubs/Logger'; +import type BaseDevice from '../devices/BaseDevice'; let flipperLibInstance: FlipperLib | undefined; export function initializeFlipperLibImplementation( - _store: Store, - _logger: Logger, + store: Store, + logger: Logger, ) { // late require to avoid cyclic dependency const {addSandyPluginEntries} = require('../MenuBar'); flipperLibInstance = { - logger: getInstance(), + logger, enableMenuEntries(entries) { addSandyPluginEntries(entries); }, @@ -31,6 +31,44 @@ export function initializeFlipperLibImplementation( GK(gatekeeper: string) { return GK.get(gatekeeper); }, + isPluginAvailable(device, client, pluginId) { + // supported device pluin + if (device.devicePlugins.includes(pluginId)) { + return true; + } + if (client) { + // plugin supported? + if (client.plugins.includes(pluginId)) { + // part of an archived device? + if (device.isArchived) { + return true; + } + // plugin enabled? + if ( + store + .getState() + .connections.userStarredPlugins[client.query.app]?.includes( + pluginId, + ) + ) { + return true; + } + } + } + return false; + }, + selectPlugin(device, client, pluginId, deeplink) { + store.dispatch({ + type: 'SELECT_PLUGIN', + payload: { + selectedPlugin: pluginId, + selectedDevice: device as BaseDevice, + selectedApp: client ? client.id : null, + deepLinkPayload: deeplink, + time: Date.now(), + }, + }); + }, }; } diff --git a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx index 1dccffdbd..f58f9e8d9 100644 --- a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx +++ b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx @@ -46,7 +46,17 @@ export type DevicePluginPredicate = (device: Device) => boolean; export type DevicePluginFactory = (client: DevicePluginClient) => object; -export interface DevicePluginClient extends BasePluginClient {} +export interface DevicePluginClient extends BasePluginClient { + /** + * Checks if the provided plugin is available for the current device + */ + isPluginAvailable(pluginId: string): 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; +} /** * Wrapper interface around BaseDevice in Flipper @@ -59,6 +69,7 @@ export interface RealFlipperDevice { addLogListener(callback: DeviceLogListener): Symbol; removeLogListener(id: Symbol): void; addLogEntry(entry: DeviceLogEntry): void; + devicePlugins: string[]; } export class SandyDevicePluginInstance extends BasePluginInstance { @@ -76,7 +87,17 @@ export class SandyDevicePluginInstance extends BasePluginInstance { initialStates?: Record, ) { super(flipperLib, definition, realDevice, initialStates); - this.client = this.createBasePluginClient(); + this.client = { + ...this.createBasePluginClient(), + isPluginAvailable(pluginId: string) { + return flipperLib.isPluginAvailable(realDevice, null, pluginId); + }, + selectPlugin(pluginId: string, deeplink?: unknown) { + if (this.isPluginAvailable(pluginId)) { + flipperLib.selectPlugin(realDevice, null, pluginId, deeplink); + } + }, + }; this.initializePlugin(() => definition.asDevicePluginModule().devicePlugin(this.client), ); diff --git a/desktop/flipper-plugin/src/plugin/FlipperLib.tsx b/desktop/flipper-plugin/src/plugin/FlipperLib.tsx index 9e49c9215..a7b787b44 100644 --- a/desktop/flipper-plugin/src/plugin/FlipperLib.tsx +++ b/desktop/flipper-plugin/src/plugin/FlipperLib.tsx @@ -8,7 +8,9 @@ */ import {Logger} from '../utils/Logger'; +import {RealFlipperDevice} from './DevicePlugin'; import {NormalizedMenuEntry} from './MenuEntry'; +import {RealFlipperClient} from './Plugin'; /** * This interface exposes all global methods for which an implementation will be provided by Flipper itself @@ -18,4 +20,15 @@ export interface FlipperLib { enableMenuEntries(menuEntries: NormalizedMenuEntry[]): void; createPaste(input: string): Promise; GK(gatekeeper: string): boolean; + isPluginAvailable( + device: RealFlipperDevice, + client: RealFlipperClient | null, + pluginId: string, + ): boolean; + selectPlugin( + device: RealFlipperDevice, + client: RealFlipperClient | null, + pluginId: string, + deeplink: unknown, + ): void; } diff --git a/desktop/flipper-plugin/src/plugin/Plugin.tsx b/desktop/flipper-plugin/src/plugin/Plugin.tsx index 18bb293c8..d47ee943a 100644 --- a/desktop/flipper-plugin/src/plugin/Plugin.tsx +++ b/desktop/flipper-plugin/src/plugin/Plugin.tsx @@ -83,6 +83,16 @@ export interface PluginClient< * Checks if a method is available on the client implementation */ supportsMethod(method: keyof Methods): Promise; + + /** + * Checks if the provided plugin is available for the current device / application + */ + isPluginAvailable(pluginId: string): 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; } /** @@ -98,6 +108,7 @@ export interface RealFlipperClient { device_id: string; }; deviceSync: RealFlipperDevice; + plugins: string[]; isBackgroundPlugin(pluginId: string): boolean; initPlugin(pluginId: string): void; deinitPlugin(pluginId: string): void; @@ -174,6 +185,23 @@ export class SandyPluginInstance extends BasePluginInstance { method as any, ); }, + isPluginAvailable(pluginId: string) { + return flipperLib.isPluginAvailable( + realClient.deviceSync, + realClient, + pluginId, + ); + }, + selectPlugin(pluginId: string, deeplink?: unknown) { + if (this.isPluginAvailable(pluginId)) { + flipperLib.selectPlugin( + realClient.deviceSync, + realClient, + pluginId, + deeplink, + ); + } + }, }; this.initializePlugin(() => definition.asPluginModule().plugin(this.client), diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index 2f3bd7dfc..e783d133e 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -190,6 +190,7 @@ export function startPlugin>( const deviceName = 'TestDevice'; const fakeFlipperClient: RealFlipperClient = { id: `${appName}#${testDevice.os}#${deviceName}#${testDevice.serial}`, + plugins: [definition.id], query: { app: appName, device: deviceName, @@ -297,6 +298,7 @@ export function startDevicePlugin( const flipperLib = createMockFlipperLib(options); const testDevice = createMockDevice(options); + testDevice.devicePlugins.push(definition.id); const pluginInstance = new SandyDevicePluginInstance( flipperLib, definition, @@ -353,6 +355,8 @@ export function createMockFlipperLib(options?: StartPluginOptions): FlipperLib { GK(gk: string) { return options?.GKs?.includes(gk) || false; }, + selectPlugin: jest.fn(), + isPluginAvailable: jest.fn().mockImplementation(() => false), }; } @@ -403,6 +407,7 @@ function createMockDevice(options?: StartPluginOptions): RealFlipperDevice { deviceType: 'emulator', serial: 'serial-000', isArchived: !!options?.isArchived, + devicePlugins: [], addLogListener(cb) { logListeners.push(cb); return (logListeners.length - 1) as any; diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index a976dccc1..cac3f817a 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -201,9 +201,21 @@ client.addMenuEntry({ }) ``` +#### `isPluginAvailable` + +Usage: `isPluginAvailable(pluginId: string): boolean` + +Returns `true` if a plugin with the given id is available by for consumption, that is: supported by the current application / device, and enabled by the user. + +#### `selectPlugin` + +Usage: `selectPlugin(pluginId: string, deeplinkPayload?: unknown): void` + +Opens a different plugin by id, optionally providing a deeplink to bring the target plugin to a certain state. + #### `supportsMethod` -Usage; `client.supportsMethod(method: string): Promise` +Usage: `client.supportsMethod(method: string): Promise` Resolves to true if the client supports the specified method. Useful when adding functionality to existing plugins, when connectivity to older clients is still required. Also useful when client plugins are implemented on multitple platforms and don't all have feature parity. @@ -262,6 +274,14 @@ See the similarly named method under [`PluginClient`](#pluginclient). See the similarly named method under [`PluginClient`](#pluginclient). +### `isPluginAvailable` + +See the similarly named method under [`PluginClient`](#pluginclient). + +### `selectPlugin` + +See the similarly named method under [`PluginClient`](#pluginclient). + ## Device `Device` captures the metadata of the device the plugin is currently connected to.