Unify computation of available plugins

Summary:
While trying to change something, discovered we have 3 different mechanisms in our code base to compute active plugins; the plugin list component, support form, and export flipper trace form had all their own, subtly different implementations of computing which plugins are available to the user.

Also removed some hardcoded exceptions for e.g. Logs plugin, which in the next diff and onward will be just a vanilla plugin without special casing

Unified that, which some how went a bit deeper than hoped, trough some hoops in in circular deps. Also unified to use the same testing utils, to avoid some gobbling objects manually together, with resulted in a bunch of unexpected NPEs. Found out that we actually still have unit tests using Flow in the process :-P. Converted one to TS.

Reviewed By: nikoant

Differential Revision: D26103172

fbshipit-source-id: 2fce2577d97d98543cb9312b3d013f24faee43aa
This commit is contained in:
Michel Weststrate
2021-02-01 11:40:20 -08:00
committed by Facebook GitHub Bot
parent 5320015776
commit e1daa449ba
17 changed files with 556 additions and 792 deletions

View File

@@ -13,14 +13,20 @@ import {
PluginDefinition,
DevicePluginDefinition,
isSandyPlugin,
ClientPluginDefinition,
} from '../plugin';
import {State as PluginStatesState} from '../reducers/pluginStates';
import {State as PluginsState} from '../reducers/plugins';
import {State as PluginMessageQueueState} from '../reducers/pluginMessageQueue';
import {deconstructPluginKey, deconstructClientId} from './clientUtils';
import type {State} from '../reducers';
import type {State as PluginStatesState} from '../reducers/pluginStates';
import type {State as PluginsState} from '../reducers/plugins';
import {_SandyPluginDefinition} from 'flipper-plugin';
type Client = import('../Client').default;
import type BaseDevice from '../devices/BaseDevice';
import type Client from '../Client';
import type {
BundledPluginDetails,
DownloadablePluginDetails,
PluginDetails,
} from 'flipper-plugin-lib';
import {filterNewestVersionOfEachPlugin} from '../dispatcher/plugins';
export const defaultEnabledBackgroundPlugins = ['Navigation']; // The navigation plugin is enabled always, to make sure the navigation features works
@@ -34,8 +40,8 @@ export function pluginsClassMap(
}
export function getPluginKey(
selectedAppId: string | null,
baseDevice: {serial: string} | null,
selectedAppId: string | null | undefined,
baseDevice: {serial: string} | null | undefined,
pluginID: string,
): string {
if (selectedAppId) {
@@ -64,153 +70,71 @@ export function getPersistedState<PersistedState>(
return persistedState;
}
/**
*
* @param starredPlugin starredPlugin is the dictionary of client and its enabled plugin
* @param client Optional paramater indicating the selected client.
* @param plugins Plugins from the state which has the mapping to Plugin's Class.
* Returns plugins which are enabled or which has exportPersistedState function defined for the passed client.
* Note all device plugins are enabled.
*/
export function getEnabledOrExportPersistedStatePlugins(
starredPlugin: {
[client: string]: string[];
},
client: Client,
plugins: PluginsState,
): Array<{id: string; label: string}> {
const appName = deconstructClientId(client.id).app;
const pluginsMap: Map<string, PluginDefinition> = pluginsClassMap(plugins);
// Enabled Plugins with no exportPersistedState function defined
const enabledPlugins = starredPlugin[appName]
? starredPlugin[appName]
.map((pluginName) => pluginsMap.get(pluginName)!)
.filter(Boolean)
.filter((plugin) => {
return !plugin.exportPersistedState;
})
.sort(sortPluginsByName)
.map((plugin) => {
return {id: plugin.id, label: getPluginTitle(plugin)};
})
: [];
// Device Plugins
const devicePlugins = Array.from(plugins.devicePlugins.keys())
.filter((plugin) => {
return client.plugins.includes(plugin);
})
.map((plugin) => {
return {
id: plugin,
label: getPluginTitle(plugins.devicePlugins.get(plugin)!),
};
})
.filter(Boolean);
// Plugins which have defined exportPersistedState.
const exportPersistedStatePlugins = client.plugins
.filter((name) => {
return pluginsMap.get(name)?.exportPersistedState != null;
})
.map((name) => {
const plugin = pluginsMap.get(name)!;
return {id: plugin.id, label: getPluginTitle(plugin)};
});
return [
...devicePlugins,
...enabledPlugins,
...exportPersistedStatePlugins,
{id: 'DeviceLogs', label: 'Logs'},
];
}
/**
*
* @param pluginsState PluginsState of the Redux Store.
* @param plugins Plugins from the state which has the mapping to Plugin's Class.
* @param selectedClient Optional paramater indicating the selected client.
* Returns active persistent plugin, which means plugins which has the data in redux store or has the `exportPersistedState` function defined which can return the plugin's data when called.
* If the selectedClient is defined then the active persistent plugins only for the selectedClient will be returned, otherwise it will return all active persistent plugins.
*/
export function getActivePersistentPlugins(
pluginsState: PluginStatesState,
pluginsMessageQueue: PluginMessageQueueState,
plugins: PluginsState,
selectedClient?: Client,
export function getExportablePlugins(
state: Pick<
State,
'plugins' | 'connections' | 'pluginStates' | 'pluginMessageQueue'
>,
device: BaseDevice | undefined | null,
client?: Client,
): {id: string; label: string}[] {
const pluginsMap = pluginsClassMap(plugins);
return getPersistentPlugins(plugins)
.map((pluginName) => pluginsMap.get(pluginName)!)
.sort(sortPluginsByName)
.filter((plugin) => {
if (plugin.id == 'DeviceLogs') {
return true;
}
if (selectedClient) {
const pluginKey = getPluginKey(
selectedClient.id,
{serial: selectedClient.query.device_id},
plugin.id,
);
// If there is a selected client, active persistent plugins are those that (can) have persisted state
return (
selectedClient.isEnabledPlugin(plugin.id) &&
// this plugin can fetch and export state
(plugin.exportPersistedState ||
// this plugin has some persisted state already
pluginsState[pluginKey] ||
pluginsMessageQueue[pluginKey] ||
// this plugin has some persistable sandy state
selectedClient.sandyPluginStates.get(plugin.id)?.isPersistable())
);
}
{
// If there is no selected client, active persistent plugin is the plugin which is just persistent.
const pluginsWithReduxData = [
...new Set([
...Object.keys(pluginsState),
...Object.keys(pluginsMessageQueue),
]),
].map((key) => deconstructPluginKey(key).pluginName);
return (
(plugin && plugin.exportPersistedState != undefined) ||
isSandyPlugin(plugin) ||
pluginsWithReduxData.includes(plugin.id)
);
}
})
.map((plugin) => ({
id: plugin.id,
label: getPluginTitle(plugin),
}));
const availablePlugins = computePluginLists(
device ?? undefined,
undefined,
client,
state.plugins,
state.connections.userStarredPlugins,
);
return [
...availablePlugins.devicePlugins.filter((plugin) => {
return isExportablePlugin(state, device, client, plugin);
}),
...availablePlugins.enabledPlugins.filter((plugin) => {
return isExportablePlugin(state, device, client, plugin);
}),
].map((p) => ({
id: p.id,
label: getPluginTitle(p),
}));
}
/**
* Returns all enabled plugins that are potentially exportable
* @param plugins
*/
export function getPersistentPlugins(plugins: PluginsState): Array<string> {
const pluginsMap = pluginsClassMap(plugins);
[...plugins.disabledPlugins, ...plugins.gatekeepedPlugins].forEach(
(plugin) => {
pluginsMap.delete(plugin.name);
},
);
plugins.failedPlugins.forEach(([details]) => {
pluginsMap.delete(details.id);
});
return Array.from(pluginsMap.keys()).filter((plugin) => {
const pluginClass = pluginsMap.get(plugin);
return (
plugin == 'DeviceLogs' ||
isSandyPlugin(pluginClass) ||
pluginClass?.defaultPersistedState ||
pluginClass?.exportPersistedState
);
});
function isExportablePlugin(
{
pluginStates,
pluginMessageQueue,
}: Pick<State, 'pluginStates' | 'pluginMessageQueue'>,
device: BaseDevice | undefined | null,
client: Client | undefined,
plugin: PluginDefinition,
): boolean {
// can generate an export when requested
if (plugin.exportPersistedState) {
return true;
}
const pluginKey = isDevicePluginDefinition(plugin)
? getPluginKey(undefined, device, plugin.id)
: getPluginKey(client?.id, undefined, plugin.id);
// plugin has exportable redux state
if (pluginStates[pluginKey]) {
return true;
}
// plugin has exportable sandy state
if (client?.sandyPluginStates.get(plugin.id)?.isPersistable()) {
return true;
}
if (device?.sandyPluginStates.get(plugin.id)?.isPersistable()) {
return true;
}
// plugin has pending messages and a persisted state reducer or isSandy
if (
pluginMessageQueue[pluginKey] &&
((plugin as any).defaultPersistedState || isSandyPlugin(plugin))
) {
return true;
}
// nothing to serialize
return false;
}
export function getPluginTitle(pluginClass: {
@@ -242,3 +166,154 @@ export function isDevicePluginDefinition(
(definition instanceof _SandyPluginDefinition && definition.isDevicePlugin)
);
}
export function getPluginTooltip(details: PluginDetails): string {
return `${getPluginTitle(details)} (${details.id}@${details.version}) ${
details.description ?? ''
}`;
}
export function computePluginLists(
device: BaseDevice | undefined,
metroDevice: BaseDevice | undefined,
client: Client | undefined,
plugins: State['plugins'],
userStarredPlugins: State['connections']['userStarredPlugins'],
_pluginsChanged?: number, // this argument is purely used to invalidate the memoization cache
) {
const devicePlugins: DevicePluginDefinition[] =
device?.devicePlugins.map((name) => plugins.devicePlugins.get(name)!) ?? [];
const metroPlugins: DevicePluginDefinition[] =
metroDevice?.devicePlugins.map(
(name) => plugins.devicePlugins.get(name)!,
) ?? [];
const enabledPlugins: ClientPluginDefinition[] = [];
const disabledPlugins: ClientPluginDefinition[] = [];
const unavailablePlugins: [plugin: PluginDetails, reason: string][] = [];
const downloadablePlugins: (
| DownloadablePluginDetails
| BundledPluginDetails
)[] = [];
if (device) {
// find all device plugins that aren't part of the current device / metro
const detectedDevicePlugins = new Set([
...device.devicePlugins,
...(metroDevice?.devicePlugins ?? []),
]);
for (const [name, definition] of plugins.devicePlugins.entries()) {
if (!detectedDevicePlugins.has(name)) {
unavailablePlugins.push([
definition.details,
`Device plugin '${getPluginTitle(
definition.details,
)}' is not supported by the current device type.`,
]);
}
}
}
// process problematic plugins
plugins.disabledPlugins.forEach((plugin) => {
unavailablePlugins.push([plugin, 'Plugin is disabled by configuration']);
});
plugins.gatekeepedPlugins.forEach((plugin) => {
unavailablePlugins.push([
plugin,
`This plugin is only available to members of gatekeeper '${plugin.gatekeeper}'`,
]);
});
plugins.failedPlugins.forEach(([plugin, error]) => {
unavailablePlugins.push([
plugin,
`Flipper failed to load this plugin: '${error}'`,
]);
});
// process all client plugins
if (device && client) {
const clientPlugins = Array.from(plugins.clientPlugins.values()).sort(
sortPluginsByName,
);
const favoritePlugins = getFavoritePlugins(
device,
client,
clientPlugins,
client && userStarredPlugins[client.query.app],
true,
);
clientPlugins.forEach((plugin) => {
if (!client.supportsPlugin(plugin.id)) {
unavailablePlugins.push([
plugin.details,
`Plugin '${getPluginTitle(
plugin.details,
)}' is installed in Flipper, but not supported by the client application`,
]);
} else if (favoritePlugins.includes(plugin)) {
enabledPlugins.push(plugin);
} else {
disabledPlugins.push(plugin);
}
});
const uninstalledMarketplacePlugins = filterNewestVersionOfEachPlugin(
[...plugins.bundledPlugins.values()],
plugins.marketplacePlugins,
).filter((p) => !plugins.loadedPlugins.has(p.id));
uninstalledMarketplacePlugins.forEach((plugin) => {
if (client.supportsPlugin(plugin.id)) {
downloadablePlugins.push(plugin);
} else {
unavailablePlugins.push([
plugin,
`Plugin '${getPluginTitle(
plugin,
)}' is not installed in Flipper and not supported by the client application`,
]);
}
});
}
devicePlugins.sort(sortPluginsByName);
metroPlugins.sort(sortPluginsByName);
unavailablePlugins.sort(([a], [b]) => {
return getPluginTitle(a) > getPluginTitle(b) ? 1 : -1;
});
downloadablePlugins.sort((a, b) => {
return getPluginTitle(a) > getPluginTitle(b) ? 1 : -1;
});
return {
devicePlugins,
metroPlugins,
enabledPlugins,
disabledPlugins,
unavailablePlugins,
downloadablePlugins,
};
}
function getFavoritePlugins(
device: BaseDevice,
client: Client,
allPlugins: PluginDefinition[],
starredPlugins: undefined | string[],
returnFavoredPlugins: boolean, // if false, unfavoried plugins are returned
): PluginDefinition[] {
if (device.isArchived) {
if (!returnFavoredPlugins) {
return [];
}
// for archived plugins, all stored plugins are enabled
return allPlugins.filter(
(plugin) => client.plugins.indexOf(plugin.id) !== -1,
);
}
if (!starredPlugins || !starredPlugins.length) {
return returnFavoredPlugins ? [] : allPlugins;
}
return allPlugins.filter((plugin) => {
const idx = starredPlugins.indexOf(plugin.id);
return idx === -1 ? !returnFavoredPlugins : returnFavoredPlugins;
});
}