From c3d61cc32d6c8a8f21bab84beaad5db5c56cffee Mon Sep 17 00:00:00 2001 From: Anton Nikolaev Date: Tue, 15 Dec 2020 09:28:58 -0800 Subject: [PATCH] Persist uninstalled plugins list Summary: This diff changes uninstallation procedure for plugins. Instead of deleting plugin files immediately we are keeping them, but mark them as "uninstalled". This makes it possible to re-install plugins quickly in case when user clicked "delete" by mistake. Reviewed By: mweststrate Differential Revision: D25493479 fbshipit-source-id: 9ff29d717cdd5401c55388f24d479599579c8dd3 --- .../app/src/dispatcher/pluginDownloads.tsx | 64 ++++++++++--------- desktop/app/src/dispatcher/pluginManager.tsx | 36 ++--------- desktop/app/src/dispatcher/plugins.tsx | 3 + .../reducers/__tests__/pluginManager.node.tsx | 10 +-- desktop/app/src/reducers/index.tsx | 22 ++++++- desktop/app/src/reducers/pluginManager.tsx | 28 ++------ .../sandy-chrome/appinspect/PluginList.tsx | 2 +- desktop/app/src/store.tsx | 3 +- desktop/plugin-lib/src/pluginInstaller.ts | 6 ++ 9 files changed, 81 insertions(+), 93 deletions(-) diff --git a/desktop/app/src/dispatcher/pluginDownloads.tsx b/desktop/app/src/dispatcher/pluginDownloads.tsx index 67351d83b..72e919e5f 100644 --- a/desktop/app/src/dispatcher/pluginDownloads.tsx +++ b/desktop/app/src/dispatcher/pluginDownloads.tsx @@ -73,37 +73,43 @@ async function handlePluginDownload( dispatch( pluginDownloadStarted({plugin, cancel: cancellationSource.cancel}), ); - await fs.ensureDir(targetDir); - let percentCompleted = 0; - const response = await axios.get(plugin.downloadUrl, { - adapter: axiosHttpAdapter, - cancelToken: cancellationSource.token, - responseType: 'stream', - onDownloadProgress: async (progressEvent) => { - const newPercentCompleted = !progressEvent.total - ? 0 - : Math.round((progressEvent.loaded * 100) / progressEvent.total); - if (newPercentCompleted - percentCompleted >= 20) { - percentCompleted = newPercentCompleted; - console.log( - `Downloading plugin "${title}" v${version} from "${downloadUrl}": ${percentCompleted}% completed (${progressEvent.loaded} from ${progressEvent.total})`, - ); - } - }, - }); - if (response.headers['content-type'] !== 'application/octet-stream') { - throw new Error( - `Unexpected content type ${response.headers['content-type']} received from ${plugin.downloadUrl}`, + if (await fs.pathExists(dir)) { + console.log( + `Using existing files instead of downloading plugin "${title}" v${version} from "${downloadUrl}" to "${dir}"`, ); + } else { + await fs.ensureDir(targetDir); + let percentCompleted = 0; + const response = await axios.get(plugin.downloadUrl, { + adapter: axiosHttpAdapter, + cancelToken: cancellationSource.token, + responseType: 'stream', + onDownloadProgress: async (progressEvent) => { + const newPercentCompleted = !progressEvent.total + ? 0 + : Math.round((progressEvent.loaded * 100) / progressEvent.total); + if (newPercentCompleted - percentCompleted >= 20) { + percentCompleted = newPercentCompleted; + console.log( + `Downloading plugin "${title}" v${version} from "${downloadUrl}": ${percentCompleted}% completed (${progressEvent.loaded} from ${progressEvent.total})`, + ); + } + }, + }); + if (response.headers['content-type'] !== 'application/octet-stream') { + throw new Error( + `Unexpected content type ${response.headers['content-type']} received from ${plugin.downloadUrl}`, + ); + } + const responseStream = response.data as fs.ReadStream; + const writeStream = responseStream.pipe( + fs.createWriteStream(targetFile, {autoClose: true}), + ); + await new Promise((resolve, reject) => + writeStream.once('finish', resolve).once('error', reject), + ); + await installPluginFromFile(targetFile); } - const responseStream = response.data as fs.ReadStream; - const writeStream = responseStream.pipe( - fs.createWriteStream(targetFile, {autoClose: true}), - ); - await new Promise((resolve, reject) => - writeStream.once('finish', resolve).once('error', reject), - ); - await installPluginFromFile(targetFile); if (!store.getState().plugins.clientPlugins.has(plugin.id)) { const pluginDefinition = requirePlugin(plugin); dispatch( diff --git a/desktop/app/src/dispatcher/pluginManager.tsx b/desktop/app/src/dispatcher/pluginManager.tsx index 81b45b1c5..48a53931a 100644 --- a/desktop/app/src/dispatcher/pluginManager.tsx +++ b/desktop/app/src/dispatcher/pluginManager.tsx @@ -9,22 +9,20 @@ import {Store} from '../reducers/index'; import {Logger} from '../fb-interfaces/Logger'; -import { - pluginFilesRemoved, - registerInstalledPlugins, -} from '../reducers/pluginManager'; +import {registerInstalledPlugins} from '../reducers/pluginManager'; import { getInstalledPlugins, cleanupOldInstalledPluginVersions, - removePlugin, + removePlugins, } from 'flipper-plugin-lib'; -import {sideEffect} from '../utils/sideEffect'; -import pMap from 'p-map'; const maxInstalledPluginVersionsToKeep = 2; function refreshInstalledPlugins(store: Store) { - cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep) + removePlugins(store.getState().pluginManager.uninstalledPlugins.values()) + .then(() => + cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep), + ) .then(() => getInstalledPlugins()) .then((plugins) => store.dispatch(registerInstalledPlugins(plugins))); } @@ -34,26 +32,4 @@ export default (store: Store, _logger: Logger) => { window.requestIdleCallback(() => { refreshInstalledPlugins(store); }); - - sideEffect( - store, - { - name: 'removeUninstalledPluginFiles', - throttleMs: 1000, - fireImmediately: true, - }, - (state) => state.pluginManager.removedPlugins, - (removedPlugins) => { - pMap(removedPlugins, (p) => { - removePlugin(p.name) - .then(() => pluginFilesRemoved(p)) - .catch((e) => - console.error( - `Error while removing files of uninstalled plugin ${p.title}`, - e, - ), - ); - }).then(() => refreshInstalledPlugins(store)); - }, - ); }; diff --git a/desktop/app/src/dispatcher/plugins.tsx b/desktop/app/src/dispatcher/plugins.tsx index c151a72a9..b648dea26 100644 --- a/desktop/app/src/dispatcher/plugins.tsx +++ b/desktop/app/src/dispatcher/plugins.tsx @@ -57,10 +57,13 @@ export default async (store: Store, logger: Logger) => { defaultPluginsIndex = getDefaultPluginsIndex(); + const uninstalledPlugins = store.getState().pluginManager.uninstalledPlugins; + const initialPlugins: PluginDefinition[] = filterNewestVersionOfEachPlugin( getBundledPlugins(), await getDynamicPlugins(), ) + .filter((p) => !uninstalledPlugins.has(p.name)) .map(reportVersion) .filter(checkDisabled(disabledPlugins)) .filter(checkGK(gatekeepedPlugins)) diff --git a/desktop/app/src/reducers/__tests__/pluginManager.node.tsx b/desktop/app/src/reducers/__tests__/pluginManager.node.tsx index 4b0d3e950..5e1e9017f 100644 --- a/desktop/app/src/reducers/__tests__/pluginManager.node.tsx +++ b/desktop/app/src/reducers/__tests__/pluginManager.node.tsx @@ -12,7 +12,7 @@ import {PluginDetails} from 'flipper-plugin-lib'; test('reduce empty registerInstalledPlugins', () => { const result = reducer(undefined, registerInstalledPlugins([])); - expect(result).toEqual({installedPlugins: [], removedPlugins: []}); + expect(result.installedPlugins).toEqual([]); }); const EXAMPLE_PLUGIN = { @@ -31,11 +31,7 @@ const EXAMPLE_PLUGIN = { test('reduce registerInstalledPlugins, clear again', () => { const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN])); - expect(result).toEqual({ - installedPlugins: [EXAMPLE_PLUGIN], - removedPlugins: [], - }); - + expect(result.installedPlugins).toEqual([EXAMPLE_PLUGIN]); const result2 = reducer(result, registerInstalledPlugins([])); - expect(result2).toEqual({installedPlugins: [], removedPlugins: []}); + expect(result2.installedPlugins).toEqual([]); }); diff --git a/desktop/app/src/reducers/index.tsx b/desktop/app/src/reducers/index.tsx index 8ebd05c26..ce5bd2cfa 100644 --- a/desktop/app/src/reducers/index.tsx +++ b/desktop/app/src/reducers/index.tsx @@ -67,11 +67,12 @@ import {launcherConfigDir} from '../utils/launcher'; import os from 'os'; import {resolve} from 'path'; import xdg from 'xdg-basedir'; -import {persistReducer} from 'redux-persist'; +import {createTransform, persistReducer} from 'redux-persist'; import {PersistPartial} from 'redux-persist/es/persistReducer'; import {Store as ReduxStore, MiddlewareAPI as ReduxMiddlewareAPI} from 'redux'; import storage from 'redux-persist/lib/storage'; +import {TransformConfig} from 'redux-persist/es/createTransform'; export type Actions = | ApplicationAction @@ -101,7 +102,7 @@ export type State = { settingsState: SettingsState & PersistPartial; launcherSettingsState: LauncherSettingsState & PersistPartial; supportForm: SupportFormState; - pluginManager: PluginManagerState; + pluginManager: PluginManagerState & PersistPartial; healthchecks: HealthcheckState & PersistPartial; usageTracking: TrackingState; pluginDownloads: PluginDownloadsState; @@ -118,6 +119,13 @@ const settingsStorage = new JsonFileStorage( ), ); +const setTransformer = (config: TransformConfig) => + createTransform( + (set: Set) => Array.from(set), + (arrayString: string[]) => new Set(arrayString), + config, + ); + const launcherSettingsStorage = new LauncherSettingsStorage( resolve(launcherConfigDir(), 'flipper-launcher.toml'), ); @@ -156,7 +164,15 @@ export default combineReducers({ plugins, ), supportForm, - pluginManager, + pluginManager: persistReducer( + { + key: 'pluginManager', + storage, + whitelist: ['uninstalledPlugins'], + transforms: [setTransformer({whitelist: ['uninstalledPlugins']})], + }, + pluginManager, + ), user: persistReducer( { key: 'user', diff --git a/desktop/app/src/reducers/pluginManager.tsx b/desktop/app/src/reducers/pluginManager.tsx index d46ceb85b..c08106386 100644 --- a/desktop/app/src/reducers/pluginManager.tsx +++ b/desktop/app/src/reducers/pluginManager.tsx @@ -9,12 +9,12 @@ import {Actions} from './'; import {PluginDetails} from 'flipper-plugin-lib'; -import {produce} from 'immer'; import {PluginDefinition} from '../plugin'; +import {produce} from 'immer'; export type State = { installedPlugins: PluginDetails[]; - removedPlugins: PluginDetails[]; + uninstalledPlugins: Set; }; export type Action = @@ -22,10 +22,6 @@ export type Action = type: 'REGISTER_INSTALLED_PLUGINS'; payload: PluginDetails[]; } - | { - type: 'PLUGIN_FILES_REMOVED'; - payload: PluginDetails; - } | { // Implemented by rootReducer in `store.tsx` type: 'UNINSTALL_PLUGIN'; @@ -34,8 +30,7 @@ export type Action = const INITIAL_STATE: State = { installedPlugins: [], - // plugins which were uninstalled recently and require file cleanup - removedPlugins: [], + uninstalledPlugins: new Set(), }; export default function reducer( @@ -43,19 +38,13 @@ export default function reducer( action: Actions, ): State { if (action.type === 'REGISTER_INSTALLED_PLUGINS') { - return { - ...state, - installedPlugins: action.payload, - }; - } else if (action.type === 'PLUGIN_FILES_REMOVED') { - const plugin = action.payload; return produce(state, (draft) => { - draft.removedPlugins = draft.removedPlugins.filter( - (p) => p.id === plugin.id, + draft.installedPlugins = action.payload.filter( + (p) => !state.uninstalledPlugins?.has(p.name), ); }); } else { - return state; + return {...state}; } } @@ -64,11 +53,6 @@ export const registerInstalledPlugins = (payload: PluginDetails[]): Action => ({ payload, }); -export const pluginFilesRemoved = (payload: PluginDetails): Action => ({ - type: 'PLUGIN_FILES_REMOVED', - payload, -}); - export const uninstallPlugin = (payload: PluginDefinition): Action => ({ type: 'UNINSTALL_PLUGIN', payload, diff --git a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx index 69927bd1c..82e7a8a39 100644 --- a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx @@ -244,7 +244,7 @@ export const PluginList = memo(function PluginList({ actions={ } /> diff --git a/desktop/app/src/store.tsx b/desktop/app/src/store.tsx index 542d66de5..106399639 100644 --- a/desktop/app/src/store.tsx +++ b/desktop/app/src/store.tsx @@ -173,6 +173,7 @@ function updateClientPlugin( clientsWithEnabledPlugin.forEach((client) => { startPlugin(client, plugin, true); }); + draft.pluginManager.uninstalledPlugins.delete(plugin.details.name); }); } @@ -190,7 +191,7 @@ function uninstallPlugin(state: StoreState, plugin: PluginDefinition) { }); cleanupPluginStates(draft.pluginStates, plugin.id); draft.plugins.clientPlugins.delete(plugin.id); - draft.pluginManager.removedPlugins.push(plugin.details); + draft.pluginManager.uninstalledPlugins.add(plugin.details.name); }); } diff --git a/desktop/plugin-lib/src/pluginInstaller.ts b/desktop/plugin-lib/src/pluginInstaller.ts index 31d76f0b1..3b83adf62 100644 --- a/desktop/plugin-lib/src/pluginInstaller.ts +++ b/desktop/plugin-lib/src/pluginInstaller.ts @@ -134,6 +134,12 @@ export async function removePlugin(name: string): Promise { await fs.remove(getPluginInstallationDir(name)); } +export async function removePlugins( + names: IterableIterator, +): Promise { + await pmap(names, (name) => removePlugin(name)); +} + export async function getInstalledPlugins(): Promise { const versionDirs = await getInstalledPluginVersionDirs(); return pmap(