Keep multiple installed versions of each plugin

Summary:
This diff changes directory structure for installed plugins to allow installation of multiple versions simultaneously, e.g. to to allow downloading new plugin version while user is still using the previous one, and to have possibility of fast rollback to the previous installed if necessary. The new folder for installed plugins is located in `~/.flipper/installed-plugins` and has the following structure:

  flipper-plugin-reactotron
    1.0.0
      ...
      package.json
    1.0.1
      ...
      package.json
  flipper-plugin-network
    0.67.1
      ...
      package.json
    0.67.2
      ...
      package.json

The tricky part here is that we also need to migrate already installed plugins from the old folder `~/.flipper/thirdparty` to the new folder and maintain the new structure for them.

Another tricky part is that we need to periodically cleanup old versions. For now we will just keep 2 versions of each plugin. Cleanup is performed in background right after Flipper startup.

Reviewed By: mweststrate

Differential Revision: D25393474

fbshipit-source-id: 26617ac26114148f797cc3d6765a42242edc205e
This commit is contained in:
Anton Nikolaev
2020-12-15 09:28:58 -08:00
committed by Facebook GitHub Bot
parent 9c5f59e109
commit 02d695cb28
15 changed files with 194 additions and 398 deletions

View File

@@ -36,7 +36,7 @@ import {
getUpdatablePlugins,
removePlugin,
UpdatablePluginDetails,
InstalledPluginDetails,
PluginDetails,
} from 'flipper-plugin-lib';
import {installPluginFromNpm} from 'flipper-plugin-lib';
import {State as AppState} from '../../reducers';
@@ -92,7 +92,7 @@ const RestartBar = styled(FlexColumn)({
});
type PropsFromState = {
installedPlugins: InstalledPluginDetails[];
installedPlugins: PluginDetails[];
};
type DispatchFromProps = {
@@ -289,7 +289,7 @@ function InstallButton(props: {
function useNPMSearch(
query: string,
onInstall: () => void,
installedPlugins: InstalledPluginDetails[],
installedPlugins: PluginDetails[],
): TableRows_immutable {
useEffect(() => {
reportUsage(`${TAG}:open`);

View File

@@ -14,14 +14,14 @@ import React from 'react';
import {render, waitForElement} from '@testing-library/react';
import configureStore from 'redux-mock-store';
import {Provider} from 'react-redux';
import type {InstalledPluginDetails} from 'flipper-plugin-lib';
import type {PluginDetails} from 'flipper-plugin-lib';
import {getUpdatablePlugins, UpdatablePluginDetails} from 'flipper-plugin-lib';
import {Store} from '../../../reducers';
import {mocked} from 'ts-jest/utils';
const getUpdatablePluginsMock = mocked(getUpdatablePlugins);
function getStore(installedPlugins: InstalledPluginDetails[] = []): Store {
function getStore(installedPlugins: PluginDetails[] = []): Store {
return configureStore([])({
application: {sessionId: 'mysession'},
pluginManager: {installedPlugins},
@@ -95,9 +95,7 @@ test('load PluginInstaller list with one plugin installed', async () => {
samplePluginDetails2,
]),
);
const store = getStore([
{...samplePluginDetails1, installationStatus: 'installed'},
]);
const store = getStore([samplePluginDetails1]);
const component = (
<Provider store={store}>
<PluginInstaller

View File

@@ -10,12 +10,17 @@
import {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger';
import {registerInstalledPlugins} from '../reducers/pluginManager';
import {getInstalledPlugins} from 'flipper-plugin-lib';
import {
getInstalledPlugins,
cleanupOldInstalledPluginVersions,
} from 'flipper-plugin-lib';
const maxInstalledPluginVersionsToKeep = 2;
function refreshInstalledPlugins(store: Store) {
getInstalledPlugins().then((plugins) =>
store.dispatch(registerInstalledPlugins(plugins)),
);
cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep)
.then(() => getInstalledPlugins())
.then((plugins) => store.dispatch(registerInstalledPlugins(plugins)));
}
export default (store: Store, _logger: Logger) => {

View File

@@ -8,7 +8,7 @@
*/
import {default as reducer, registerInstalledPlugins} from '../pluginManager';
import {InstalledPluginDetails} from 'flipper-plugin-lib';
import {PluginDetails} from 'flipper-plugin-lib';
test('reduce empty registerInstalledPlugins', () => {
const result = reducer(undefined, registerInstalledPlugins([]));
@@ -27,8 +27,7 @@ const EXAMPLE_PLUGIN = {
title: 'test',
id: 'test',
entry: '/plugins/test/lib/index.js',
installationStatus: 'installed',
} as InstalledPluginDetails;
} as PluginDetails;
test('reduce registerInstalledPlugins, clear again', () => {
const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN]));

View File

@@ -8,15 +8,15 @@
*/
import {Actions} from './';
import {InstalledPluginDetails} from 'flipper-plugin-lib';
import {PluginDetails} from 'flipper-plugin-lib';
export type State = {
installedPlugins: InstalledPluginDetails[];
installedPlugins: PluginDetails[];
};
export type Action = {
type: 'REGISTER_INSTALLED_PLUGINS';
payload: InstalledPluginDetails[];
payload: PluginDetails[];
};
const INITIAL_STATE: State = {
@@ -37,9 +37,7 @@ export default function reducer(
}
}
export const registerInstalledPlugins = (
payload: InstalledPluginDetails[],
): Action => ({
export const registerInstalledPlugins = (payload: PluginDetails[]): Action => ({
type: 'REGISTER_INSTALLED_PLUGINS',
payload,
});

View File

@@ -9,20 +9,16 @@
import path from 'path';
import fs from 'fs-extra';
import pMap from 'p-map';
import {
PluginDetails,
getSourcePlugins,
getInstalledPlugins,
finishPendingPluginInstallations,
moveInstalledPluginsFromLegacyDir,
} from 'flipper-plugin-lib';
import os from 'os';
import {getStaticPath} from '../utils/pathUtils';
const pluginCache = path.join(os.homedir(), '.flipper', 'plugins');
// Load "dynamic" plugins, e.g. those which are either installed or loaded from sources for development purposes.
// This opposed to "static" plugins which are already included into Flipper bundle.
// This opposed to "default" plugins which are included into Flipper bundle.
export default async function loadDynamicPlugins(): Promise<PluginDetails[]> {
if (process.env.FLIPPER_FAST_REFRESH) {
console.log(
@@ -30,63 +26,38 @@ export default async function loadDynamicPlugins(): Promise<PluginDetails[]> {
);
return [];
}
try {
await finishPendingPluginInstallations();
} catch (err) {
console.error('❌ Failed to finish pending installations', err);
}
await moveInstalledPluginsFromLegacyDir().catch((ex) =>
console.error(
'Eror while migrating installed plugins from legacy folder',
ex,
),
);
const staticPath = getStaticPath();
const defaultPlugins = new Set<string>(
(
await fs.readJson(path.join(staticPath, 'defaultPlugins', 'index.json'))
).map((p: any) => p.name) as string[],
);
const dynamicPlugins = [
...(await getInstalledPlugins()),
...(await getSourcePlugins()).filter((p) => !defaultPlugins.has(p.name)),
];
await fs.ensureDir(pluginCache);
const compilations = pMap(
dynamicPlugins,
(plugin) => {
return loadPlugin(plugin);
},
{concurrency: 4},
const [installedPlugins, unfilteredSourcePlugins] = await Promise.all([
getInstalledPlugins(),
getSourcePlugins(),
]);
const sourcePlugins = unfilteredSourcePlugins.filter(
(p) => !defaultPlugins.has(p.name),
);
const compiledDynamicPlugins = (await compilations).filter(
(c) => c !== null,
) as PluginDetails[];
console.log(
`✅ Loaded ${dynamicPlugins.length} dynamic plugins: ${dynamicPlugins
.map((x) => x.title)
.join(', ')}.`,
);
return compiledDynamicPlugins;
}
async function loadPlugin(
pluginDetails: PluginDetails,
): Promise<PluginDetails | null> {
const {specVersion, version, entry, name} = pluginDetails;
if (specVersion > 1) {
if (await fs.pathExists(entry)) {
return pluginDetails;
} else {
console.error(
`❌ Plugin ${name} is ignored, because its entry point not found: ${entry}.`,
);
return null;
}
} else {
// Try to load cached version of legacy plugin
const entry = path.join(pluginCache, `${name}@${version || '0.0.0'}.js`);
if (await fs.pathExists(entry)) {
console.log(`🥫 Using cached version of legacy plugin ${name}...`);
return pluginDetails;
} else {
console.error(
`❌ Plugin ${name} is ignored, because it is defined by the unsupported spec v1 and could not be compiled.`,
);
return null;
}
if (installedPlugins.length > 0) {
console.log(
`✅ Loaded ${
installedPlugins.length
} installed plugins: ${installedPlugins.map((x) => x.title).join(', ')}.`,
);
}
if (sourcePlugins.length > 0) {
console.log(
`✅ Loaded ${sourcePlugins.length} source plugins: ${sourcePlugins
.map((x) => x.title)
.join(', ')}.`,
);
}
return [...installedPlugins, ...sourcePlugins];
}