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

@@ -39,6 +39,7 @@
"lodash.memoize": "^4.1.2", "lodash.memoize": "^4.1.2",
"open": "^7.0.0", "open": "^7.0.0",
"openssl-wrapper": "^0.3.4", "openssl-wrapper": "^0.3.4",
"p-filter": "^2.1.0",
"p-map": "^4.0.0", "p-map": "^4.0.0",
"promise-retry": "^2.0.1", "promise-retry": "^2.0.1",
"promisify-child-process": "^4.1.0", "promisify-child-process": "^4.1.0",

View File

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

View File

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

View File

@@ -10,12 +10,17 @@
import {Store} from '../reducers/index'; import {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger'; import {Logger} from '../fb-interfaces/Logger';
import {registerInstalledPlugins} from '../reducers/pluginManager'; 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) { function refreshInstalledPlugins(store: Store) {
getInstalledPlugins().then((plugins) => cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep)
store.dispatch(registerInstalledPlugins(plugins)), .then(() => getInstalledPlugins())
); .then((plugins) => store.dispatch(registerInstalledPlugins(plugins)));
} }
export default (store: Store, _logger: Logger) => { export default (store: Store, _logger: Logger) => {

View File

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

View File

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

View File

@@ -9,20 +9,16 @@
import path from 'path'; import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import pMap from 'p-map';
import { import {
PluginDetails, PluginDetails,
getSourcePlugins, getSourcePlugins,
getInstalledPlugins, getInstalledPlugins,
finishPendingPluginInstallations, moveInstalledPluginsFromLegacyDir,
} from 'flipper-plugin-lib'; } from 'flipper-plugin-lib';
import os from 'os';
import {getStaticPath} from '../utils/pathUtils'; 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. // 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[]> { export default async function loadDynamicPlugins(): Promise<PluginDetails[]> {
if (process.env.FLIPPER_FAST_REFRESH) { if (process.env.FLIPPER_FAST_REFRESH) {
console.log( console.log(
@@ -30,63 +26,38 @@ export default async function loadDynamicPlugins(): Promise<PluginDetails[]> {
); );
return []; return [];
} }
try { await moveInstalledPluginsFromLegacyDir().catch((ex) =>
await finishPendingPluginInstallations(); console.error(
} catch (err) { 'Eror while migrating installed plugins from legacy folder',
console.error('❌ Failed to finish pending installations', err); ex,
} ),
);
const staticPath = getStaticPath(); const staticPath = getStaticPath();
const defaultPlugins = new Set<string>( const defaultPlugins = new Set<string>(
( (
await fs.readJson(path.join(staticPath, 'defaultPlugins', 'index.json')) await fs.readJson(path.join(staticPath, 'defaultPlugins', 'index.json'))
).map((p: any) => p.name) as string[], ).map((p: any) => p.name) as string[],
); );
const dynamicPlugins = [ const [installedPlugins, unfilteredSourcePlugins] = await Promise.all([
...(await getInstalledPlugins()), getInstalledPlugins(),
...(await getSourcePlugins()).filter((p) => !defaultPlugins.has(p.name)), getSourcePlugins(),
]; ]);
await fs.ensureDir(pluginCache); const sourcePlugins = unfilteredSourcePlugins.filter(
const compilations = pMap( (p) => !defaultPlugins.has(p.name),
dynamicPlugins,
(plugin) => {
return loadPlugin(plugin);
},
{concurrency: 4},
); );
const compiledDynamicPlugins = (await compilations).filter( if (installedPlugins.length > 0) {
(c) => c !== null,
) as PluginDetails[];
console.log( console.log(
`✅ Loaded ${dynamicPlugins.length} dynamic plugins: ${dynamicPlugins `✅ 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) .map((x) => x.title)
.join(', ')}.`, .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;
}
} }
return [...installedPlugins, ...sourcePlugins];
} }

View File

@@ -7,7 +7,7 @@
* @format * @format
*/ */
jest.mock('../getInstalledPlugins'); jest.mock('../pluginInstaller');
jest.mock('../getNpmHostedPlugins'); jest.mock('../getNpmHostedPlugins');
import {getUpdatablePlugins} from '../getUpdatablePlugins'; import {getUpdatablePlugins} from '../getUpdatablePlugins';
@@ -15,10 +15,10 @@ import {
getNpmHostedPlugins, getNpmHostedPlugins,
NpmPackageDescriptor, NpmPackageDescriptor,
} from '../getNpmHostedPlugins'; } from '../getNpmHostedPlugins';
import type {InstalledPluginDetails} from '../getInstalledPlugins'; import {getInstalledPlugins} from '../pluginInstaller';
import {getInstalledPlugins} from '../getInstalledPlugins';
import {mocked} from 'ts-jest/utils'; import {mocked} from 'ts-jest/utils';
import type {Package} from 'npm-api'; import type {Package} from 'npm-api';
import PluginDetails from '../PluginDetails';
jest.mock('npm-api', () => { jest.mock('npm-api', () => {
return jest.fn().mockImplementation(() => { return jest.fn().mockImplementation(() => {
@@ -54,7 +54,7 @@ jest.mock('npm-api', () => {
}); });
}); });
const installedPlugins: InstalledPluginDetails[] = [ const installedPlugins: PluginDetails[] = [
{ {
name: 'flipper-plugin-hello', name: 'flipper-plugin-hello',
entry: './test/index.js', entry: './test/index.js',
@@ -67,7 +67,6 @@ const installedPlugins: InstalledPluginDetails[] = [
title: 'Hello', title: 'Hello',
description: 'World?', description: 'World?',
isDefault: false, isDefault: false,
installationStatus: 'installed',
}, },
{ {
name: 'flipper-plugin-world', name: 'flipper-plugin-world',
@@ -81,7 +80,6 @@ const installedPlugins: InstalledPluginDetails[] = [
title: 'World', title: 'World',
description: 'Hello?', description: 'Hello?',
isDefault: false, isDefault: false,
installationStatus: 'pending',
}, },
]; ];

View File

@@ -1,95 +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 mockfs from 'mock-fs';
import fs from 'fs-extra';
import path from 'path';
import {consoleMock} from 'flipper-test-utils';
import {finishPendingPluginInstallations} from '../pluginInstaller';
import {
pluginPendingInstallationDir,
pluginInstallationDir,
} from '../pluginPaths';
describe('pluginInstaller', () => {
const realConsole = global.console;
global.console = consoleMock as any;
afterAll(() => {
global.console = realConsole;
});
beforeEach(() => {});
afterEach(() => {
mockfs.restore();
});
test('finish pending plugin installations', async () => {
mockfs({
[pluginPendingInstallationDir]: {
'flipper-plugin-test1': {
'1.2.0': {
'index.ts': '',
'package.json': '',
},
},
'flipper-plugin-test2': {
'0.3.0': {
'index.js': '',
'0.3.0.js': '',
'package.json': '',
},
'0.2.0': {
'index.js': '',
'0.2.0.js': '',
'package.json': '',
},
},
},
});
await finishPendingPluginInstallations();
expect(await fs.readdir(pluginInstallationDir)).toMatchInlineSnapshot(`
Array [
".watchmanconfig",
"flipper-plugin-test1",
"flipper-plugin-test2",
]
`);
expect(
await fs.readdir(
path.join(pluginInstallationDir, 'flipper-plugin-test1'),
),
).toMatchInlineSnapshot(`
Array [
"index.ts",
"package.json",
]
`);
expect(
await fs.readdir(
path.join(pluginInstallationDir, 'flipper-plugin-test2'),
),
).toMatchInlineSnapshot(`
Array [
"0.3.0.js",
"index.js",
"package.json",
]
`);
expect(
await fs.readdir(pluginPendingInstallationDir),
).toMatchInlineSnapshot(`Array []`);
});
});

View File

@@ -1,104 +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 fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import {
pluginPendingInstallationDir,
pluginInstallationDir,
} from './pluginPaths';
import PluginDetails from './PluginDetails';
import {getPluginDetailsFromDir} from './getPluginDetails';
import pmap from 'p-map';
import {notNull} from './typeUtils';
export type PluginInstallationStatus =
| 'not-installed'
| 'installed'
| 'pending';
export type InstalledPluginDetails = PluginDetails & {
installationStatus: PluginInstallationStatus;
};
async function getFullyInstalledPlugins(): Promise<PluginDetails[]> {
const pluginDirExists = await fs.pathExists(pluginInstallationDir);
if (!pluginDirExists) {
return [];
}
const dirs = await fs.readdir(pluginInstallationDir);
const plugins = await pmap(dirs, async (dirName) => {
const pluginDir = path.join(pluginInstallationDir, dirName);
if (!(await fs.lstat(pluginDir)).isDirectory()) {
return undefined;
}
try {
return await getPluginDetailsFromDir(pluginDir);
} catch (e) {
console.error(`Failed to load plugin from ${pluginDir}`, e);
return undefined;
}
});
return plugins.filter(notNull);
}
async function getPendingInstallationPlugins(): Promise<PluginDetails[]> {
const pluginDirExists = await fs.pathExists(pluginPendingInstallationDir);
if (!pluginDirExists) {
return [];
}
const dirs = await fs.readdir(pluginPendingInstallationDir);
const plugins = await pmap(dirs, async (dirName) => {
const versions = (
await fs.readdir(path.join(pluginPendingInstallationDir, dirName))
).sort((v1, v2) => semver.compare(v2, v1, true));
if (versions.length === 0) {
return undefined;
}
const pluginDir = path.join(
pluginPendingInstallationDir,
dirName,
versions[0],
);
if (!(await fs.lstat(pluginDir)).isDirectory()) {
return undefined;
}
try {
return await getPluginDetailsFromDir(pluginDir);
} catch (e) {
console.error(`Failed to load plugin from ${pluginDir}`, e);
return undefined;
}
});
return plugins.filter(notNull);
}
export async function getInstalledPlugins(): Promise<InstalledPluginDetails[]> {
const map = new Map<string, InstalledPluginDetails>(
(await getFullyInstalledPlugins()).map((p) => [
p.name,
{...p, installationStatus: 'installed'},
]),
);
for (const p of await getPendingInstallationPlugins()) {
if (!map.get(p.name) || semver.gt(p.version, map.get(p.name)!.version)) {
map.set(p.name, {...p, installationStatus: 'pending'});
}
}
const allPlugins = [...map.values()].sort((p1, p2) =>
p1.installationStatus === 'installed' && p2.installationStatus === 'pending'
? 1
: p1.installationStatus === 'pending' &&
p2.installationStatus === 'installed'
? -1
: p1.name.localeCompare(p2.name),
);
return allPlugins;
}

View File

@@ -10,7 +10,7 @@
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import {PluginDetails} from './PluginDetails'; import {PluginDetails} from './PluginDetails';
import {getPluginInstallationDir, pluginCacheDir} from './pluginPaths'; import {getPluginVersionInstallationDir, pluginCacheDir} from './pluginPaths';
export async function getPluginDetails(pluginDir: string, packageJson: any) { export async function getPluginDetails(pluginDir: string, packageJson: any) {
const specVersion = const specVersion =
@@ -37,7 +37,10 @@ export async function getPluginDetailsFromDir(
} }
export async function getPluginDetailsFromPackageJson(packageJson: any) { export async function getPluginDetailsFromPackageJson(packageJson: any) {
const pluginDir = getPluginInstallationDir(packageJson.name); const pluginDir = getPluginVersionInstallationDir(
packageJson.name,
packageJson.version,
);
return await getPluginDetails(pluginDir, packageJson); return await getPluginDetails(pluginDir, packageJson);
} }

View File

@@ -8,19 +8,21 @@
*/ */
import PluginDetails from './PluginDetails'; import PluginDetails from './PluginDetails';
import {getInstalledPlugins} from './getInstalledPlugins'; import {getInstalledPlugins} from './pluginInstaller';
import semver from 'semver'; import semver from 'semver';
import {getNpmHostedPlugins, NpmPackageDescriptor} from './getNpmHostedPlugins'; import {getNpmHostedPlugins, NpmPackageDescriptor} from './getNpmHostedPlugins';
import NpmApi from 'npm-api'; import NpmApi from 'npm-api';
import {getPluginDetails} from './getPluginDetails'; import {
import {getPluginInstallationDir} from './pluginPaths'; getPluginDetails,
getPluginDetailsFromPackageJson,
} from './getPluginDetails';
import {getPluginVersionInstallationDir} from './pluginPaths';
import pmap from 'p-map'; import pmap from 'p-map';
import {notNull} from './typeUtils'; import {notNull} from './typeUtils';
const npmApi = new NpmApi(); const npmApi = new NpmApi();
export type UpdateResult = export type UpdateResult =
| {kind: 'not-installed'; version: string} | {kind: 'not-installed'; version: string}
| {kind: 'pending'}
| {kind: 'up-to-date'} | {kind: 'up-to-date'}
| {kind: 'error'; error: Error} | {kind: 'error'; error: Error}
| {kind: 'update-available'; version: string}; | {kind: 'update-available'; version: string};
@@ -50,7 +52,10 @@ export async function getUpdatablePlugins(
) { ) {
const pkg = await npmApi.repo(npmPackageDescriptor.name).package(); const pkg = await npmApi.repo(npmPackageDescriptor.name).package();
const npmPluginDetails = await getPluginDetails( const npmPluginDetails = await getPluginDetails(
getPluginInstallationDir(npmPackageDescriptor.name), getPluginVersionInstallationDir(
npmPackageDescriptor.name,
npmPackageDescriptor.version,
),
pkg, pkg,
); );
return { return {
@@ -62,10 +67,7 @@ export async function getUpdatablePlugins(
}; };
} }
} }
const updateStatus: UpdateResult = const updateStatus: UpdateResult = {kind: 'up-to-date'};
installedPlugin.installationStatus === 'installed'
? {kind: 'up-to-date'}
: {kind: 'pending'};
return { return {
...installedPlugin, ...installedPlugin,
updateStatus, updateStatus,
@@ -89,10 +91,7 @@ export async function getUpdatablePlugins(
async (notInstalledPlugin) => { async (notInstalledPlugin) => {
try { try {
const pkg = await npmApi.repo(notInstalledPlugin.name).package(); const pkg = await npmApi.repo(notInstalledPlugin.name).package();
const npmPluginDetails = await getPluginDetails( const npmPluginDetails = await getPluginDetailsFromPackageJson(pkg);
getPluginInstallationDir(notInstalledPlugin.name),
pkg,
);
if (npmPluginDetails.specVersion === 1) { if (npmPluginDetails.specVersion === 1) {
return null; return null;
} }
@@ -106,6 +105,7 @@ export async function getUpdatablePlugins(
} catch (error) { } catch (error) {
console.log( console.log(
`Failed to load details from npm for plugin ${notInstalledPlugin.name}`, `Failed to load details from npm for plugin ${notInstalledPlugin.name}`,
error,
); );
return null; return null;
} }

View File

@@ -10,7 +10,6 @@
export * from './PluginDetails'; export * from './PluginDetails';
export * from './getPluginDetails'; export * from './getPluginDetails';
export * from './pluginInstaller'; export * from './pluginInstaller';
export * from './getInstalledPlugins';
export * from './getUpdatablePlugins'; export * from './getUpdatablePlugins';
export * from './getSourcePlugins'; export * from './getSourcePlugins';
export * from './pluginPaths'; export * from './pluginPaths';

View File

@@ -18,14 +18,16 @@ import tmp from 'tmp';
import PluginDetails from './PluginDetails'; import PluginDetails from './PluginDetails';
import {getPluginDetailsFromDir} from './getPluginDetails'; import {getPluginDetailsFromDir} from './getPluginDetails';
import { import {
getPluginInstallationDir, getPluginVersionInstallationDir,
getPluginPendingInstallationDir,
getPluginPendingInstallationsDir,
pluginInstallationDir,
pluginPendingInstallationDir,
getPluginDirNameFromPackageName, getPluginDirNameFromPackageName,
getPluginInstallationDir,
pluginInstallationDir,
legacyPluginInstallationDir,
} from './pluginPaths'; } from './pluginPaths';
import pfilter from 'p-filter';
import pmap from 'p-map';
import semver from 'semver'; import semver from 'semver';
import {notNull} from './typeUtils';
const getTmpDir = promisify(tmp.dir) as () => Promise<string>; const getTmpDir = promisify(tmp.dir) as () => Promise<string>;
@@ -39,8 +41,7 @@ async function installPluginFromTempDir(
const pluginDetails = await getPluginDetailsFromDir(sourceDir); const pluginDetails = await getPluginDetailsFromDir(sourceDir);
const {name, version} = pluginDetails; const {name, version} = pluginDetails;
const backupDir = path.join(await getTmpDir(), `${name}-${version}`); const backupDir = path.join(await getTmpDir(), `${name}-${version}`);
const installationsDir = getPluginPendingInstallationsDir(name); const destinationDir = getPluginVersionInstallationDir(name, version);
const destinationDir = getPluginPendingInstallationDir(name, version);
if (pluginDetails.specVersion == 1) { if (pluginDetails.specVersion == 1) {
throw new Error( throw new Error(
@@ -53,17 +54,7 @@ async function installPluginFromTempDir(
if (await fs.pathExists(destinationDir)) { if (await fs.pathExists(destinationDir)) {
await fs.move(destinationDir, backupDir, {overwrite: true}); await fs.move(destinationDir, backupDir, {overwrite: true});
} }
await fs.move(sourceDir, destinationDir); await fs.move(sourceDir, destinationDir);
// Cleaning up all the previously downloaded packages, because we've got the newest one.
const otherPackages = await fs.readdir(installationsDir);
for (const otherPackage of otherPackages) {
const otherPackageDir = path.join(installationsDir, otherPackage);
if (otherPackageDir !== destinationDir) {
await fs.remove(otherPackageDir);
}
}
} catch (err) { } catch (err) {
// Restore previous version from backup if installation failed // Restore previous version from backup if installation failed
await fs.remove(destinationDir); await fs.remove(destinationDir);
@@ -95,21 +86,15 @@ async function getPluginRootDir(dir: string) {
export async function getInstalledPlugin( export async function getInstalledPlugin(
name: string, name: string,
version: string,
): Promise<PluginDetails | null> { ): Promise<PluginDetails | null> {
const dir = getPluginInstallationDir(name); const dir = getPluginVersionInstallationDir(name, version);
if (!(await fs.pathExists(dir))) { if (!(await fs.pathExists(dir))) {
return null; return null;
} }
return await getPluginDetailsFromDir(dir); return await getPluginDetailsFromDir(dir);
} }
export async function isPluginPendingInstallation(
name: string,
version: string,
) {
return await fs.pathExists(getPluginPendingInstallationDir(name, version));
}
export async function installPluginFromNpm(name: string) { export async function installPluginFromNpm(name: string) {
const tmpDir = await getTmpDir(); const tmpDir = await getTmpDir();
try { try {
@@ -146,63 +131,105 @@ export async function installPluginFromFile(
} }
export async function removePlugin(name: string): Promise<void> { export async function removePlugin(name: string): Promise<void> {
await Promise.all([ await fs.remove(getPluginInstallationDir(name));
fs.remove(getPluginInstallationDir(name)),
fs.remove(getPluginPendingInstallationsDir(name)),
]);
} }
export async function finishPendingPluginInstallations() { export async function getInstalledPlugins(): Promise<PluginDetails[]> {
if (!(await fs.pathExists(pluginPendingInstallationDir))) { const versionDirs = await getInstalledPluginVersionDirs();
return; return pmap(
} versionDirs
try { .filter(([_, versionDirs]) => versionDirs.length > 0)
await fs.ensureDir(pluginInstallationDir); .map(([_, versionDirs]) => versionDirs[0]),
// create empty watchman config (required by metro's file watcher) (latestVersionDir) => getPluginDetailsFromDir(latestVersionDir),
const watchmanConfigPath = path.join(
pluginInstallationDir,
'.watchmanconfig',
); );
if (!(await fs.pathExists(watchmanConfigPath))) {
await fs.writeFile(watchmanConfigPath, '{}');
} }
const pendingPlugins = await fs.readdir(pluginPendingInstallationDir);
for (const pendingPlugin of pendingPlugins) { export async function cleanupOldInstalledPluginVersions(
const pendingInstallationsDir = getPluginPendingInstallationsDir( maxNumberOfVersionsToKeep: number,
pendingPlugin, ): Promise<void> {
const versionDirs = await getInstalledPluginVersionDirs();
const versionDirsToDelete = versionDirs
.map(([_, versionDirs]) => versionDirs.slice(maxNumberOfVersionsToKeep))
.flat();
await pmap(versionDirsToDelete, (versionDirToDelete) =>
fs.remove(versionDirToDelete).catch(() => {}),
); );
const pendingVersions = (
await fs.readdir(pendingInstallationsDir)
).sort((v1, v2) => semver.compare(v2, v1, true)); // sort versions in descending order
if (pendingVersions.length === 0) {
await fs.remove(pendingInstallationsDir);
continue;
} }
const version = pendingVersions[0];
const pendingInstallation = path.join(pendingInstallationsDir, version); // Before that we installed all plugins to "thirdparty" folder and only kept
const installationDir = getPluginInstallationDir(pendingPlugin); // a single version for each of them. Now we install plugins to "installed-plugins"
const backupDir = path.join(await getTmpDir(), pendingPlugin); // folder and keep multiple versions. This function checks if the legacy folder exists and
try { // moves all the plugins installed there to the new folder.
if (await fs.pathExists(installationDir)) { export async function moveInstalledPluginsFromLegacyDir() {
await fs.move(installationDir, backupDir, {overwrite: true}); if (await fs.pathExists(legacyPluginInstallationDir)) {
} await fs
await fs.move(pendingInstallation, installationDir, {overwrite: true}); .readdir(legacyPluginInstallationDir)
await fs.remove(pendingInstallationsDir); .then((dirs) =>
} catch (err) { dirs.map((dir) => path.join(legacyPluginInstallationDir, dir)),
console.error( )
`Error while finishing pending installation for ${pendingPlugin}`, .then((dirs) =>
err, pfilter(dirs, (dir) =>
fs
.lstat(dir)
.then((lstat) => lstat.isDirectory())
.catch(() => Promise.resolve(false)),
),
)
.then((dirs) =>
pmap(dirs, (dir) => getPluginDetailsFromDir(dir).catch(() => null)),
)
.then((plugins) =>
pmap(plugins.filter(notNull), (plugin) =>
fs.move(
plugin.dir,
getPluginVersionInstallationDir(plugin.name, plugin.version),
{overwrite: true},
),
),
); );
// in case of error, keep the previously installed version await fs.remove(legacyPluginInstallationDir);
await fs.remove(installationDir);
if (await fs.pathExists(backupDir)) {
await fs.move(backupDir, installationDir, {overwrite: true});
}
} finally {
await fs.remove(backupDir);
} }
} }
} catch (err) {
console.error('Error while finishing plugin pending installations', err); type InstalledPluginVersionDirs = [string, string[]][];
}
async function getInstalledPluginVersionDirs(): Promise<
InstalledPluginVersionDirs
> {
return await fs
.readdir(pluginInstallationDir)
.then((dirs) => dirs.map((dir) => path.join(pluginInstallationDir, dir)))
.then((dirs) =>
pfilter(dirs, (dir) =>
fs
.lstat(dir)
.then((lstat) => lstat.isDirectory())
.catch(() => false),
),
)
.then((dirs) =>
pmap(dirs, (dir) =>
fs
.readdir(dir)
.then((versionDirs) =>
versionDirs.sort((v1, v2) => semver.compare(v2, v1, true)),
)
.then((versionDirs) =>
versionDirs.map((versionDir) => path.join(dir, versionDir)),
)
.then((versionDirs) =>
pfilter(versionDirs, (versionDir) =>
fs
.lstat(versionDir)
.then((lstat) => lstat.isDirectory())
.catch(() => false),
),
),
).then((allDirs) =>
allDirs.reduce<InstalledPluginVersionDirs>((agg, versionDirs, i) => {
agg.push([dirs[i], versionDirs]);
return agg;
}, []),
),
);
} }

View File

@@ -14,11 +14,14 @@ import expandTilde from 'expand-tilde';
const flipperDataDir = path.join(homedir(), '.flipper'); const flipperDataDir = path.join(homedir(), '.flipper');
export const pluginInstallationDir = path.join(flipperDataDir, 'thirdparty'); export const legacyPluginInstallationDir = path.join(
export const pluginPendingInstallationDir = path.join(
flipperDataDir, flipperDataDir,
'pending', 'thirdparty',
);
export const pluginInstallationDir = path.join(
flipperDataDir,
'installed-plugins',
); );
export const pluginCacheDir = path.join(flipperDataDir, 'plugins'); export const pluginCacheDir = path.join(flipperDataDir, 'plugins');
@@ -43,27 +46,20 @@ export async function getPluginSourceFolders(): Promise<string[]> {
return pluginFolders.map(expandTilde).filter(fs.existsSync); return pluginFolders.map(expandTilde).filter(fs.existsSync);
} }
export function getPluginPendingInstallationDir( export function getPluginInstallationDir(name: string) {
name: string,
version: string,
): string {
return path.join(getPluginPendingInstallationsDir(name), version);
}
export function getPluginPendingInstallationsDir(name: string): string {
return path.join(
pluginPendingInstallationDir,
getPluginDirNameFromPackageName(name),
);
}
export function getPluginInstallationDir(name: string): string {
return path.join( return path.join(
pluginInstallationDir, pluginInstallationDir,
getPluginDirNameFromPackageName(name), getPluginDirNameFromPackageName(name),
); );
} }
export function getPluginVersionInstallationDir(
name: string,
version: string,
): string {
return path.join(getPluginInstallationDir(name), version);
}
export function getPluginDirNameFromPackageName(name: string) { export function getPluginDirNameFromPackageName(name: string) {
return name.replace('/', '__'); return name.replace('/', '__');
} }