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,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(

View File

@@ -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));
},
);
};

View File

@@ -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))

View File

@@ -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([]);
});

View File

@@ -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<string>) => 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<State, Actions>({
plugins,
),
supportForm,
pluginManager,
pluginManager: persistReducer<PluginManagerState, Actions>(
{
key: 'pluginManager',
storage,
whitelist: ['uninstalledPlugins'],
transforms: [setTransformer({whitelist: ['uninstalledPlugins']})],
},
pluginManager,
),
user: persistReducer(
{
key: 'user',

View File

@@ -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<string>;
};
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<string>(),
};
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,

View File

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

View File

@@ -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);
});
}

View File

@@ -134,6 +134,12 @@ export async function removePlugin(name: string): Promise<void> {
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[]> {
const versionDirs = await getInstalledPluginVersionDirs();
return pmap(