Reload single plugin on auto-update

Summary: Implemented a way for re-loading single plugin on auto-update. This make it possible to apply update without full Flipper restart.

Reviewed By: mweststrate

Differential Revision: D23729972

fbshipit-source-id: ed30f7cde5a0537945db0b5bb6969ae8fde42cb6
This commit is contained in:
Anton Nikolaev
2020-09-28 02:50:10 -07:00
committed by Facebook GitHub Bot
parent 5e979403a0
commit 0982dc06a0
9 changed files with 204 additions and 47 deletions

View File

@@ -268,8 +268,8 @@ export default class Client extends EventEmitter {
});
}
supportsPlugin(Plugin: ClientPluginDefinition): boolean {
return this.plugins.includes(Plugin.id);
supportsPlugin(pluginId: string): boolean {
return this.plugins.includes(pluginId);
}
isBackgroundPlugin(pluginId: string) {

View File

@@ -13,7 +13,7 @@ import dispatcher, {
getDynamicPlugins,
checkDisabled,
checkGK,
requirePlugin,
createRequirePluginFunction,
filterNewestVersionOfEachPlugin,
} from '../plugins';
import {PluginDetails} from 'flipper-plugin-lib';
@@ -136,7 +136,7 @@ test('checkGK for failing plugin', () => {
});
test('requirePlugin returns null for invalid requires', () => {
const requireFn = requirePlugin([], {}, require);
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...samplePluginDetails,
name: 'pluginID',
@@ -149,7 +149,7 @@ test('requirePlugin returns null for invalid requires', () => {
test('requirePlugin loads plugin', () => {
const name = 'pluginID';
const requireFn = requirePlugin([], {}, require);
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...samplePluginDetails,
name,
@@ -236,7 +236,7 @@ test('bundled versions are used when env var FLIPPER_DISABLE_PLUGIN_AUTO_UPDATE
test('requirePlugin loads valid Sandy plugin', () => {
const name = 'pluginID';
const requireFn = requirePlugin([], {}, require);
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...samplePluginDetails,
name,
@@ -270,7 +270,7 @@ test('requirePlugin loads valid Sandy plugin', () => {
test('requirePlugin errors on invalid Sandy plugin', () => {
const name = 'pluginID';
const failedPlugins: any[] = [];
const requireFn = requirePlugin(failedPlugins, {}, require);
const requireFn = createRequirePluginFunction(failedPlugins, require);
requireFn({
...samplePluginDetails,
name,
@@ -286,7 +286,7 @@ test('requirePlugin errors on invalid Sandy plugin', () => {
test('requirePlugin loads valid Sandy Device plugin', () => {
const name = 'pluginID';
const requireFn = requirePlugin([], {}, require);
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...samplePluginDetails,
name,

View File

@@ -37,7 +37,9 @@ import loadDynamicPlugins from '../utils/loadDynamicPlugins';
import Immer from 'immer';
// eslint-disable-next-line import/no-unresolved
import getPluginIndex from '../utils/getDefaultPluginsIndex';
import getDefaultPluginsIndex from '../utils/getDefaultPluginsIndex';
let defaultPluginsIndex: any = null;
export default async (store: Store, logger: Logger) => {
// expose Flipper and exact globally for dynamically loaded plugins
@@ -53,7 +55,7 @@ export default async (store: Store, logger: Logger) => {
const disabledPlugins: Array<PluginDetails> = [];
const failedPlugins: Array<[PluginDetails, string]> = [];
const defaultPluginsIndex = getPluginIndex();
defaultPluginsIndex = getDefaultPluginsIndex();
const initialPlugins: PluginDefinition[] = filterNewestVersionOfEachPlugin(
getBundledPlugins(),
@@ -62,7 +64,7 @@ export default async (store: Store, logger: Logger) => {
.map(reportVersion)
.filter(checkDisabled(disabledPlugins))
.filter(checkGK(gatekeepedPlugins))
.map(requirePlugin(failedPlugins, defaultPluginsIndex))
.map(createRequirePluginFunction(failedPlugins))
.filter(notNull);
store.dispatch(addGatekeepedPlugins(gatekeepedPlugins));
@@ -173,18 +175,13 @@ export const checkDisabled = (disabledPlugins: Array<PluginDetails>) => (
return !disabledList.has(plugin.name);
};
export const requirePlugin = (
export const createRequirePluginFunction = (
failedPlugins: Array<[PluginDetails, string]>,
defaultPluginsIndex: any,
reqFn: Function = global.electronRequire,
) => {
return (pluginDetails: PluginDetails): PluginDefinition | null => {
try {
return tryCatchReportPluginFailures(
() => requirePluginInternal(pluginDetails, defaultPluginsIndex, reqFn),
'plugin:load',
pluginDetails.id,
);
return requirePlugin(pluginDetails, reqFn);
} catch (e) {
failedPlugins.push([pluginDetails, e.message]);
console.error(`Plugin ${pluginDetails.id} failed to load`, e);
@@ -193,15 +190,24 @@ export const requirePlugin = (
};
};
export const requirePlugin = (
pluginDetails: PluginDetails,
reqFn: Function = global.electronRequire,
): PluginDefinition => {
return tryCatchReportPluginFailures(
() => requirePluginInternal(pluginDetails, reqFn),
'plugin:load',
pluginDetails.id,
);
};
const requirePluginInternal = (
pluginDetails: PluginDetails,
defaultPluginsIndex: any,
reqFn: Function = global.electronRequire,
) => {
): PluginDefinition => {
let plugin = pluginDetails.isDefault
? defaultPluginsIndex[pluginDetails.name]
: reqFn(pluginDetails.entry);
if (pluginDetails.flipperSDKVersion) {
// Sandy plugin
return new SandyPluginDefinition(pluginDetails, plugin);

View File

@@ -7,7 +7,7 @@
* @format
*/
import {State, addNotification} from '../notifications';
import {State, addNotification, removeNotification} from '../notifications';
import {
default as reducer,
@@ -108,6 +108,56 @@ test('reduce setActiveNotifications', () => {
});
test('addNotification removes duplicates', () => {
let res = reducer(
getInitialState(),
addNotification({
pluginId: 'test',
client: null,
notification,
}),
);
res = reducer(
res,
addNotification({
pluginId: 'test',
client: null,
notification: {
...notification,
id: 'otherId',
},
}),
);
res = reducer(
res,
removeNotification({
pluginId: 'test',
client: null,
notificationId: 'id',
}),
);
expect(res).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [
Object {
"client": null,
"notification": Object {
"id": "otherId",
"message": "message",
"severity": "warning",
"title": "title",
},
"pluginId": "test",
},
],
"blacklistedCategories": Array [],
"blacklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});
test('reduce removeNotification', () => {
let res = reducer(
getInitialState(),
addNotification({

View File

@@ -125,7 +125,12 @@ export type Action =
type: 'SELECT_CLIENT';
payload: string;
}
| RegisterPluginAction;
| RegisterPluginAction
| {
// Implemented by rootReducer in `store.tsx`
type: 'UPDATE_PLUGIN';
payload: PluginDefinition;
};
const DEFAULT_PLUGIN = 'DeviceLogs';
const DEFAULT_DEVICE_BLACKLIST = [MacDevice];
@@ -341,7 +346,6 @@ export default (state: State = INITAL_STATE, action: Actions): State => {
});
return state;
}
default:
return state;
}
@@ -395,6 +399,11 @@ export const selectClient = (clientId: string): Action => ({
payload: clientId,
});
export const registerPluginUpdate = (payload: PluginDefinition): Action => ({
type: 'UPDATE_PLUGIN',
payload,
});
export function getAvailableClients(
device: null | undefined | BaseDevice,
clients: Client[],

View File

@@ -16,6 +16,12 @@ export type PluginNotification = {
client: null | string;
};
export type PluginNotificationReference = {
notificationId: string;
pluginId: string;
client: null | string;
};
export type State = {
activeNotifications: Array<PluginNotification>;
invalidatedNotifications: Array<PluginNotification>;
@@ -56,6 +62,10 @@ export type Action =
| {
type: 'ADD_NOTIFICATION';
payload: PluginNotification;
}
| {
type: 'REMOVE_NOTIFICATION';
payload: PluginNotificationReference;
};
const INITIAL_STATE: State = {
@@ -113,6 +123,18 @@ export default function reducer(
action.payload,
],
};
case 'REMOVE_NOTIFICATION':
return {
...state,
activeNotifications: [
...state.activeNotifications.filter(
(notif) =>
notif.client !== action.payload.client ||
notif.pluginId !== action.payload.pluginId ||
notif.notification.id !== action.payload.notificationId,
),
],
};
default:
return state;
}
@@ -166,6 +188,15 @@ export function addNotification(payload: PluginNotification): Action {
};
}
export function removeNotification(
payload: PluginNotificationReference,
): Action {
return {
type: 'REMOVE_NOTIFICATION',
payload,
};
}
export function addErrorNotification(
title: string,
message: string,

View File

@@ -15,7 +15,11 @@ import produce from 'immer';
import {
defaultEnabledBackgroundPlugins,
getPluginKey,
isDevicePluginDefinition,
} from './utils/pluginUtils';
import Client from './Client';
import {PluginDefinition} from './plugin';
import {deconstructPluginKey} from './utils/clientUtils';
export const store: Store = createStore<StoreState, Actions, any, any>(
rootReducer,
@@ -48,35 +52,59 @@ export function rootReducer(
plugins.push(selectedPlugin);
// enabling a plugin on one device enables it on all...
clients.forEach((client) => {
// sandy plugin? initialize it
client.startPluginIfNeeded(plugin, true);
// background plugin? connect it needed
if (
!defaultEnabledBackgroundPlugins.includes(selectedPlugin) &&
client?.isBackgroundPlugin(selectedPlugin)
) {
client.initPlugin(selectedPlugin);
}
startPlugin(client, plugin);
});
} else {
plugins.splice(idx, 1);
// enabling a plugin on one device disables it on all...
clients.forEach((client) => {
// disconnect background plugins
if (
!defaultEnabledBackgroundPlugins.includes(selectedPlugin) &&
client?.isBackgroundPlugin(selectedPlugin)
) {
client.deinitPlugin(selectedPlugin);
}
// stop sandy plugins
client.stopPluginIfNeeded(plugin.id);
delete draft.pluginMessageQueue[
getPluginKey(client.id, {serial: client.query.device_id}, plugin.id)
];
stopPlugin(client, plugin.id);
const pluginKey = getPluginKey(
client.id,
{serial: client.query.device_id},
plugin.id,
);
delete draft.pluginMessageQueue[pluginKey];
});
}
});
} else if (action.type === 'UPDATE_PLUGIN' && state) {
const plugin: PluginDefinition = action.payload;
const clients = state.connections.clients;
return produce(state, (draft) => {
const clientsWithEnabledPlugin = clients.filter((c) => {
return (
c.supportsPlugin(plugin.id) &&
state.connections.userStarredPlugins[c.query.app]?.includes(plugin.id)
);
});
// stop plugin for each client where it is enabled
clientsWithEnabledPlugin.forEach((client) => {
stopPlugin(client, plugin.id, true);
delete draft.pluginMessageQueue[
getPluginKey(client.id, {serial: client.query.device_id}, plugin.id)
];
});
// cleanup classic plugin state
Object.keys(draft.pluginStates).forEach((pluginKey) => {
const pluginKeyParts = deconstructPluginKey(pluginKey);
if (pluginKeyParts.pluginName === plugin.id) {
delete draft.pluginStates[pluginKey];
}
});
// update plugin definition
const {devicePlugins, clientPlugins} = draft.plugins;
const p = action.payload;
if (isDevicePluginDefinition(p)) {
devicePlugins.set(p.id, p);
} else {
clientPlugins.set(p.id, p);
}
// start plugin for each client
clientsWithEnabledPlugin.forEach((client) => {
startPlugin(client, plugin, true);
});
});
}
// otherwise
@@ -88,3 +116,36 @@ if (!isProduction()) {
// @ts-ignore
window.flipperStore = store;
}
function stopPlugin(
client: Client,
pluginId: string,
forceInitBackgroundPlugin: boolean = false,
): boolean {
if (
(forceInitBackgroundPlugin ||
!defaultEnabledBackgroundPlugins.includes(pluginId)) &&
client?.isBackgroundPlugin(pluginId)
) {
client.deinitPlugin(pluginId);
}
// stop sandy plugins
client.stopPluginIfNeeded(pluginId);
return true;
}
function startPlugin(
client: Client,
plugin: PluginDefinition,
forceInitBackgroundPlugin: boolean = false,
) {
client.startPluginIfNeeded(plugin, true);
// background plugin? connect it needed
if (
(forceInitBackgroundPlugin ||
!defaultEnabledBackgroundPlugins.includes(plugin.id)) &&
client?.isBackgroundPlugin(plugin.id)
) {
client.initPlugin(plugin.id);
}
}

View File

@@ -28,7 +28,7 @@ export type NpmHostedPluginsSearchArgs = {
export async function getNpmHostedPlugins(
args: NpmHostedPluginsSearchArgs = {},
): Promise<NpmPackageDescriptor[]> {
): Promise<readonly NpmPackageDescriptor[]> {
const index = provideSearchIndex();
args = Object.assign(
{

View File

@@ -93,7 +93,7 @@ async function installPluginFromTempDir(
}
throw err;
}
return pluginDetails;
return await getPluginDetails(destinationDir);
}
async function getPluginRootDir(dir: string) {