Install plugins to pending directory first to enable installing new versions of existing plugins

Summary:
Install plugins to pending directory first to enable installing new versions of existing plugins. On startup Flipper moves all the plugins from pending directory into installed plugins directory.

Auto-update, after downloading a plugin package, will also extract it to "pending", so after restart update will automatically be applied.

Reviewed By: mweststrate

Differential Revision: D21929713

fbshipit-source-id: 141b106415e941156ae598cf810ab3bed8c76ced
This commit is contained in:
Anton Nikolaev
2020-06-09 04:52:39 -07:00
committed by Facebook GitHub Bot
parent eff378defa
commit eeded4e32f
6 changed files with 206 additions and 48 deletions

View File

@@ -33,7 +33,7 @@ import {reportPlatformFailures, reportUsage} from '../../utils/metrics';
import restartFlipper from '../../utils/restartFlipper';
import {registerInstalledPlugins} from '../../reducers/pluginManager';
import {
readInstalledPlugins,
getPendingAndInstalledPlugins,
removePlugin,
PluginMap,
PluginDetails,
@@ -445,7 +445,7 @@ export default connect<PropsFromState, DispatchFromProps, OwnProps, AppState>(
}),
(dispatch: Dispatch<Action<any>>) => ({
refreshInstalledPlugins: () => {
readInstalledPlugins().then((plugins) =>
getPendingAndInstalledPlugins().then((plugins) =>
dispatch(registerInstalledPlugins(plugins)),
);
},

View File

@@ -10,10 +10,10 @@
import {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger';
import {registerInstalledPlugins} from '../reducers/pluginManager';
import {readInstalledPlugins} from 'flipper-plugin-lib';
import {getPendingAndInstalledPlugins} from 'flipper-plugin-lib';
function refreshInstalledPlugins(store: Store) {
readInstalledPlugins().then((plugins) =>
getPendingAndInstalledPlugins().then((plugins) =>
store.dispatch(registerInstalledPlugins(plugins)),
);
}

View File

@@ -14,6 +14,7 @@
"decompress-unzip": "^4.0.1",
"fs-extra": "^8.1.0",
"live-plugin-manager": "^0.14.1",
"semver": "^7.3.2",
"tmp": "^0.2.1"
},
"devDependencies": {

View File

@@ -17,7 +17,11 @@ import decompressUnzip from 'decompress-unzip';
import tmp from 'tmp';
import PluginDetails from './PluginDetails';
import getPluginDetails from './getPluginDetails';
import {pluginInstallationDir} from './pluginPaths';
import {
pluginInstallationDir,
pluginPendingInstallationDir,
} from './pluginPaths';
import semver from 'semver';
export type PluginMap = Map<string, PluginDetails>;
@@ -33,40 +37,67 @@ function providePluginManagerNoDependencies(): PM {
return new PM({ignoredDependencies: [/.*/]});
}
async function installPluginFromTempDir(pluginDir: string) {
const packageJSONPath = path.join(pluginDir, 'package.json');
const packageJSON = JSON.parse(
(await fs.readFile(packageJSONPath)).toString(),
);
const name = packageJSON.name;
function getPluginPendingInstallationDir(
name: string,
version: string,
): string {
return path.join(getPluginPendingInstallationsDir(name), version);
}
await fs.ensureDir(pluginInstallationDir);
// create empty watchman config (required by metro's file watcher)
await fs.writeFile(path.join(pluginInstallationDir, '.watchmanconfig'), '{}');
const destinationDir = path.join(pluginInstallationDir, name);
// Clean up existing destination files.
await fs.remove(destinationDir);
await fs.ensureDir(destinationDir);
function getPluginPendingInstallationsDir(name: string): string {
return path.join(pluginPendingInstallationDir, name);
}
const isPreBundled = await fs.pathExists(path.join(pluginDir, 'dist'));
if (!isPreBundled) {
function getPluginInstallationDir(name: string): string {
return path.join(pluginInstallationDir, name);
}
async function installPluginFromTempDir(sourceDir: string) {
const pluginDetails = await getPluginDetails(sourceDir);
const {name, version} = pluginDetails;
const backupDir = path.join(await getTmpDir(), `${name}-${version}`);
const installationsDir = getPluginPendingInstallationsDir(name);
const destinationDir = getPluginPendingInstallationDir(name, version);
if (pluginDetails.specVersion == 1) {
// For first version of spec we need to install dependencies
const pluginManager = providePluginManager();
// install the plugin dependencies into node_modules
const nodeModulesDir = path.join(destinationDir, 'node_modules');
const nodeModulesDir = path.join(
await getTmpDir(),
`${name}-${version}-modules`,
);
pluginManager.options.pluginsPath = nodeModulesDir;
await pluginManager.installFromPath(pluginDir);
await pluginManager.installFromPath(sourceDir);
// live-plugin-manager also installs plugin itself into the target dir, it's better remove it
await fs.remove(path.join(nodeModulesDir, name));
await fs.move(nodeModulesDir, path.join(sourceDir, 'node_modules'));
}
try {
// Moving the existing destination dir to backup
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);
if (await fs.pathExists(backupDir)) {
await fs.move(backupDir, destinationDir, {overwrite: true});
}
throw err;
}
// copying plugin files into the destination folder
const pluginFiles = await fs.readdir(pluginDir);
await Promise.all(
pluginFiles
.filter((f) => f !== 'node_modules')
.map((f) =>
fs.move(path.join(pluginDir, f), path.join(destinationDir, f)),
),
);
}
async function getPluginRootDir(dir: string) {
@@ -87,6 +118,23 @@ async function getPluginRootDir(dir: string) {
return isNpmPackage ? packageDir : extensionDir;
}
export async function getInstalledPlugin(
name: string,
): Promise<PluginDetails | null> {
const dir = getPluginInstallationDir(name);
if (!(await fs.pathExists(dir))) {
return null;
}
return await getPluginDetails(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 {
@@ -97,10 +145,8 @@ export async function installPluginFromNpm(name: string) {
const pluginTempDir = path.join(tmpDir, name);
await installPluginFromTempDir(pluginTempDir);
} finally {
if (await fs.pathExists(tmpDir)) {
await fs.remove(tmpDir);
}
}
}
export async function installPluginFromFile(packagePath: string) {
@@ -115,13 +161,11 @@ export async function installPluginFromFile(packagePath: string) {
const pluginDir = await getPluginRootDir(tmpDir);
await installPluginFromTempDir(pluginDir);
} finally {
if (await fs.pathExists(tmpDir)) {
await fs.remove(tmpDir);
}
}
}
export async function readInstalledPlugins(): Promise<PluginMap> {
export async function getInstalledPlugins(): Promise<PluginMap> {
const pluginDirExists = await fs.pathExists(pluginInstallationDir);
if (!pluginDirExists) {
return new Map();
@@ -146,6 +190,109 @@ export async function readInstalledPlugins(): Promise<PluginMap> {
return new Map(plugins.filter(Boolean));
}
export async function getPendingInstallationPlugins(): Promise<PluginMap> {
const pluginDirExists = await fs.pathExists(pluginPendingInstallationDir);
if (!pluginDirExists) {
return new Map();
}
const dirs = await fs.readdir(pluginPendingInstallationDir);
const plugins = await Promise.all<[string, PluginDetails]>(
dirs.map(
(name) =>
new Promise(async (resolve, reject) => {
const versions = (
await fs.readdir(path.join(pluginPendingInstallationDir, name))
).sort((v1, v2) => semver.compare(v2, v1, true));
if (versions.length === 0) {
return resolve(undefined);
}
const pluginDir = path.join(
pluginPendingInstallationDir,
name,
versions[0],
);
if (!(await fs.lstat(pluginDir)).isDirectory()) {
return resolve(undefined);
}
try {
resolve([name, await getPluginDetails(pluginDir)]);
} catch (e) {
reject(e);
}
}),
),
);
return new Map(plugins.filter(Boolean));
}
export async function getPendingAndInstalledPlugins(): Promise<PluginMap> {
const plugins = await getInstalledPlugins();
for (const [name, details] of await getPendingInstallationPlugins()) {
if (
!plugins.get(name) ||
semver.gt(details.version, plugins.get(name)!.version)
) {
plugins.set(name, details);
}
}
return plugins;
}
export async function removePlugin(name: string): Promise<void> {
await fs.remove(path.join(pluginInstallationDir, 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,
);
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);
}
}

View File

@@ -14,4 +14,9 @@ export const flipperDataDir = path.join(homedir(), '.flipper');
export const pluginInstallationDir = path.join(flipperDataDir, 'thirdparty');
export const pluginPendingInstallationDir = path.join(
flipperDataDir,
'pending',
);
export const pluginCacheDir = path.join(flipperDataDir, 'plugins');

View File

@@ -28,6 +28,7 @@ import setup from './setup';
import isFB from './fb-stubs/isFB';
import delegateToLauncher from './launcher';
import yargs from 'yargs';
import {finishPendingPluginInstallations} from 'flipper-plugin-lib';
const VERSION: string = (global as any).__VERSION__;
@@ -110,17 +111,21 @@ setInterval(() => {
}
}, 60 * 1000);
compilePlugins(() => {
finishPendingPluginInstallations()
.then(() =>
compilePlugins(() => {
if (win) {
win.reload();
}
}, path.join(flipperDir, 'plugins')).then((dynamicPlugins) => {
}, path.join(flipperDir, 'plugins')),
)
.then((dynamicPlugins) => {
ipcMain.on('get-dynamic-plugins', (event) => {
event.returnValue = dynamicPlugins;
});
pluginsCompiled = true;
tryCreateWindow();
});
});
// check if we already have an instance of this app open
const gotTheLock = app.requestSingleInstanceLock();