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 { supportsPlugin(pluginId: string): boolean {
return this.plugins.includes(Plugin.id); return this.plugins.includes(pluginId);
} }
isBackgroundPlugin(pluginId: string) { isBackgroundPlugin(pluginId: string) {

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
* @format * @format
*/ */
import {State, addNotification} from '../notifications'; import {State, addNotification, removeNotification} from '../notifications';
import { import {
default as reducer, default as reducer,
@@ -108,6 +108,56 @@ test('reduce setActiveNotifications', () => {
}); });
test('addNotification removes duplicates', () => { 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( let res = reducer(
getInitialState(), getInitialState(),
addNotification({ addNotification({

View File

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

View File

@@ -16,6 +16,12 @@ export type PluginNotification = {
client: null | string; client: null | string;
}; };
export type PluginNotificationReference = {
notificationId: string;
pluginId: string;
client: null | string;
};
export type State = { export type State = {
activeNotifications: Array<PluginNotification>; activeNotifications: Array<PluginNotification>;
invalidatedNotifications: Array<PluginNotification>; invalidatedNotifications: Array<PluginNotification>;
@@ -56,6 +62,10 @@ export type Action =
| { | {
type: 'ADD_NOTIFICATION'; type: 'ADD_NOTIFICATION';
payload: PluginNotification; payload: PluginNotification;
}
| {
type: 'REMOVE_NOTIFICATION';
payload: PluginNotificationReference;
}; };
const INITIAL_STATE: State = { const INITIAL_STATE: State = {
@@ -113,6 +123,18 @@ export default function reducer(
action.payload, 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: default:
return state; 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( export function addErrorNotification(
title: string, title: string,
message: string, message: string,

View File

@@ -15,7 +15,11 @@ import produce from 'immer';
import { import {
defaultEnabledBackgroundPlugins, defaultEnabledBackgroundPlugins,
getPluginKey, getPluginKey,
isDevicePluginDefinition,
} from './utils/pluginUtils'; } 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>( export const store: Store = createStore<StoreState, Actions, any, any>(
rootReducer, rootReducer,
@@ -48,35 +52,59 @@ export function rootReducer(
plugins.push(selectedPlugin); plugins.push(selectedPlugin);
// enabling a plugin on one device enables it on all... // enabling a plugin on one device enables it on all...
clients.forEach((client) => { clients.forEach((client) => {
// sandy plugin? initialize it startPlugin(client, plugin);
client.startPluginIfNeeded(plugin, true);
// background plugin? connect it needed
if (
!defaultEnabledBackgroundPlugins.includes(selectedPlugin) &&
client?.isBackgroundPlugin(selectedPlugin)
) {
client.initPlugin(selectedPlugin);
}
}); });
} else { } else {
plugins.splice(idx, 1); plugins.splice(idx, 1);
// enabling a plugin on one device disables it on all... // enabling a plugin on one device disables it on all...
clients.forEach((client) => { clients.forEach((client) => {
// disconnect background plugins stopPlugin(client, plugin.id);
if ( const pluginKey = getPluginKey(
!defaultEnabledBackgroundPlugins.includes(selectedPlugin) && client.id,
client?.isBackgroundPlugin(selectedPlugin) {serial: client.query.device_id},
) { plugin.id,
client.deinitPlugin(selectedPlugin); );
} delete draft.pluginMessageQueue[pluginKey];
// stop sandy plugins
client.stopPluginIfNeeded(plugin.id);
delete draft.pluginMessageQueue[
getPluginKey(client.id, {serial: client.query.device_id}, plugin.id)
];
}); });
} }
}); });
} 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 // otherwise
@@ -88,3 +116,36 @@ if (!isProduction()) {
// @ts-ignore // @ts-ignore
window.flipperStore = store; 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( export async function getNpmHostedPlugins(
args: NpmHostedPluginsSearchArgs = {}, args: NpmHostedPluginsSearchArgs = {},
): Promise<NpmPackageDescriptor[]> { ): Promise<readonly NpmPackageDescriptor[]> {
const index = provideSearchIndex(); const index = provideSearchIndex();
args = Object.assign( args = Object.assign(
{ {

View File

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