Summary: Sorry for long diff! I can try to split it if necessary, but many changes here are 1-1 replacements / renames. **Preambule** Currently we bundle default plugins into the Flipper main bundle. This helps us to reduce bundle size, because of plugin dependencies re-use. E.g. if multiple plugins use "lodash" when they are bundled together, only one copy of "lodash" added. When they are bundled separately, the same dependency might be added to each of them. However as we're not going to include most of plugins into Flipper distributive anymore and going to rely on Marketplace instead, this bundling doesn't provide significant size benefits anymore. In addition to that, bundling makes it impossible to differentiate whether thrown errors are originated from Flipper core or one of its plugins. Why don't we remove plugin bundling at all? Because for "dev mode" it actually quite useful. It makes dev build start much faster and also enables using of Fast Refresh for plugin development (fast refresh won't work for plugins loaded from disk). **Changes** This diff introduces new option "no-bundled-plugins" for "yarn start" and "yarn build" commands. For now, by default, we will continue bundling default plugins into the Flipper main bundle, but if this option provided then we will build each default plugin separately and include their packages into the Flipper distributive as "pre-installed" to be able to load them from disk even without access to Marketplace. For "yarn start", we're adding symlinks to plugin folders in "static/defaultPlugins" and then they are loaded by Flipper. For "yarn build" we are dereferencing these symlinks to include physical files of plugins into folder "defaultPlugins" of the produced distributive. Folder "defaultPlugins" is excluded from asar, because loading of plugins from asar archive might introduce some unexpected issues depending on their implementation. Reviewed By: mweststrate Differential Revision: D28431838 fbshipit-source-id: f7757e9f5ba9183ed918d70252de3ce0e823177d
295 lines
9.4 KiB
TypeScript
295 lines
9.4 KiB
TypeScript
/**
|
|
* 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 path from 'path';
|
|
import fs from 'fs-extra';
|
|
import {promisify} from 'util';
|
|
import {PluginManager as PM} from 'live-plugin-manager';
|
|
import decompress from 'decompress';
|
|
import decompressTargz from 'decompress-targz';
|
|
import decompressUnzip from 'decompress-unzip';
|
|
import tmp from 'tmp';
|
|
import {InstalledPluginDetails} from './PluginDetails';
|
|
import {getInstalledPluginDetails, isPluginDir} from './getPluginDetails';
|
|
import {
|
|
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<string>;
|
|
|
|
function providePluginManagerNoDependencies(): PM {
|
|
return new PM({ignoredDependencies: [/.*/]});
|
|
}
|
|
|
|
async function installPluginFromTempDir(
|
|
sourceDir: string,
|
|
): Promise<InstalledPluginDetails> {
|
|
const pluginDetails = await getInstalledPluginDetails(sourceDir);
|
|
const {name, version} = pluginDetails;
|
|
const backupDir = path.join(await getTmpDir(), `${name}-${version}`);
|
|
const destinationDir = getPluginVersionInstallationDir(name, version);
|
|
|
|
if (pluginDetails.specVersion == 1) {
|
|
throw new Error(
|
|
`Cannot install plugin ${pluginDetails.name} because it is packaged using the unsupported format v1. Please encourage the plugin author to update to v2, following the instructions on https://fbflipper.com/docs/extending/js-setup#migration-to-the-new-plugin-specification`,
|
|
);
|
|
}
|
|
|
|
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);
|
|
} 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;
|
|
}
|
|
return await getInstalledPluginDetails(destinationDir);
|
|
}
|
|
|
|
async function getPluginRootDir(dir: string) {
|
|
// npm packages are tar.gz archives containing folder 'package' inside
|
|
const packageDir = path.join(dir, 'package');
|
|
const isNpmPackage = await fs.pathExists(packageDir);
|
|
|
|
// vsix packages are zip archives containing folder 'extension' inside
|
|
const extensionDir = path.join(dir, 'extension');
|
|
const isVsix = await fs.pathExists(extensionDir);
|
|
|
|
if (!isNpmPackage && !isVsix) {
|
|
throw new Error(
|
|
'Package format is invalid: directory "package" or "extensions" not found in the archive root',
|
|
);
|
|
}
|
|
|
|
return isNpmPackage ? packageDir : extensionDir;
|
|
}
|
|
|
|
export async function getInstalledPlugin(
|
|
name: string,
|
|
version: string,
|
|
): Promise<InstalledPluginDetails | null> {
|
|
const dir = getPluginVersionInstallationDir(name, version);
|
|
if (!(await fs.pathExists(dir))) {
|
|
return null;
|
|
}
|
|
return await getInstalledPluginDetails(dir);
|
|
}
|
|
|
|
export async function installPluginFromNpm(name: string) {
|
|
const tmpDir = await getTmpDir();
|
|
try {
|
|
await fs.ensureDir(tmpDir);
|
|
const plugManNoDep = providePluginManagerNoDependencies();
|
|
plugManNoDep.options.pluginsPath = tmpDir;
|
|
await plugManNoDep.install(name);
|
|
const pluginTempDir = path.join(
|
|
tmpDir,
|
|
getPluginDirNameFromPackageName(name),
|
|
);
|
|
await installPluginFromTempDir(pluginTempDir);
|
|
} finally {
|
|
await fs.remove(tmpDir);
|
|
}
|
|
}
|
|
|
|
export async function installPluginFromFile(
|
|
packagePath: string,
|
|
): Promise<InstalledPluginDetails> {
|
|
const tmpDir = await getTmpDir();
|
|
try {
|
|
const files = await decompress(packagePath, tmpDir, {
|
|
plugins: [decompressTargz(), decompressUnzip()],
|
|
});
|
|
if (!files.length) {
|
|
throw new Error('The package is not in tar.gz format or is empty');
|
|
}
|
|
const pluginDir = await getPluginRootDir(tmpDir);
|
|
return await installPluginFromTempDir(pluginDir);
|
|
} finally {
|
|
await fs.remove(tmpDir);
|
|
}
|
|
}
|
|
|
|
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 getAllInstalledPluginVersions(): Promise<
|
|
InstalledPluginDetails[]
|
|
> {
|
|
const pluginDirs = await getInstalledPluginVersionDirs();
|
|
const versionDirs = pluginDirs.map(([_, versionDirs]) => versionDirs).flat();
|
|
return await pmap(versionDirs, (versionDir) =>
|
|
getInstalledPluginDetails(versionDir).catch((err) => {
|
|
console.error(`Failed to load plugin details from ${versionDir}`, err);
|
|
return null;
|
|
}),
|
|
).then((versionDetails) => versionDetails.filter(notNull));
|
|
}
|
|
|
|
export async function getInstalledPlugins(): Promise<InstalledPluginDetails[]> {
|
|
const versionDirs = await getInstalledPluginVersionDirs();
|
|
return pmap(
|
|
versionDirs
|
|
.filter(([_, versionDirs]) => versionDirs.length > 0)
|
|
.map(([_, versionDirs]) => versionDirs[0]),
|
|
(latestVersionDir) =>
|
|
getInstalledPluginDetails(latestVersionDir).catch((err) => {
|
|
console.error(`Failed to load plugin from ${latestVersionDir}`, err);
|
|
return null;
|
|
}),
|
|
).then((plugins) => plugins.filter(notNull));
|
|
}
|
|
|
|
export async function cleanupOldInstalledPluginVersions(
|
|
maxNumberOfVersionsToKeep: number,
|
|
): Promise<void> {
|
|
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(() => false),
|
|
),
|
|
)
|
|
.then((dirs) =>
|
|
pmap(dirs, (dir) =>
|
|
getInstalledPluginDetails(dir).catch(async (err) => {
|
|
console.error(
|
|
`Failed to load plugin from ${dir} on moving legacy plugins. Removing it.`,
|
|
err,
|
|
);
|
|
fs.remove(dir);
|
|
return null;
|
|
}),
|
|
),
|
|
)
|
|
.then((plugins) =>
|
|
pmap(plugins.filter(notNull), (plugin) =>
|
|
fs.move(
|
|
plugin.dir,
|
|
getPluginVersionInstallationDir(plugin.name, plugin.version),
|
|
{overwrite: true},
|
|
),
|
|
),
|
|
);
|
|
await fs.remove(legacyPluginInstallationDir);
|
|
}
|
|
}
|
|
|
|
type InstalledPluginVersionDirs = [string, string[]][];
|
|
|
|
async function getInstalledPluginVersionDirs(): Promise<InstalledPluginVersionDirs> {
|
|
if (!(await fs.pathExists(pluginInstallationDir))) {
|
|
return [];
|
|
}
|
|
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.filter((d) => semver.valid(d)))
|
|
.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;
|
|
}, []),
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function getAllInstalledPluginsInDir(
|
|
dir: string,
|
|
recursive: boolean = false,
|
|
): Promise<InstalledPluginDetails[]> {
|
|
const plugins: InstalledPluginDetails[] = [];
|
|
if (!((await fs.pathExists(dir)) && (await fs.stat(dir)).isDirectory())) {
|
|
console.log('defaultPlugins dir not found');
|
|
return plugins;
|
|
}
|
|
const items = await fs.readdir(dir);
|
|
await pmap(items, async (item) => {
|
|
const fullPath = path.join(dir, item);
|
|
if (await isPluginDir(fullPath)) {
|
|
try {
|
|
plugins.push(await getInstalledPluginDetails(fullPath));
|
|
} catch (err) {
|
|
console.error(`Failed to load plugin from ${fullPath}`);
|
|
}
|
|
} else if (recursive) {
|
|
plugins.push(...(await getAllInstalledPluginsInDir(fullPath, recursive)));
|
|
}
|
|
});
|
|
return plugins;
|
|
}
|