Files
flipper/desktop/app/src/dispatcher/plugins.tsx
Anton Nikolaev 00851c6b5d Fix bug with plugin re-installation after uninstallation
Summary:
Fixed a bug when plugin installation status wouldn't be saved between sessions when plugin uninstalled and then re-installed again. Before the fix, after Flipper restart, such plugin was uninstalled again because its package name was not removed from "uninstalledPlugins" state. This was because plugin id was used by mistake instead of name in few places.

To try avoiding this issue in future I've also renamed "uninstalledPlugins" to "uninstalledPluginNames" to make it more clear than package name should be used there rather than ID. As this field is persisted, I also added migration which moves data to the renamed field.

Reviewed By: passy

Differential Revision: D28314447

fbshipit-source-id: fbe3edc258b78fe7fbb0d966f93aabcdf3b66d4b
2021-05-12 07:49:13 -07:00

360 lines
11 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 type {Store} from '../reducers/index';
import type {Logger} from '../fb-interfaces/Logger';
import type {PluginDefinition} from '../plugin';
import React from 'react';
import ReactDOM from 'react-dom';
import adbkit from 'adbkit';
import {
registerPlugins,
addGatekeepedPlugins,
addDisabledPlugins,
addFailedPlugins,
registerLoadedPlugins,
registerBundledPlugins,
registerMarketplacePlugins,
MarketplacePluginDetails,
pluginsInitialised,
} from '../reducers/plugins';
import GK from '../fb-stubs/GK';
import {FlipperBasePlugin} from '../plugin';
import {setupMenuBar} from '../MenuBar';
import path from 'path';
import {default as config} from '../utils/processConfig';
import isProduction from '../utils/isProduction';
import {notNull} from '../utils/typeUtils';
import {sideEffect} from '../utils/sideEffect';
import semver from 'semver';
import {
ActivatablePluginDetails,
BundledPluginDetails,
PluginDetails,
} from 'flipper-plugin-lib';
import {tryCatchReportPluginFailures, reportUsage} from '../utils/metrics';
import * as FlipperPluginSDK from 'flipper-plugin';
import {_SandyPluginDefinition} from 'flipper-plugin';
import loadDynamicPlugins from '../utils/loadDynamicPlugins';
import * as Immer from 'immer';
import * as antd from 'antd';
import * as emotion_styled from '@emotion/styled';
import * as antdesign_icons from '@ant-design/icons';
// @ts-ignore
import * as crc32 from 'crc32';
// eslint-disable-next-line import/no-unresolved
import getDefaultPluginsIndex from '../utils/getDefaultPluginsIndex';
import {isDevicePluginDefinition} from '../utils/pluginUtils';
import {isPluginCompatible} from '../utils/isPluginCompatible';
let defaultPluginsIndex: any = null;
export default async (store: Store, logger: Logger) => {
// expose Flipper and exact globally for dynamically loaded plugins
const globalObject: any = typeof window === 'undefined' ? global : window;
// this list should match `replace-flipper-requires.tsx` and the `builtInModules` in `desktop/.eslintrc`
globalObject.React = React;
globalObject.ReactDOM = ReactDOM;
globalObject.Flipper = require('../index');
globalObject.adbkit = adbkit;
globalObject.FlipperPlugin = FlipperPluginSDK;
globalObject.Immer = Immer;
globalObject.antd = antd;
globalObject.emotion_styled = emotion_styled;
globalObject.antdesign_icons = antdesign_icons;
globalObject.crc32_hack_fix_me = crc32;
const gatekeepedPlugins: Array<ActivatablePluginDetails> = [];
const disabledPlugins: Array<ActivatablePluginDetails> = [];
const failedPlugins: Array<[ActivatablePluginDetails, string]> = [];
defaultPluginsIndex = getDefaultPluginsIndex();
const marketplacePlugins = store.getState().plugins.marketplacePlugins;
store.dispatch(
registerMarketplacePlugins(
selectCompatibleMarketplaceVersions(marketplacePlugins),
),
);
const uninstalledPluginNames =
store.getState().plugins.uninstalledPluginNames;
const bundledPlugins = getBundledPlugins();
const loadedPlugins = filterNewestVersionOfEachPlugin(
bundledPlugins,
await getDynamicPlugins(),
).filter((p) => !uninstalledPluginNames.has(p.name));
const initialPlugins: PluginDefinition[] = loadedPlugins
.map(reportVersion)
.filter(checkDisabled(disabledPlugins))
.filter(checkGK(gatekeepedPlugins))
.map(createRequirePluginFunction(failedPlugins))
.filter(notNull);
store.dispatch(registerBundledPlugins(bundledPlugins));
store.dispatch(registerLoadedPlugins(loadedPlugins));
store.dispatch(addGatekeepedPlugins(gatekeepedPlugins));
store.dispatch(addDisabledPlugins(disabledPlugins));
store.dispatch(addFailedPlugins(failedPlugins));
store.dispatch(registerPlugins(initialPlugins));
store.dispatch(pluginsInitialised());
sideEffect(
store,
{name: 'setupMenuBar', throttleMs: 1000, fireImmediately: true},
(state) => state.plugins,
(plugins, store) => {
setupMenuBar(
[...plugins.devicePlugins.values(), ...plugins.clientPlugins.values()],
store,
logger,
);
},
);
};
function reportVersion(pluginDetails: ActivatablePluginDetails) {
reportUsage(
'plugin:version',
{
version: pluginDetails.version,
},
pluginDetails.id,
);
return pluginDetails;
}
export function filterNewestVersionOfEachPlugin<
T1 extends PluginDetails,
T2 extends PluginDetails,
>(bundledPlugins: T1[], dynamicPlugins: T2[]): (T1 | T2)[] {
const pluginByName: {[key: string]: T1 | T2} = {};
for (const plugin of bundledPlugins) {
pluginByName[plugin.name] = plugin;
}
for (const plugin of dynamicPlugins) {
if (
!pluginByName[plugin.name] ||
(!process.env.FLIPPER_DISABLE_PLUGIN_AUTO_UPDATE &&
semver.gt(plugin.version, pluginByName[plugin.name].version, true))
) {
pluginByName[plugin.name] = plugin;
}
}
return Object.values(pluginByName);
}
function getBundledPlugins(): Array<BundledPluginDetails> {
// DefaultPlugins that are included in the bundle.
// List of defaultPlugins is written at build time
const pluginPath =
process.env.BUNDLED_PLUGIN_PATH ||
(isProduction()
? path.join(__dirname, 'defaultPlugins')
: './defaultPlugins/index.json');
let bundledPlugins: Array<BundledPluginDetails> = [];
try {
bundledPlugins = global.electronRequire(pluginPath);
} catch (e) {
console.error('Failed to load bundled plugins', e);
}
return bundledPlugins;
}
export async function getDynamicPlugins() {
try {
return await loadDynamicPlugins();
} catch (e) {
console.error('Failed to load dynamic plugins', e);
return [];
}
}
export const checkGK =
(gatekeepedPlugins: Array<ActivatablePluginDetails>) =>
(plugin: ActivatablePluginDetails): boolean => {
try {
if (!plugin.gatekeeper) {
return true;
}
const result = GK.get(plugin.gatekeeper);
if (!result) {
gatekeepedPlugins.push(plugin);
}
return result;
} catch (err) {
console.error(`Failed to check GK for plugin ${plugin.id}`, err);
return false;
}
};
export const checkDisabled = (
disabledPlugins: Array<ActivatablePluginDetails>,
) => {
let enabledList: Set<string> | null = null;
let disabledList: Set<string> = new Set();
try {
if (process.env.FLIPPER_ENABLED_PLUGINS) {
enabledList = new Set<string>(
process.env.FLIPPER_ENABLED_PLUGINS.split(','),
);
}
disabledList = config().disabledPlugins;
} catch (e) {
console.error('Failed to compute enabled/disabled plugins', e);
}
return (plugin: ActivatablePluginDetails): boolean => {
try {
if (disabledList.has(plugin.name)) {
disabledPlugins.push(plugin);
return false;
}
if (
enabledList &&
!(
enabledList.has(plugin.name) ||
enabledList.has(plugin.id) ||
enabledList.has(plugin.name.replace('flipper-plugin-', ''))
)
) {
disabledPlugins.push(plugin);
return false;
}
return true;
} catch (e) {
console.error(
`Failed to check whether plugin ${plugin.id} is disabled`,
e,
);
return false;
}
};
};
export const createRequirePluginFunction = (
failedPlugins: Array<[ActivatablePluginDetails, string]>,
reqFn: Function = global.electronRequire,
) => {
return (pluginDetails: ActivatablePluginDetails): PluginDefinition | null => {
try {
const pluginDefinition = requirePlugin(pluginDetails, reqFn);
if (
pluginDefinition &&
isDevicePluginDefinition(pluginDefinition) &&
pluginDefinition.details.pluginType !== 'device'
) {
console.warn(
`Package ${pluginDefinition.details.name} contains the device plugin "${pluginDefinition.title}" defined in a wrong format. Specify "pluginType" and "supportedDevices" properties and remove exported function "supportsDevice". See details at https://fbflipper.com/docs/extending/desktop-plugin-structure#creating-a-device-plugin.`,
);
}
return pluginDefinition;
} catch (e) {
failedPlugins.push([pluginDetails, e.message]);
console.error(`Plugin ${pluginDetails.id} failed to load`, e);
return null;
}
};
};
export const requirePlugin = (
pluginDetails: ActivatablePluginDetails,
reqFn: Function = global.electronRequire,
): PluginDefinition => {
reportUsage(
'plugin:load',
{
version: pluginDetails.version,
},
pluginDetails.id,
);
return tryCatchReportPluginFailures(
() => requirePluginInternal(pluginDetails, reqFn),
'plugin:load',
pluginDetails.id,
);
};
const requirePluginInternal = (
pluginDetails: ActivatablePluginDetails,
reqFn: Function = global.electronRequire,
): PluginDefinition => {
let plugin = pluginDetails.isBundled
? defaultPluginsIndex[pluginDetails.name]
: reqFn(pluginDetails.entry);
if (pluginDetails.flipperSDKVersion) {
// Sandy plugin
return new _SandyPluginDefinition(pluginDetails, plugin);
} else {
// classic plugin
if (plugin.default) {
plugin = plugin.default;
}
if (plugin.prototype === undefined) {
throw new Error(
`Plugin ${pluginDetails.name} is neither a class-based plugin nor a Sandy-based one.
Ensure that it exports either a FlipperPlugin class or has flipper-plugin declared as a peer-dependency and exports a plugin and Component.
See https://fbflipper.com/docs/extending/sandy-migration/ for more information.`,
);
} else if (!(plugin.prototype instanceof FlipperBasePlugin)) {
throw new Error(
`Plugin ${pluginDetails.name} is not a FlipperBasePlugin`,
);
}
if (plugin.id && pluginDetails.id !== plugin.id) {
console.error(
`Plugin name mismatch: Package '${pluginDetails.id}' exposed a plugin with id '${plugin.id}'. Please update the 'package.json' to match the exposed plugin id`,
);
}
plugin.id = plugin.id || pluginDetails.id;
plugin.packageName = pluginDetails.name;
plugin.details = pluginDetails;
// set values from package.json as static variables on class
Object.keys(pluginDetails).forEach((key) => {
if (key !== 'name' && key !== 'id') {
plugin[key] =
plugin[key] || pluginDetails[key as keyof ActivatablePluginDetails];
}
});
}
return plugin;
};
export function selectCompatibleMarketplaceVersions(
availablePlugins: MarketplacePluginDetails[],
): MarketplacePluginDetails[] {
const plugins: MarketplacePluginDetails[] = [];
for (const plugin of availablePlugins) {
if (!isPluginCompatible(plugin)) {
const compatibleVersion =
plugin.availableVersions?.find(isPluginCompatible) ??
plugin.availableVersions?.slice(-1).pop();
if (compatibleVersion) {
plugins.push({
...compatibleVersion,
availableVersions: plugin?.availableVersions,
});
} else {
plugins.push(plugin);
}
} else {
plugins.push(plugin);
}
}
return plugins;
}