From 02d695cb28892123f24ea6f884e94fee45bc13fd Mon Sep 17 00:00:00 2001 From: Anton Nikolaev Date: Tue, 15 Dec 2020 09:28:58 -0800 Subject: [PATCH] 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 --- desktop/app/package.json | 1 + .../chrome/plugin-manager/PluginInstaller.tsx | 6 +- .../__tests__/PluginInstaller.node.tsx | 8 +- desktop/app/src/dispatcher/pluginManager.tsx | 13 +- .../reducers/__tests__/pluginManager.node.tsx | 5 +- desktop/app/src/reducers/pluginManager.tsx | 10 +- desktop/app/src/utils/loadDynamicPlugins.tsx | 85 +++----- .../src/__tests__/getUpdatablePlugins.node.ts | 10 +- .../src/__tests__/pluginInstaller.node.ts | 95 --------- desktop/plugin-lib/src/getInstalledPlugins.ts | 104 ---------- desktop/plugin-lib/src/getPluginDetails.ts | 7 +- desktop/plugin-lib/src/getUpdatablePlugins.ts | 26 +-- desktop/plugin-lib/src/index.ts | 1 - desktop/plugin-lib/src/pluginInstaller.ts | 187 ++++++++++-------- desktop/plugin-lib/src/pluginPaths.ts | 34 ++-- 15 files changed, 194 insertions(+), 398 deletions(-) delete mode 100644 desktop/plugin-lib/src/__tests__/pluginInstaller.node.ts delete mode 100644 desktop/plugin-lib/src/getInstalledPlugins.ts diff --git a/desktop/app/package.json b/desktop/app/package.json index 26821a1d6..a65e2f035 100644 --- a/desktop/app/package.json +++ b/desktop/app/package.json @@ -39,6 +39,7 @@ "lodash.memoize": "^4.1.2", "open": "^7.0.0", "openssl-wrapper": "^0.3.4", + "p-filter": "^2.1.0", "p-map": "^4.0.0", "promise-retry": "^2.0.1", "promisify-child-process": "^4.1.0", diff --git a/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx b/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx index 43a175b66..f6411af37 100644 --- a/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx +++ b/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx @@ -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`); diff --git a/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx b/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx index 10a9e5260..c296c629e 100644 --- a/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx +++ b/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx @@ -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 = ( - store.dispatch(registerInstalledPlugins(plugins)), - ); + cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep) + .then(() => getInstalledPlugins()) + .then((plugins) => store.dispatch(registerInstalledPlugins(plugins))); } export default (store: Store, _logger: Logger) => { diff --git a/desktop/app/src/reducers/__tests__/pluginManager.node.tsx b/desktop/app/src/reducers/__tests__/pluginManager.node.tsx index 85aed7787..3548a5171 100644 --- a/desktop/app/src/reducers/__tests__/pluginManager.node.tsx +++ b/desktop/app/src/reducers/__tests__/pluginManager.node.tsx @@ -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])); diff --git a/desktop/app/src/reducers/pluginManager.tsx b/desktop/app/src/reducers/pluginManager.tsx index 0b9871de2..b755e0a8f 100644 --- a/desktop/app/src/reducers/pluginManager.tsx +++ b/desktop/app/src/reducers/pluginManager.tsx @@ -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, }); diff --git a/desktop/app/src/utils/loadDynamicPlugins.tsx b/desktop/app/src/utils/loadDynamicPlugins.tsx index 6208d0ccd..e5b5a86f1 100644 --- a/desktop/app/src/utils/loadDynamicPlugins.tsx +++ b/desktop/app/src/utils/loadDynamicPlugins.tsx @@ -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 { if (process.env.FLIPPER_FAST_REFRESH) { console.log( @@ -30,63 +26,38 @@ export default async function loadDynamicPlugins(): Promise { ); 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( ( 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 { - 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]; } diff --git a/desktop/plugin-lib/src/__tests__/getUpdatablePlugins.node.ts b/desktop/plugin-lib/src/__tests__/getUpdatablePlugins.node.ts index 379f88f34..38485c5c1 100644 --- a/desktop/plugin-lib/src/__tests__/getUpdatablePlugins.node.ts +++ b/desktop/plugin-lib/src/__tests__/getUpdatablePlugins.node.ts @@ -7,7 +7,7 @@ * @format */ -jest.mock('../getInstalledPlugins'); +jest.mock('../pluginInstaller'); jest.mock('../getNpmHostedPlugins'); import {getUpdatablePlugins} from '../getUpdatablePlugins'; @@ -15,10 +15,10 @@ import { getNpmHostedPlugins, NpmPackageDescriptor, } from '../getNpmHostedPlugins'; -import type {InstalledPluginDetails} from '../getInstalledPlugins'; -import {getInstalledPlugins} from '../getInstalledPlugins'; +import {getInstalledPlugins} from '../pluginInstaller'; import {mocked} from 'ts-jest/utils'; import type {Package} from 'npm-api'; +import PluginDetails from '../PluginDetails'; jest.mock('npm-api', () => { return jest.fn().mockImplementation(() => { @@ -54,7 +54,7 @@ jest.mock('npm-api', () => { }); }); -const installedPlugins: InstalledPluginDetails[] = [ +const installedPlugins: PluginDetails[] = [ { name: 'flipper-plugin-hello', entry: './test/index.js', @@ -67,7 +67,6 @@ const installedPlugins: InstalledPluginDetails[] = [ title: 'Hello', description: 'World?', isDefault: false, - installationStatus: 'installed', }, { name: 'flipper-plugin-world', @@ -81,7 +80,6 @@ const installedPlugins: InstalledPluginDetails[] = [ title: 'World', description: 'Hello?', isDefault: false, - installationStatus: 'pending', }, ]; diff --git a/desktop/plugin-lib/src/__tests__/pluginInstaller.node.ts b/desktop/plugin-lib/src/__tests__/pluginInstaller.node.ts deleted file mode 100644 index 496b61eb8..000000000 --- a/desktop/plugin-lib/src/__tests__/pluginInstaller.node.ts +++ /dev/null @@ -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 []`); - }); -}); diff --git a/desktop/plugin-lib/src/getInstalledPlugins.ts b/desktop/plugin-lib/src/getInstalledPlugins.ts deleted file mode 100644 index 55a872145..000000000 --- a/desktop/plugin-lib/src/getInstalledPlugins.ts +++ /dev/null @@ -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 { - 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 { - 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 { - const map = new Map( - (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; -} diff --git a/desktop/plugin-lib/src/getPluginDetails.ts b/desktop/plugin-lib/src/getPluginDetails.ts index ff11cce1d..fdc827226 100644 --- a/desktop/plugin-lib/src/getPluginDetails.ts +++ b/desktop/plugin-lib/src/getPluginDetails.ts @@ -10,7 +10,7 @@ import fs from 'fs-extra'; import path from 'path'; import {PluginDetails} from './PluginDetails'; -import {getPluginInstallationDir, pluginCacheDir} from './pluginPaths'; +import {getPluginVersionInstallationDir, pluginCacheDir} from './pluginPaths'; export async function getPluginDetails(pluginDir: string, packageJson: any) { const specVersion = @@ -37,7 +37,10 @@ export async function getPluginDetailsFromDir( } export async function getPluginDetailsFromPackageJson(packageJson: any) { - const pluginDir = getPluginInstallationDir(packageJson.name); + const pluginDir = getPluginVersionInstallationDir( + packageJson.name, + packageJson.version, + ); return await getPluginDetails(pluginDir, packageJson); } diff --git a/desktop/plugin-lib/src/getUpdatablePlugins.ts b/desktop/plugin-lib/src/getUpdatablePlugins.ts index 708fa6d4e..021d0339f 100644 --- a/desktop/plugin-lib/src/getUpdatablePlugins.ts +++ b/desktop/plugin-lib/src/getUpdatablePlugins.ts @@ -8,19 +8,21 @@ */ import PluginDetails from './PluginDetails'; -import {getInstalledPlugins} from './getInstalledPlugins'; +import {getInstalledPlugins} from './pluginInstaller'; import semver from 'semver'; import {getNpmHostedPlugins, NpmPackageDescriptor} from './getNpmHostedPlugins'; import NpmApi from 'npm-api'; -import {getPluginDetails} from './getPluginDetails'; -import {getPluginInstallationDir} from './pluginPaths'; +import { + getPluginDetails, + getPluginDetailsFromPackageJson, +} from './getPluginDetails'; +import {getPluginVersionInstallationDir} from './pluginPaths'; import pmap from 'p-map'; import {notNull} from './typeUtils'; const npmApi = new NpmApi(); export type UpdateResult = | {kind: 'not-installed'; version: string} - | {kind: 'pending'} | {kind: 'up-to-date'} | {kind: 'error'; error: Error} | {kind: 'update-available'; version: string}; @@ -50,7 +52,10 @@ export async function getUpdatablePlugins( ) { const pkg = await npmApi.repo(npmPackageDescriptor.name).package(); const npmPluginDetails = await getPluginDetails( - getPluginInstallationDir(npmPackageDescriptor.name), + getPluginVersionInstallationDir( + npmPackageDescriptor.name, + npmPackageDescriptor.version, + ), pkg, ); return { @@ -62,10 +67,7 @@ export async function getUpdatablePlugins( }; } } - const updateStatus: UpdateResult = - installedPlugin.installationStatus === 'installed' - ? {kind: 'up-to-date'} - : {kind: 'pending'}; + const updateStatus: UpdateResult = {kind: 'up-to-date'}; return { ...installedPlugin, updateStatus, @@ -89,10 +91,7 @@ export async function getUpdatablePlugins( async (notInstalledPlugin) => { try { const pkg = await npmApi.repo(notInstalledPlugin.name).package(); - const npmPluginDetails = await getPluginDetails( - getPluginInstallationDir(notInstalledPlugin.name), - pkg, - ); + const npmPluginDetails = await getPluginDetailsFromPackageJson(pkg); if (npmPluginDetails.specVersion === 1) { return null; } @@ -106,6 +105,7 @@ export async function getUpdatablePlugins( } catch (error) { console.log( `Failed to load details from npm for plugin ${notInstalledPlugin.name}`, + error, ); return null; } diff --git a/desktop/plugin-lib/src/index.ts b/desktop/plugin-lib/src/index.ts index 78731618d..80d17e336 100644 --- a/desktop/plugin-lib/src/index.ts +++ b/desktop/plugin-lib/src/index.ts @@ -10,7 +10,6 @@ export * from './PluginDetails'; export * from './getPluginDetails'; export * from './pluginInstaller'; -export * from './getInstalledPlugins'; export * from './getUpdatablePlugins'; export * from './getSourcePlugins'; export * from './pluginPaths'; diff --git a/desktop/plugin-lib/src/pluginInstaller.ts b/desktop/plugin-lib/src/pluginInstaller.ts index a2c148837..955db347d 100644 --- a/desktop/plugin-lib/src/pluginInstaller.ts +++ b/desktop/plugin-lib/src/pluginInstaller.ts @@ -18,14 +18,16 @@ import tmp from 'tmp'; import PluginDetails from './PluginDetails'; import {getPluginDetailsFromDir} from './getPluginDetails'; import { - getPluginInstallationDir, - getPluginPendingInstallationDir, - getPluginPendingInstallationsDir, - pluginInstallationDir, - pluginPendingInstallationDir, + getPluginVersionInstallationDir, getPluginDirNameFromPackageName, + getPluginInstallationDir, + pluginInstallationDir, + legacyPluginInstallationDir, } from './pluginPaths'; +import pfilter from 'p-filter'; +import pmap from 'p-map'; import semver from 'semver'; +import {notNull} from './typeUtils'; const getTmpDir = promisify(tmp.dir) as () => Promise; @@ -39,8 +41,7 @@ async function installPluginFromTempDir( const pluginDetails = await getPluginDetailsFromDir(sourceDir); const {name, version} = pluginDetails; const backupDir = path.join(await getTmpDir(), `${name}-${version}`); - const installationsDir = getPluginPendingInstallationsDir(name); - const destinationDir = getPluginPendingInstallationDir(name, version); + const destinationDir = getPluginVersionInstallationDir(name, version); if (pluginDetails.specVersion == 1) { throw new Error( @@ -53,17 +54,7 @@ async function installPluginFromTempDir( if (await fs.pathExists(destinationDir)) { await fs.move(destinationDir, backupDir, {overwrite: true}); } - 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) { // Restore previous version from backup if installation failed await fs.remove(destinationDir); @@ -95,21 +86,15 @@ async function getPluginRootDir(dir: string) { export async function getInstalledPlugin( name: string, + version: string, ): Promise { - const dir = getPluginInstallationDir(name); + const dir = getPluginVersionInstallationDir(name, version); if (!(await fs.pathExists(dir))) { return null; } 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) { const tmpDir = await getTmpDir(); try { @@ -146,63 +131,105 @@ export async function installPluginFromFile( } export async function removePlugin(name: string): Promise { - await Promise.all([ - fs.remove(getPluginInstallationDir(name)), - fs.remove(getPluginPendingInstallationsDir(name)), - ]); + await fs.remove(getPluginInstallationDir(name)); } -export async function finishPendingPluginInstallations() { - if (!(await fs.pathExists(pluginPendingInstallationDir))) { - return; - } - try { - await fs.ensureDir(pluginInstallationDir); - // create empty watchman config (required by metro's file watcher) - 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) { - const pendingInstallationsDir = getPluginPendingInstallationsDir( - pendingPlugin, +export async function getInstalledPlugins(): Promise { + const versionDirs = await getInstalledPluginVersionDirs(); + return pmap( + versionDirs + .filter(([_, versionDirs]) => versionDirs.length > 0) + .map(([_, versionDirs]) => versionDirs[0]), + (latestVersionDir) => getPluginDetailsFromDir(latestVersionDir), + ); +} + +export async function cleanupOldInstalledPluginVersions( + maxNumberOfVersionsToKeep: number, +): Promise { + const versionDirs = await getInstalledPluginVersionDirs(); + const versionDirsToDelete = versionDirs + .map(([_, versionDirs]) => versionDirs.slice(maxNumberOfVersionsToKeep)) + .flat(); + await pmap(versionDirsToDelete, (versionDirToDelete) => + fs.remove(versionDirToDelete).catch(() => {}), + ); +} + +// Before that we installed all plugins to "thirdparty" folder and only kept +// a single version for each of them. Now we install plugins to "installed-plugins" +// folder and keep multiple versions. This function checks if the legacy folder exists and +// moves all the plugins installed there to the new folder. +export async function moveInstalledPluginsFromLegacyDir() { + if (await fs.pathExists(legacyPluginInstallationDir)) { + await fs + .readdir(legacyPluginInstallationDir) + .then((dirs) => + dirs.map((dir) => path.join(legacyPluginInstallationDir, dir)), + ) + .then((dirs) => + 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}, + ), + ), ); - 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); - const installationDir = getPluginInstallationDir(pendingPlugin); - const backupDir = path.join(await getTmpDir(), pendingPlugin); - try { - if (await fs.pathExists(installationDir)) { - await fs.move(installationDir, backupDir, {overwrite: true}); - } - await fs.move(pendingInstallation, installationDir, {overwrite: true}); - await fs.remove(pendingInstallationsDir); - } catch (err) { - console.error( - `Error while finishing pending installation for ${pendingPlugin}`, - err, - ); - // in case of error, keep the previously installed version - 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); + await fs.remove(legacyPluginInstallationDir); } } + +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((agg, versionDirs, i) => { + agg.push([dirs[i], versionDirs]); + return agg; + }, []), + ), + ); +} diff --git a/desktop/plugin-lib/src/pluginPaths.ts b/desktop/plugin-lib/src/pluginPaths.ts index a7eac5c44..dc875fdb1 100644 --- a/desktop/plugin-lib/src/pluginPaths.ts +++ b/desktop/plugin-lib/src/pluginPaths.ts @@ -14,11 +14,14 @@ import expandTilde from 'expand-tilde'; const flipperDataDir = path.join(homedir(), '.flipper'); -export const pluginInstallationDir = path.join(flipperDataDir, 'thirdparty'); - -export const pluginPendingInstallationDir = path.join( +export const legacyPluginInstallationDir = path.join( flipperDataDir, - 'pending', + 'thirdparty', +); + +export const pluginInstallationDir = path.join( + flipperDataDir, + 'installed-plugins', ); export const pluginCacheDir = path.join(flipperDataDir, 'plugins'); @@ -43,27 +46,20 @@ export async function getPluginSourceFolders(): Promise { return pluginFolders.map(expandTilde).filter(fs.existsSync); } -export function getPluginPendingInstallationDir( - 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 { +export function getPluginInstallationDir(name: string) { return path.join( pluginInstallationDir, getPluginDirNameFromPackageName(name), ); } +export function getPluginVersionInstallationDir( + name: string, + version: string, +): string { + return path.join(getPluginInstallationDir(name), version); +} + export function getPluginDirNameFromPackageName(name: string) { return name.replace('/', '__'); }