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
This commit is contained in:
Anton Nikolaev
2020-12-15 09:28:58 -08:00
committed by Facebook GitHub Bot
parent df03ccbeab
commit c3d61cc32d
9 changed files with 81 additions and 93 deletions

View File

@@ -73,6 +73,11 @@ async function handlePluginDownload(
dispatch( dispatch(
pluginDownloadStarted({plugin, cancel: cancellationSource.cancel}), pluginDownloadStarted({plugin, cancel: cancellationSource.cancel}),
); );
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); await fs.ensureDir(targetDir);
let percentCompleted = 0; let percentCompleted = 0;
const response = await axios.get(plugin.downloadUrl, { const response = await axios.get(plugin.downloadUrl, {
@@ -104,6 +109,7 @@ async function handlePluginDownload(
writeStream.once('finish', resolve).once('error', reject), writeStream.once('finish', resolve).once('error', reject),
); );
await installPluginFromFile(targetFile); await installPluginFromFile(targetFile);
}
if (!store.getState().plugins.clientPlugins.has(plugin.id)) { if (!store.getState().plugins.clientPlugins.has(plugin.id)) {
const pluginDefinition = requirePlugin(plugin); const pluginDefinition = requirePlugin(plugin);
dispatch( dispatch(

View File

@@ -9,22 +9,20 @@
import {Store} from '../reducers/index'; import {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger'; import {Logger} from '../fb-interfaces/Logger';
import { import {registerInstalledPlugins} from '../reducers/pluginManager';
pluginFilesRemoved,
registerInstalledPlugins,
} from '../reducers/pluginManager';
import { import {
getInstalledPlugins, getInstalledPlugins,
cleanupOldInstalledPluginVersions, cleanupOldInstalledPluginVersions,
removePlugin, removePlugins,
} from 'flipper-plugin-lib'; } from 'flipper-plugin-lib';
import {sideEffect} from '../utils/sideEffect';
import pMap from 'p-map';
const maxInstalledPluginVersionsToKeep = 2; const maxInstalledPluginVersionsToKeep = 2;
function refreshInstalledPlugins(store: Store) { function refreshInstalledPlugins(store: Store) {
cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep) removePlugins(store.getState().pluginManager.uninstalledPlugins.values())
.then(() =>
cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep),
)
.then(() => getInstalledPlugins()) .then(() => getInstalledPlugins())
.then((plugins) => store.dispatch(registerInstalledPlugins(plugins))); .then((plugins) => store.dispatch(registerInstalledPlugins(plugins)));
} }
@@ -34,26 +32,4 @@ export default (store: Store, _logger: Logger) => {
window.requestIdleCallback(() => { window.requestIdleCallback(() => {
refreshInstalledPlugins(store); 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));
},
);
}; };

View File

@@ -57,10 +57,13 @@ export default async (store: Store, logger: Logger) => {
defaultPluginsIndex = getDefaultPluginsIndex(); defaultPluginsIndex = getDefaultPluginsIndex();
const uninstalledPlugins = store.getState().pluginManager.uninstalledPlugins;
const initialPlugins: PluginDefinition[] = filterNewestVersionOfEachPlugin( const initialPlugins: PluginDefinition[] = filterNewestVersionOfEachPlugin(
getBundledPlugins(), getBundledPlugins(),
await getDynamicPlugins(), await getDynamicPlugins(),
) )
.filter((p) => !uninstalledPlugins.has(p.name))
.map(reportVersion) .map(reportVersion)
.filter(checkDisabled(disabledPlugins)) .filter(checkDisabled(disabledPlugins))
.filter(checkGK(gatekeepedPlugins)) .filter(checkGK(gatekeepedPlugins))

View File

@@ -12,7 +12,7 @@ import {PluginDetails} from 'flipper-plugin-lib';
test('reduce empty registerInstalledPlugins', () => { test('reduce empty registerInstalledPlugins', () => {
const result = reducer(undefined, registerInstalledPlugins([])); const result = reducer(undefined, registerInstalledPlugins([]));
expect(result).toEqual({installedPlugins: [], removedPlugins: []}); expect(result.installedPlugins).toEqual([]);
}); });
const EXAMPLE_PLUGIN = { const EXAMPLE_PLUGIN = {
@@ -31,11 +31,7 @@ const EXAMPLE_PLUGIN = {
test('reduce registerInstalledPlugins, clear again', () => { test('reduce registerInstalledPlugins, clear again', () => {
const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN])); const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN]));
expect(result).toEqual({ expect(result.installedPlugins).toEqual([EXAMPLE_PLUGIN]);
installedPlugins: [EXAMPLE_PLUGIN],
removedPlugins: [],
});
const result2 = reducer(result, registerInstalledPlugins([])); const result2 = reducer(result, registerInstalledPlugins([]));
expect(result2).toEqual({installedPlugins: [], removedPlugins: []}); expect(result2.installedPlugins).toEqual([]);
}); });

View File

@@ -67,11 +67,12 @@ import {launcherConfigDir} from '../utils/launcher';
import os from 'os'; import os from 'os';
import {resolve} from 'path'; import {resolve} from 'path';
import xdg from 'xdg-basedir'; import xdg from 'xdg-basedir';
import {persistReducer} from 'redux-persist'; import {createTransform, persistReducer} from 'redux-persist';
import {PersistPartial} from 'redux-persist/es/persistReducer'; import {PersistPartial} from 'redux-persist/es/persistReducer';
import {Store as ReduxStore, MiddlewareAPI as ReduxMiddlewareAPI} from 'redux'; import {Store as ReduxStore, MiddlewareAPI as ReduxMiddlewareAPI} from 'redux';
import storage from 'redux-persist/lib/storage'; import storage from 'redux-persist/lib/storage';
import {TransformConfig} from 'redux-persist/es/createTransform';
export type Actions = export type Actions =
| ApplicationAction | ApplicationAction
@@ -101,7 +102,7 @@ export type State = {
settingsState: SettingsState & PersistPartial; settingsState: SettingsState & PersistPartial;
launcherSettingsState: LauncherSettingsState & PersistPartial; launcherSettingsState: LauncherSettingsState & PersistPartial;
supportForm: SupportFormState; supportForm: SupportFormState;
pluginManager: PluginManagerState; pluginManager: PluginManagerState & PersistPartial;
healthchecks: HealthcheckState & PersistPartial; healthchecks: HealthcheckState & PersistPartial;
usageTracking: TrackingState; usageTracking: TrackingState;
pluginDownloads: PluginDownloadsState; pluginDownloads: PluginDownloadsState;
@@ -118,6 +119,13 @@ const settingsStorage = new JsonFileStorage(
), ),
); );
const setTransformer = (config: TransformConfig) =>
createTransform(
(set: Set<string>) => Array.from(set),
(arrayString: string[]) => new Set(arrayString),
config,
);
const launcherSettingsStorage = new LauncherSettingsStorage( const launcherSettingsStorage = new LauncherSettingsStorage(
resolve(launcherConfigDir(), 'flipper-launcher.toml'), resolve(launcherConfigDir(), 'flipper-launcher.toml'),
); );
@@ -156,7 +164,15 @@ export default combineReducers<State, Actions>({
plugins, plugins,
), ),
supportForm, supportForm,
pluginManager: persistReducer<PluginManagerState, Actions>(
{
key: 'pluginManager',
storage,
whitelist: ['uninstalledPlugins'],
transforms: [setTransformer({whitelist: ['uninstalledPlugins']})],
},
pluginManager, pluginManager,
),
user: persistReducer( user: persistReducer(
{ {
key: 'user', key: 'user',

View File

@@ -9,12 +9,12 @@
import {Actions} from './'; import {Actions} from './';
import {PluginDetails} from 'flipper-plugin-lib'; import {PluginDetails} from 'flipper-plugin-lib';
import {produce} from 'immer';
import {PluginDefinition} from '../plugin'; import {PluginDefinition} from '../plugin';
import {produce} from 'immer';
export type State = { export type State = {
installedPlugins: PluginDetails[]; installedPlugins: PluginDetails[];
removedPlugins: PluginDetails[]; uninstalledPlugins: Set<string>;
}; };
export type Action = export type Action =
@@ -22,10 +22,6 @@ export type Action =
type: 'REGISTER_INSTALLED_PLUGINS'; type: 'REGISTER_INSTALLED_PLUGINS';
payload: PluginDetails[]; payload: PluginDetails[];
} }
| {
type: 'PLUGIN_FILES_REMOVED';
payload: PluginDetails;
}
| { | {
// Implemented by rootReducer in `store.tsx` // Implemented by rootReducer in `store.tsx`
type: 'UNINSTALL_PLUGIN'; type: 'UNINSTALL_PLUGIN';
@@ -34,8 +30,7 @@ export type Action =
const INITIAL_STATE: State = { const INITIAL_STATE: State = {
installedPlugins: [], installedPlugins: [],
// plugins which were uninstalled recently and require file cleanup uninstalledPlugins: new Set<string>(),
removedPlugins: [],
}; };
export default function reducer( export default function reducer(
@@ -43,19 +38,13 @@ export default function reducer(
action: Actions, action: Actions,
): State { ): State {
if (action.type === 'REGISTER_INSTALLED_PLUGINS') { 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) => { return produce(state, (draft) => {
draft.removedPlugins = draft.removedPlugins.filter( draft.installedPlugins = action.payload.filter(
(p) => p.id === plugin.id, (p) => !state.uninstalledPlugins?.has(p.name),
); );
}); });
} else { } else {
return state; return {...state};
} }
} }
@@ -64,11 +53,6 @@ export const registerInstalledPlugins = (payload: PluginDetails[]): Action => ({
payload, payload,
}); });
export const pluginFilesRemoved = (payload: PluginDetails): Action => ({
type: 'PLUGIN_FILES_REMOVED',
payload,
});
export const uninstallPlugin = (payload: PluginDefinition): Action => ({ export const uninstallPlugin = (payload: PluginDefinition): Action => ({
type: 'UNINSTALL_PLUGIN', type: 'UNINSTALL_PLUGIN',
payload, payload,

View File

@@ -244,7 +244,7 @@ export const PluginList = memo(function PluginList({
actions={ actions={
<ActionButton <ActionButton
id={plugin.id} id={plugin.id}
title="Install and Enable plugin" title="Install and enable plugin"
onClick={handleInstallPlugin} onClick={handleInstallPlugin}
icon={<PlusOutlined size={16} style={{marginRight: 0}} />} icon={<PlusOutlined size={16} style={{marginRight: 0}} />}
/> />

View File

@@ -173,6 +173,7 @@ function updateClientPlugin(
clientsWithEnabledPlugin.forEach((client) => { clientsWithEnabledPlugin.forEach((client) => {
startPlugin(client, plugin, true); 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); cleanupPluginStates(draft.pluginStates, plugin.id);
draft.plugins.clientPlugins.delete(plugin.id); draft.plugins.clientPlugins.delete(plugin.id);
draft.pluginManager.removedPlugins.push(plugin.details); draft.pluginManager.uninstalledPlugins.add(plugin.details.name);
}); });
} }

View File

@@ -134,6 +134,12 @@ export async function removePlugin(name: string): Promise<void> {
await fs.remove(getPluginInstallationDir(name)); await fs.remove(getPluginInstallationDir(name));
} }
export async function removePlugins(
names: IterableIterator<string>,
): Promise<void> {
await pmap(names, (name) => removePlugin(name));
}
export async function getInstalledPlugins(): Promise<PluginDetails[]> { export async function getInstalledPlugins(): Promise<PluginDetails[]> {
const versionDirs = await getInstalledPluginVersionDirs(); const versionDirs = await getInstalledPluginVersionDirs();
return pmap( return pmap(