From 01f02b2cab8c9c8a6cfe955b4b518a184ed70af7 Mon Sep 17 00:00:00 2001 From: Anton Nikolaev Date: Tue, 16 Feb 2021 10:46:11 -0800 Subject: [PATCH] Command processing (3/n): Uninstall plugin Summary: *Stack summary*: this stack refactors plugin management actions to perform them in a dispatcher rather than in the root reducer (store.tsx) as all of these actions has side effects. To do that, we store requested plugin management actions (install/update/uninstall, star/unstar) in a queue which is then handled by pluginManager dispatcher. This dispatcher then dispatches all required state updates. *Diff summary*: refactored "uninstall plugin" operation to perform it in pluginManager dispatcher Reviewed By: mweststrate Differential Revision: D26166198 fbshipit-source-id: d74a1d690102d9036c6d3d8612d2428f5ecef4e6 --- desktop/app/src/PluginContainer.tsx | 3 +- .../createMockFlipperWithPlugin.node.tsx.snap | 2 + .../chrome/plugin-manager/PluginInstaller.tsx | 4 +- .../__tests__/PluginInstaller.node.tsx | 2 +- .../__tests__/pluginManager.node.tsx | 54 ++++++++++- .../app/src/dispatcher/pluginDownloads.tsx | 3 +- desktop/app/src/dispatcher/pluginManager.tsx | 67 ++++++++++++- desktop/app/src/dispatcher/plugins.tsx | 2 +- .../reducers/__tests__/pluginManager.node.tsx | 40 -------- .../src/reducers/__tests__/plugins.node.tsx | 38 ++++++++ desktop/app/src/reducers/index.tsx | 15 +-- desktop/app/src/reducers/pluginManager.tsx | 93 ++++++------------- .../app/src/reducers/pluginMessageQueue.tsx | 17 ++++ desktop/app/src/reducers/pluginStates.tsx | 22 ++++- desktop/app/src/reducers/plugins.tsx | 86 ++++++++++++++--- .../sandy-chrome/appinspect/PluginList.tsx | 2 +- desktop/app/src/store.tsx | 26 +----- .../src/utils/__tests__/exportData.node.tsx | 10 ++ .../src/test-utils/test-utils.tsx | 20 +++- 19 files changed, 336 insertions(+), 170 deletions(-) delete mode 100644 desktop/app/src/reducers/__tests__/pluginManager.node.tsx diff --git a/desktop/app/src/PluginContainer.tsx b/desktop/app/src/PluginContainer.tsx index b6cde7c3f..dcf109b27 100644 --- a/desktop/app/src/PluginContainer.tsx +++ b/desktop/app/src/PluginContainer.tsx @@ -556,8 +556,7 @@ export default connect( userStarredPlugins, }, pluginStates, - plugins: {devicePlugins, clientPlugins}, - pluginManager: {installedPlugins}, + plugins: {devicePlugins, clientPlugins, installedPlugins}, pluginMessageQueue, settingsState, }) => { diff --git a/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap b/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap index 7c84e10a2..4199f1442 100644 --- a/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap +++ b/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap @@ -55,8 +55,10 @@ Object { "disabledPlugins": Array [], "failedPlugins": Array [], "gatekeepedPlugins": Array [], + "installedPlugins": Map {}, "loadedPlugins": Map {}, "marketplacePlugins": Array [], "selectedPlugins": Array [], + "uninstalledPlugins": Set {}, } `; diff --git a/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx b/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx index ade5900cb..3f25fc973 100644 --- a/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx +++ b/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx @@ -29,7 +29,7 @@ import React, {useCallback, useState, useEffect} from 'react'; import {List} from 'immutable'; import {reportPlatformFailures, reportUsage} from '../../utils/metrics'; import reloadFlipper from '../../utils/reloadFlipper'; -import {registerInstalledPlugins} from '../../reducers/pluginManager'; +import {registerInstalledPlugins} from '../../reducers/plugins'; import { UpdateResult, getInstalledPlugins, @@ -366,7 +366,7 @@ function useNPMSearch( PluginInstaller.defaultProps = defaultProps; export default connect( - ({pluginManager: {installedPlugins}}) => ({ + ({plugins: {installedPlugins}}) => ({ installedPlugins, }), (dispatch: Dispatch>) => ({ diff --git a/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx b/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx index 1e0a638e3..4f1a82056 100644 --- a/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx +++ b/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx @@ -24,7 +24,7 @@ const getUpdatablePluginsMock = mocked(getUpdatablePlugins); function getStore(installedPlugins: PluginDetails[] = []): Store { return configureStore([])({ application: {sessionId: 'mysession'}, - pluginManager: {installedPlugins}, + plugins: {installedPlugins}, }) as Store; } diff --git a/desktop/app/src/dispatcher/__tests__/pluginManager.node.tsx b/desktop/app/src/dispatcher/__tests__/pluginManager.node.tsx index bed1e4a26..b2a31aa67 100644 --- a/desktop/app/src/dispatcher/__tests__/pluginManager.node.tsx +++ b/desktop/app/src/dispatcher/__tests__/pluginManager.node.tsx @@ -9,7 +9,7 @@ jest.mock('../plugins'); jest.mock('../../utils/electronModuleCache'); -import {loadPlugin} from '../../reducers/pluginManager'; +import {loadPlugin, uninstallPlugin} from '../../reducers/pluginManager'; import {requirePlugin} from '../plugins'; import {mocked} from 'ts-jest/utils'; import {TestUtils} from 'flipper-plugin'; @@ -19,12 +19,14 @@ import MockFlipper from '../../test-utils/MockFlipper'; const pluginDetails1 = TestUtils.createMockPluginDetails({ id: 'plugin1', + name: 'flipper-plugin1', version: '0.0.1', }); const pluginDefinition1 = new SandyPluginDefinition(pluginDetails1, TestPlugin); const pluginDetails1V2 = TestUtils.createMockPluginDetails({ id: 'plugin1', + name: 'flipper-plugin1', version: '0.0.2', }); const pluginDefinition1V2 = new SandyPluginDefinition( @@ -32,7 +34,10 @@ const pluginDefinition1V2 = new SandyPluginDefinition( TestPlugin, ); -const pluginDetails2 = TestUtils.createMockPluginDetails({id: 'plugin2'}); +const pluginDetails2 = TestUtils.createMockPluginDetails({ + id: 'plugin2', + name: 'flipper-plugin2', +}); const pluginDefinition2 = new SandyPluginDefinition(pluginDetails2, TestPlugin); const mockedRequirePlugin = mocked(requirePlugin); @@ -106,3 +111,48 @@ test('load and enable Sandy plugin', async () => { ); expect(mockFlipper.clients[0].sandyPluginStates.has('plugin1')).toBeTruthy(); }); + +test('uninstall plugin', async () => { + mockFlipper.dispatch( + loadPlugin({plugin: pluginDetails1, enable: true, notifyIfFailed: false}), + ); + mockFlipper.dispatch(uninstallPlugin({plugin: pluginDefinition1})); + expect( + mockFlipper.getState().plugins.clientPlugins.has('plugin1'), + ).toBeFalsy(); + expect( + mockFlipper.getState().plugins.loadedPlugins.has('plugin1'), + ).toBeFalsy(); + expect( + mockFlipper.getState().plugins.uninstalledPlugins.has('flipper-plugin1'), + ).toBeTruthy(); + expect(mockFlipper.clients[0].sandyPluginStates.has('plugin1')).toBeFalsy(); +}); + +test('uninstall bundled plugin', async () => { + const pluginDetails = TestUtils.createMockBundledPluginDetails({ + id: 'bundled-plugin', + name: 'flipper-bundled-plugin', + version: '0.43.0', + }); + const pluginDefinition = new SandyPluginDefinition(pluginDetails, TestPlugin); + mockedRequirePlugin.mockReturnValue(pluginDefinition); + mockFlipper.dispatch( + loadPlugin({plugin: pluginDetails, enable: true, notifyIfFailed: false}), + ); + mockFlipper.dispatch(uninstallPlugin({plugin: pluginDefinition})); + expect( + mockFlipper.getState().plugins.clientPlugins.has('bundled-plugin'), + ).toBeFalsy(); + expect( + mockFlipper.getState().plugins.loadedPlugins.has('bundled-plugin'), + ).toBeFalsy(); + expect( + mockFlipper + .getState() + .plugins.uninstalledPlugins.has('flipper-bundled-plugin'), + ).toBeTruthy(); + expect( + mockFlipper.clients[0].sandyPluginStates.has('bundled-plugin'), + ).toBeFalsy(); +}); diff --git a/desktop/app/src/dispatcher/pluginDownloads.tsx b/desktop/app/src/dispatcher/pluginDownloads.tsx index e6fdfcff4..48e10d361 100644 --- a/desktop/app/src/dispatcher/pluginDownloads.tsx +++ b/desktop/app/src/dispatcher/pluginDownloads.tsx @@ -27,8 +27,9 @@ import path from 'path'; import tmp from 'tmp'; import {promisify} from 'util'; import {reportPlatformFailures, reportUsage} from '../utils/metrics'; -import {loadPlugin, pluginInstalled} from '../reducers/pluginManager'; +import {loadPlugin} from '../reducers/pluginManager'; import {showErrorNotification} from '../utils/notifications'; +import {pluginInstalled} from '../reducers/plugins'; // Adapter which forces node.js implementation for axios instead of browser implementation // used by default in Electron. Node.js implementation is better, because it diff --git a/desktop/app/src/dispatcher/pluginManager.tsx b/desktop/app/src/dispatcher/pluginManager.tsx index 5b386a810..4bcb95c06 100644 --- a/desktop/app/src/dispatcher/pluginManager.tsx +++ b/desktop/app/src/dispatcher/pluginManager.tsx @@ -7,27 +7,33 @@ * @format */ -import {Store} from '../reducers/index'; -import {Logger} from '../fb-interfaces/Logger'; +import type {Store} from '../reducers/index'; +import type {Logger} from '../fb-interfaces/Logger'; +import {clearPluginState} from '../reducers/pluginStates'; import { LoadPluginActionPayload, pluginCommandsProcessed, - registerInstalledPlugins, + UninstallPluginActionPayload, } from '../reducers/pluginManager'; import { getInstalledPlugins, cleanupOldInstalledPluginVersions, removePlugins, + ActivatablePluginDetails, } from 'flipper-plugin-lib'; import {sideEffect} from '../utils/sideEffect'; import {requirePlugin} from './plugins'; import {registerPluginUpdate} from '../reducers/connections'; import {showErrorNotification} from '../utils/notifications'; +import type Client from '../Client'; +import {unloadModule} from '../utils/electronModuleCache'; +import {pluginUninstalled, registerInstalledPlugins} from '../reducers/plugins'; +import {defaultEnabledBackgroundPlugins} from '../utils/pluginUtils'; const maxInstalledPluginVersionsToKeep = 2; function refreshInstalledPlugins(store: Store) { - removePlugins(store.getState().pluginManager.uninstalledPlugins.values()) + removePlugins(store.getState().plugins.uninstalledPlugins.values()) .then(() => cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep), ) @@ -65,6 +71,9 @@ export default ( case 'LOAD_PLUGIN': loadPlugin(store, command.payload); break; + case 'UNINSTALL_PLUGIN': + uninstallPlugin(store, command.payload); + break; default: console.error('Unexpected plugin command', command); break; @@ -95,8 +104,56 @@ function loadPlugin(store: Store, payload: LoadPluginActionPayload) { ); if (payload.notifyIfFailed) { showErrorNotification( - `Failed to load plugin "${payload.plugin.title}" v${payload.plugin.version}`, + `Failed to activate plugin "${payload.plugin.title}" v${payload.plugin.version}`, ); } } } + +function uninstallPlugin(store: Store, {plugin}: UninstallPluginActionPayload) { + try { + const state = store.getState(); + const clients = state.connections.clients; + clients.forEach((client) => { + stopPlugin(client, plugin.id); + }); + store.dispatch(clearPluginState({pluginId: plugin.id})); + if (!plugin.details.isBundled) { + unloadPluginModule(plugin.details); + } + store.dispatch(pluginUninstalled(plugin.details)); + } catch (err) { + console.error( + `Failed to uninstall plugin ${plugin.title} v${plugin.version}`, + err, + ); + showErrorNotification( + `Failed to uninstall plugin "${plugin.title}" v${plugin.version}`, + ); + } +} + +function stopPlugin( + client: Client, + pluginId: string, + forceInitBackgroundPlugin: boolean = false, +): boolean { + if ( + (forceInitBackgroundPlugin || + !defaultEnabledBackgroundPlugins.includes(pluginId)) && + client?.isBackgroundPlugin(pluginId) + ) { + client.deinitPlugin(pluginId); + } + // stop sandy plugins + client.stopPluginIfNeeded(pluginId); + return true; +} + +function unloadPluginModule(plugin: ActivatablePluginDetails) { + if (plugin.isBundled) { + // We cannot unload bundled plugin. + return; + } + unloadModule(plugin.entry); +} diff --git a/desktop/app/src/dispatcher/plugins.tsx b/desktop/app/src/dispatcher/plugins.tsx index e423348e3..c765ae8e0 100644 --- a/desktop/app/src/dispatcher/plugins.tsx +++ b/desktop/app/src/dispatcher/plugins.tsx @@ -70,7 +70,7 @@ export default async (store: Store, logger: Logger) => { defaultPluginsIndex = getDefaultPluginsIndex(); - const uninstalledPlugins = store.getState().pluginManager.uninstalledPlugins; + const uninstalledPlugins = store.getState().plugins.uninstalledPlugins; const bundledPlugins = getBundledPlugins(); diff --git a/desktop/app/src/reducers/__tests__/pluginManager.node.tsx b/desktop/app/src/reducers/__tests__/pluginManager.node.tsx deleted file mode 100644 index 4d7f5dd08..000000000 --- a/desktop/app/src/reducers/__tests__/pluginManager.node.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @format - */ - -import {default as reducer, registerInstalledPlugins} from '../pluginManager'; -import {InstalledPluginDetails} from 'flipper-plugin-lib'; - -test('reduce empty registerInstalledPlugins', () => { - const result = reducer(undefined, registerInstalledPlugins([])); - expect(result.installedPlugins).toEqual(new Map()); -}); - -const EXAMPLE_PLUGIN = { - name: 'test', - version: '0.1', - description: 'my test plugin', - dir: '/plugins/test', - specVersion: 2, - source: 'src/index.ts', - isBundled: false, - isActivatable: true, - main: 'lib/index.js', - title: 'test', - id: 'test', - entry: '/plugins/test/lib/index.js', -} as InstalledPluginDetails; - -test('reduce registerInstalledPlugins, clear again', () => { - const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN])); - expect(result.installedPlugins).toEqual( - new Map([[EXAMPLE_PLUGIN.name, EXAMPLE_PLUGIN]]), - ); - const result2 = reducer(result, registerInstalledPlugins([])); - expect(result2.installedPlugins).toEqual(new Map()); -}); diff --git a/desktop/app/src/reducers/__tests__/plugins.node.tsx b/desktop/app/src/reducers/__tests__/plugins.node.tsx index 90e27708c..5466956ee 100644 --- a/desktop/app/src/reducers/__tests__/plugins.node.tsx +++ b/desktop/app/src/reducers/__tests__/plugins.node.tsx @@ -11,6 +11,7 @@ import { default as reducer, registerPlugins, addGatekeepedPlugins, + registerInstalledPlugins, } from '../plugins'; import {FlipperPlugin, FlipperDevicePlugin, BaseAction} from '../../plugin'; import {InstalledPluginDetails} from 'flipper-plugin-lib'; @@ -39,6 +40,8 @@ test('add clientPlugin', () => { disabledPlugins: [], selectedPlugins: [], marketplacePlugins: [], + uninstalledPlugins: new Set(), + installedPlugins: new Map(), }, registerPlugins([testPlugin]), ); @@ -57,6 +60,8 @@ test('add devicePlugin', () => { disabledPlugins: [], selectedPlugins: [], marketplacePlugins: [], + uninstalledPlugins: new Set(), + installedPlugins: new Map(), }, registerPlugins([testDevicePlugin]), ); @@ -75,6 +80,8 @@ test('do not add plugin twice', () => { disabledPlugins: [], selectedPlugins: [], marketplacePlugins: [], + uninstalledPlugins: new Set(), + installedPlugins: new Map(), }, registerPlugins([testPlugin, testPlugin]), ); @@ -109,8 +116,39 @@ test('add gatekeeped plugin', () => { disabledPlugins: [], selectedPlugins: [], marketplacePlugins: [], + installedPlugins: new Map(), + uninstalledPlugins: new Set(), }, addGatekeepedPlugins(gatekeepedPlugins), ); expect(res.gatekeepedPlugins).toEqual(gatekeepedPlugins); }); + +test('reduce empty registerInstalledPlugins', () => { + const result = reducer(undefined, registerInstalledPlugins([])); + expect(result.installedPlugins).toEqual(new Map()); +}); + +const EXAMPLE_PLUGIN = { + name: 'test', + version: '0.1', + description: 'my test plugin', + dir: '/plugins/test', + specVersion: 2, + source: 'src/index.ts', + isBundled: false, + isActivatable: true, + main: 'lib/index.js', + title: 'test', + id: 'test', + entry: '/plugins/test/lib/index.js', +} as InstalledPluginDetails; + +test('reduce registerInstalledPlugins, clear again', () => { + const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN])); + expect(result.installedPlugins).toEqual( + new Map([[EXAMPLE_PLUGIN.name, EXAMPLE_PLUGIN]]), + ); + const result2 = reducer(result, registerInstalledPlugins([])); + expect(result2.installedPlugins).toEqual(new Map()); +}); diff --git a/desktop/app/src/reducers/index.tsx b/desktop/app/src/reducers/index.tsx index ce5bd2cfa..84be506c5 100644 --- a/desktop/app/src/reducers/index.tsx +++ b/desktop/app/src/reducers/index.tsx @@ -102,7 +102,7 @@ export type State = { settingsState: SettingsState & PersistPartial; launcherSettingsState: LauncherSettingsState & PersistPartial; supportForm: SupportFormState; - pluginManager: PluginManagerState & PersistPartial; + pluginManager: PluginManagerState; healthchecks: HealthcheckState & PersistPartial; usageTracking: TrackingState; pluginDownloads: PluginDownloadsState; @@ -159,20 +159,13 @@ export default combineReducers({ { key: 'plugins', storage, - whitelist: ['marketplacePlugins'], + whitelist: ['marketplacePlugins', 'uninstalledPlugins'], + transforms: [setTransformer({whitelist: ['uninstalledPlugins']})], }, plugins, ), supportForm, - pluginManager: persistReducer( - { - key: 'pluginManager', - storage, - whitelist: ['uninstalledPlugins'], - transforms: [setTransformer({whitelist: ['uninstalledPlugins']})], - }, - pluginManager, - ), + pluginManager, user: persistReducer( { key: 'user', diff --git a/desktop/app/src/reducers/pluginManager.tsx b/desktop/app/src/reducers/pluginManager.tsx index 075682a9c..ee7409a96 100644 --- a/desktop/app/src/reducers/pluginManager.tsx +++ b/desktop/app/src/reducers/pluginManager.tsx @@ -7,22 +7,16 @@ * @format */ -import {Actions} from './'; -import { - ActivatablePluginDetails, - InstalledPluginDetails, -} from 'flipper-plugin-lib'; -import {PluginDefinition} from '../plugin'; +import type {Actions} from './'; +import type {ActivatablePluginDetails} from 'flipper-plugin-lib'; +import type {PluginDefinition} from '../plugin'; import {produce} from 'immer'; -import semver from 'semver'; export type State = { - installedPlugins: Map; - uninstalledPlugins: Set; pluginCommandsQueue: PluginCommand[]; }; -export type PluginCommand = LoadPluginAction; +export type PluginCommand = LoadPluginAction | UninstallPluginAction; export type LoadPluginActionPayload = { plugin: ActivatablePluginDetails; @@ -35,29 +29,23 @@ export type LoadPluginAction = { payload: LoadPluginActionPayload; }; +export type UninstallPluginActionPayload = { + plugin: PluginDefinition; +}; + +export type UninstallPluginAction = { + type: 'UNINSTALL_PLUGIN'; + payload: UninstallPluginActionPayload; +}; + export type Action = - | { - type: 'REGISTER_INSTALLED_PLUGINS'; - payload: InstalledPluginDetails[]; - } - | { - // Implemented by rootReducer in `store.tsx` - type: 'UNINSTALL_PLUGIN'; - payload: PluginDefinition; - } - | { - type: 'PLUGIN_INSTALLED'; - payload: InstalledPluginDetails; - } | { type: 'PLUGIN_COMMANDS_PROCESSED'; payload: number; } - | LoadPluginAction; + | PluginCommand; const INITIAL_STATE: State = { - installedPlugins: new Map(), - uninstalledPlugins: new Set(), pluginCommandsQueue: [], }; @@ -65,55 +53,28 @@ export default function reducer( state: State = INITIAL_STATE, action: Actions, ): State { - if (action.type === 'REGISTER_INSTALLED_PLUGINS') { - return produce(state, (draft) => { - draft.installedPlugins = new Map( - action.payload - .filter((p) => !state.uninstalledPlugins?.has(p.name)) - .map((p) => [p.name, p]), - ); - }); - } else if (action.type === 'PLUGIN_INSTALLED') { - const plugin = action.payload; - return produce(state, (draft) => { - const existing = draft.installedPlugins.get(plugin.name); - if (!existing || semver.gt(plugin.version, existing.version)) { - draft.installedPlugins.set(plugin.name, plugin); - } - }); - } else if (action.type === 'LOAD_PLUGIN') { - return produce(state, (draft) => { - draft.pluginCommandsQueue.push({ - type: 'LOAD_PLUGIN', - payload: action.payload, + switch (action.type) { + case 'LOAD_PLUGIN': + case 'UNINSTALL_PLUGIN': + return produce(state, (draft) => { + draft.pluginCommandsQueue.push(action); }); - }); - } else if (action.type === 'PLUGIN_COMMANDS_PROCESSED') { - return produce(state, (draft) => { - draft.pluginCommandsQueue.splice(0, action.payload); - }); - } else { - return {...state}; + case 'PLUGIN_COMMANDS_PROCESSED': + return produce(state, (draft) => { + draft.pluginCommandsQueue.splice(0, action.payload); + }); + default: + return state; } } -export const registerInstalledPlugins = ( - payload: InstalledPluginDetails[], +export const uninstallPlugin = ( + payload: UninstallPluginActionPayload, ): Action => ({ - type: 'REGISTER_INSTALLED_PLUGINS', - payload, -}); - -export const uninstallPlugin = (payload: PluginDefinition): Action => ({ type: 'UNINSTALL_PLUGIN', payload, }); -export const pluginInstalled = (payload: InstalledPluginDetails): Action => ({ - type: 'PLUGIN_INSTALLED', - payload, -}); - export const loadPlugin = (payload: LoadPluginActionPayload): Action => ({ type: 'LOAD_PLUGIN', payload, diff --git a/desktop/app/src/reducers/pluginMessageQueue.tsx b/desktop/app/src/reducers/pluginMessageQueue.tsx index 82662cb2b..97defec54 100644 --- a/desktop/app/src/reducers/pluginMessageQueue.tsx +++ b/desktop/app/src/reducers/pluginMessageQueue.tsx @@ -40,6 +40,10 @@ export type Action = | { type: 'CLEAR_CLIENT_PLUGINS_STATE'; payload: {clientId: string; devicePlugins: Set}; + } + | { + type: 'CLEAR_PLUGIN_STATE'; + payload: {pluginId: string}; }; const INITIAL_STATE: State = {}; @@ -93,6 +97,19 @@ export default function reducer( return newState; }, {}); } + + case 'CLEAR_PLUGIN_STATE': { + const {pluginId} = action.payload; + return produce(state, (draft) => { + Object.keys(draft).forEach((pluginKey) => { + const pluginKeyParts = deconstructPluginKey(pluginKey); + if (pluginKeyParts.pluginName === pluginId) { + delete draft[pluginKey]; + } + }); + }); + } + default: return state; } diff --git a/desktop/app/src/reducers/pluginStates.tsx b/desktop/app/src/reducers/pluginStates.tsx index 5033d1b0d..ad8f00e4d 100644 --- a/desktop/app/src/reducers/pluginStates.tsx +++ b/desktop/app/src/reducers/pluginStates.tsx @@ -7,7 +7,8 @@ * @format */ -import type {Actions} from '.'; +import {produce} from 'immer'; +import {Actions} from '.'; import {deconstructPluginKey} from '../utils/clientUtils'; export type State = { @@ -29,6 +30,10 @@ export type Action = | { type: 'CLEAR_CLIENT_PLUGINS_STATE'; payload: {clientId: string; devicePlugins: Set}; + } + | { + type: 'CLEAR_PLUGIN_STATE'; + payload: {pluginId: string}; }; export default function reducer( @@ -63,6 +68,16 @@ export default function reducer( } return newState; }, {}); + } else if (action.type === 'CLEAR_PLUGIN_STATE') { + const {pluginId} = action.payload; + return produce(state, (draft) => { + Object.keys(draft).forEach((pluginKey) => { + const pluginKeyParts = deconstructPluginKey(pluginKey); + if (pluginKeyParts.pluginName === pluginId) { + delete draft[pluginKey]; + } + }); + }); } else { return state; } @@ -75,3 +90,8 @@ export const setPluginState = (payload: { type: 'SET_PLUGIN_STATE', payload, }); + +export const clearPluginState = (payload: {pluginId: string}): Action => ({ + type: 'CLEAR_PLUGIN_STATE', + payload, +}); diff --git a/desktop/app/src/reducers/plugins.tsx b/desktop/app/src/reducers/plugins.tsx index c97c1972b..769363953 100644 --- a/desktop/app/src/reducers/plugins.tsx +++ b/desktop/app/src/reducers/plugins.tsx @@ -16,10 +16,12 @@ import type { DownloadablePluginDetails, ActivatablePluginDetails, BundledPluginDetails, + InstalledPluginDetails, } from 'flipper-plugin-lib'; import type {Actions} from '.'; import produce from 'immer'; import {isDevicePluginDefinition} from '../utils/pluginUtils'; +import semver from 'semver'; export type State = { devicePlugins: DevicePluginMap; @@ -31,6 +33,8 @@ export type State = { failedPlugins: Array<[ActivatablePluginDetails, string]>; selectedPlugins: Array; marketplacePlugins: Array; + uninstalledPlugins: Set; + installedPlugins: Map; }; export type RegisterPluginAction = { @@ -67,20 +71,36 @@ export type Action = | { type: 'REGISTER_BUNDLED_PLUGINS'; payload: Array; + } + | { + type: 'REGISTER_INSTALLED_PLUGINS'; + payload: InstalledPluginDetails[]; + } + | { + type: 'PLUGIN_INSTALLED'; + payload: InstalledPluginDetails; + } + | { + type: 'PLUGIN_UNINSTALLED'; + payload: ActivatablePluginDetails; }; +const INITIAL_STATE: State = { + devicePlugins: new Map(), + clientPlugins: new Map(), + loadedPlugins: new Map(), + bundledPlugins: new Map(), + gatekeepedPlugins: [], + disabledPlugins: [], + failedPlugins: [], + selectedPlugins: [], + marketplacePlugins: [], + uninstalledPlugins: new Set(), + installedPlugins: new Map(), +}; + export default function reducer( - state: State | undefined = { - devicePlugins: new Map(), - clientPlugins: new Map(), - loadedPlugins: new Map(), - bundledPlugins: new Map(), - gatekeepedPlugins: [], - disabledPlugins: [], - failedPlugins: [], - selectedPlugins: [], - marketplacePlugins: [], - }, + state: State | undefined = INITIAL_STATE, action: Actions, ): State { if (action.type === 'REGISTER_PLUGINS') { @@ -133,6 +153,31 @@ export default function reducer( ...state, bundledPlugins: new Map(action.payload.map((p) => [p.id, p])), }; + } else if (action.type === 'REGISTER_INSTALLED_PLUGINS') { + return produce(state, (draft) => { + draft.installedPlugins.clear(); + action.payload.forEach((p) => { + if (!draft.uninstalledPlugins.has(p.id)) { + draft.installedPlugins.set(p.id, p); + } + }); + }); + } else if (action.type === 'PLUGIN_INSTALLED') { + const plugin = action.payload; + return produce(state, (draft) => { + const existing = draft.installedPlugins.get(plugin.name); + if (!existing || semver.gt(plugin.version, existing.version)) { + draft.installedPlugins.set(plugin.name, plugin); + } + }); + } else if (action.type === 'PLUGIN_UNINSTALLED') { + const plugin = action.payload; + return produce(state, (draft) => { + draft.clientPlugins.delete(plugin.id); + draft.devicePlugins.delete(plugin.id); + draft.loadedPlugins.delete(plugin.id); + draft.uninstalledPlugins.add(plugin.name); + }); } else { return state; } @@ -189,3 +234,22 @@ export const registerBundledPlugins = ( type: 'REGISTER_BUNDLED_PLUGINS', payload, }); + +export const registerInstalledPlugins = ( + payload: InstalledPluginDetails[], +): Action => ({ + type: 'REGISTER_INSTALLED_PLUGINS', + payload, +}); + +export const pluginInstalled = (payload: InstalledPluginDetails): Action => ({ + type: 'PLUGIN_INSTALLED', + payload, +}); + +export const pluginUninstalled = ( + payload: ActivatablePluginDetails, +): Action => ({ + type: 'PLUGIN_UNINSTALLED', + payload, +}); diff --git a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx index 4a75f39f5..6d337fa50 100644 --- a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx @@ -164,7 +164,7 @@ export const PluginList = memo(function PluginList({ (id: string) => { const plugin = disabledPlugins.find((p) => p.id === id)!; reportUsage('plugin:uninstall', {version: plugin.version}, plugin.id); - dispatch(uninstallPlugin(plugin)); + dispatch(uninstallPlugin({plugin})); }, [disabledPlugins, dispatch], ); diff --git a/desktop/app/src/store.tsx b/desktop/app/src/store.tsx index e134153fd..16c09dc0a 100644 --- a/desktop/app/src/store.tsx +++ b/desktop/app/src/store.tsx @@ -84,9 +84,6 @@ export function rootReducer( } else { return updateClientPlugin(state, plugin, enablePlugin); } - } else if (action.type === 'UNINSTALL_PLUGIN' && state) { - const plugin = action.payload; - return uninstallPlugin(state, plugin); } // otherwise @@ -184,27 +181,6 @@ function updateClientPlugin( }); } -function uninstallPlugin(state: StoreState, plugin: PluginDefinition) { - const clients = state.connections.clients; - return produce(state, (draft) => { - clients.forEach((client) => { - stopPlugin(client, plugin.id); - const pluginKey = getPluginKey( - client.id, - {serial: client.query.device_id}, - plugin.id, - ); - delete draft.pluginMessageQueue[pluginKey]; - }); - cleanupPluginStates(draft.pluginStates, plugin.id); - unloadPluginModule(plugin.details); - draft.plugins.clientPlugins.delete(plugin.id); - draft.plugins.devicePlugins.delete(plugin.id); - draft.plugins.loadedPlugins.delete(plugin.id); - draft.pluginManager.uninstalledPlugins.add(plugin.details.name); - }); -} - function updateDevicePlugin(state: StoreState, plugin: DevicePluginDefinition) { const devices = state.connections.devices; return produce(state, (draft) => { @@ -235,7 +211,7 @@ function registerLoadedPlugin( }, plugin: ActivatablePluginDetails, ) { - draft.pluginManager.uninstalledPlugins.delete(plugin.name); + draft.plugins.uninstalledPlugins.delete(plugin.name); draft.plugins.loadedPlugins.set(plugin.id, plugin); } diff --git a/desktop/app/src/utils/__tests__/exportData.node.tsx b/desktop/app/src/utils/__tests__/exportData.node.tsx index 9a475a960..ad8df1e7f 100644 --- a/desktop/app/src/utils/__tests__/exportData.node.tsx +++ b/desktop/app/src/utils/__tests__/exportData.node.tsx @@ -766,6 +766,8 @@ test('test determinePluginsToProcess for mutilple clients having plugins present failedPlugins: [], selectedPlugins: ['TestPlugin'], marketplacePlugins: [], + installedPlugins: new Map(), + uninstalledPlugins: new Set(), }; const op = determinePluginsToProcess( [client1, client2, client3], @@ -838,6 +840,8 @@ test('test determinePluginsToProcess for no selected plugin present in any clien failedPlugins: [], selectedPlugins: ['RandomPlugin'], marketplacePlugins: [], + installedPlugins: new Map(), + uninstalledPlugins: new Set(), }; const op = determinePluginsToProcess([client1, client2], device1, plugins); expect(op).toBeDefined(); @@ -887,6 +891,8 @@ test('test determinePluginsToProcess for multiple clients on same device', async failedPlugins: [], selectedPlugins: ['TestPlugin'], marketplacePlugins: [], + installedPlugins: new Map(), + uninstalledPlugins: new Set(), }; const op = determinePluginsToProcess([client1, client2], device1, plugins); expect(op).toBeDefined(); @@ -974,6 +980,8 @@ test('test determinePluginsToProcess for multiple clients on different device', failedPlugins: [], selectedPlugins: ['TestPlugin'], marketplacePlugins: [], + installedPlugins: new Map(), + uninstalledPlugins: new Set(), }; const op = determinePluginsToProcess( [client1Device1, client2Device1, client1Device2, client2Device2], @@ -1057,6 +1065,8 @@ test('test determinePluginsToProcess to ignore archived clients', async () => { failedPlugins: [], selectedPlugins: ['TestPlugin'], marketplacePlugins: [], + installedPlugins: new Map(), + uninstalledPlugins: new Set(), }; const op = determinePluginsToProcess( [client, archivedClient], diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index 15f76ab44..a1752cee2 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -14,7 +14,7 @@ import { act as testingLibAct, } from '@testing-library/react'; import {queries} from '@testing-library/dom'; -import {InstalledPluginDetails} from 'flipper-plugin-lib'; +import {BundledPluginDetails, InstalledPluginDetails} from 'flipper-plugin-lib'; import { RealFlipperClient, @@ -414,6 +414,24 @@ export function createMockPluginDetails( }; } +export function createMockBundledPluginDetails( + details?: Partial, +): BundledPluginDetails { + return { + id: 'TestBundledPlugin', + name: 'TestBundledPlugin', + specVersion: 0, + pluginType: 'client', + isBundled: true, + isActivatable: true, + main: '', + source: '', + title: 'Testing Bundled Plugin', + version: '', + ...details, + }; +} + function createMockDevice(options?: StartPluginOptions): RealFlipperDevice { const logListeners: (undefined | DeviceLogListener)[] = []; return {