/** * 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, ActivatablePluginDetails} from 'flipper-common'; import { LoadPluginActionPayload, UninstallPluginActionPayload, UpdatePluginActionPayload, pluginCommandsProcessed, SwitchPluginActionPayload, PluginCommand, } from '../reducers/pluginManager'; import {sideEffect} from '../utils/sideEffect'; import {requirePlugin} from './plugins'; import {showErrorNotification} from '../utils/notifications'; import {PluginDefinition} from '../plugin'; import type Client from '../Client'; import { pluginLoaded, pluginUninstalled, registerInstalledPlugins, } from '../reducers/plugins'; import {_SandyPluginDefinition} from 'flipper-plugin'; import { setDevicePluginEnabled, setDevicePluginDisabled, setPluginEnabled, setPluginDisabled, getClientsByAppName, getAllClients, } from '../reducers/connections'; import {deconstructClientId} from 'flipper-common'; import {clearMessageQueue} from '../reducers/pluginMessageQueue'; import { isDevicePluginDefinition, defaultEnabledBackgroundPlugins, } from '../utils/pluginUtils'; import {getPluginKey} from '../utils/pluginKey'; import {getRenderHostInstance} from '../RenderHost'; async function refreshInstalledPlugins(store: Store) { const flipperServer = getRenderHostInstance().flipperServer; if (!flipperServer) { throw new Error('Flipper Server not ready'); } await flipperServer.exec( 'plugins-remove-plugins', Array.from(store.getState().plugins.uninstalledPluginNames.values()), ); const plugins = await flipperServer.exec('plugins-get-installed-plugins'); return store.dispatch(registerInstalledPlugins(plugins)); } export default ( store: Store, _logger: Logger, {runSideEffectsSynchronously}: {runSideEffectsSynchronously: boolean} = { runSideEffectsSynchronously: false, }, ) => { // This needn't happen immediately and is (light) I/O work. if (window.requestIdleCallback) { window.requestIdleCallback(() => { refreshInstalledPlugins(store).catch((err) => console.error('Failed to refresh installed plugins:', err), ); }); } let running = false; const unsubscribeHandlePluginCommands = sideEffect( store, { name: 'handlePluginCommands', throttleMs: 0, fireImmediately: true, runSynchronously: runSideEffectsSynchronously, // Used to simplify writing tests, if "true" passed, the all side effects will be called synchronously and immediately after changes noTimeBudgetWarns: true, // These side effects are critical, so we're doing them with zero throttling and want to avoid unnecessary warns }, (state) => state.pluginManager.pluginCommandsQueue, async (_queue: PluginCommand[], store: Store) => { // To make sure all commands are running in order, and not kicking off parallel command // processing when new commands arrive (sideEffect doesn't await) // we keep the 'running' flag, and keep running in a loop until the commandQueue is empty, // to make sure any commands that have arrived during execution are executed if (running) { return; // will be picked up in while(true) loop } running = true; try { while (true) { const remaining = store.getState().pluginManager.pluginCommandsQueue; if (!remaining.length) { return; // done } await processPluginCommandsQueue(remaining, store); store.dispatch(pluginCommandsProcessed(remaining.length)); } } finally { running = false; } }, ); return async () => { unsubscribeHandlePluginCommands(); }; }; export async function awaitPluginCommandQueueEmpty(store: Store) { if (store.getState().pluginManager.pluginCommandsQueue.length === 0) { return; } return new Promise((resolve) => { const unsubscribe = store.subscribe(() => { if (store.getState().pluginManager.pluginCommandsQueue.length === 0) { unsubscribe(); resolve(); } }); }); } async function processPluginCommandsQueue( queue: PluginCommand[], store: Store, ) { for (const command of queue) { try { switch (command.type) { case 'LOAD_PLUGIN': await loadPlugin(store, command.payload); break; case 'UNINSTALL_PLUGIN': uninstallPlugin(store, command.payload); break; case 'UPDATE_PLUGIN': updatePlugin(store, command.payload); break; case 'SWITCH_PLUGIN': switchPlugin(store, command.payload); break; default: console.error('Unexpected plugin command', command); break; } } catch (e) { // make sure that upon failure the command is still marked processed to avoid // unending loops! console.error('Failed to process command', command); } } } async function loadPlugin(store: Store, payload: LoadPluginActionPayload) { try { const plugin = await requirePlugin(payload.plugin); const enablePlugin = payload.enable; updatePlugin(store, {plugin, enablePlugin}); } catch (err) { console.error( `Failed to load plugin ${payload.plugin.title} v${payload.plugin.version}`, err, ); if (payload.notifyIfFailed) { showErrorNotification( `Failed to load plugin "${payload.plugin.title}" v${payload.plugin.version}`, ); } } } function uninstallPlugin(store: Store, {plugin}: UninstallPluginActionPayload) { try { const state = store.getState(); const clients = state.connections.clients; clients.forEach((client) => { stopPlugin(client, plugin.id); }); if (!plugin.details.isBundled) { unloadPluginModule(plugin.details); } store.dispatch(pluginUninstalled(plugin.details)); } catch (err) { console.error( `Failed to uninstall plugin ${plugin.title} v${plugin.version}`, err, ); showErrorNotification( `Failed to uninstall plugin "${plugin.title}" v${plugin.version}`, ); } } function updatePlugin(store: Store, payload: UpdatePluginActionPayload) { const {plugin, enablePlugin} = payload; if (isDevicePluginDefinition(plugin)) { return updateDevicePlugin(store, plugin, enablePlugin); } else { return updateClientPlugin(store, plugin, enablePlugin); } } function getSelectedAppName(store: Store) { const {connections} = store.getState(); const selectedAppId = connections.selectedAppId ? deconstructClientId(connections.selectedAppId).app : undefined; return selectedAppId; } function switchPlugin( store: Store, {plugin, selectedApp}: SwitchPluginActionPayload, ) { if (isDevicePluginDefinition(plugin)) { switchDevicePlugin(store, plugin); } else { switchClientPlugin(store, plugin, selectedApp); } } function switchClientPlugin( store: Store, plugin: PluginDefinition, selectedApp: string | undefined, ) { selectedApp = selectedApp ?? getSelectedAppName(store); if (!selectedApp) { return; } const {connections} = store.getState(); const clients = getClientsByAppName(connections.clients, selectedApp); if (connections.enabledPlugins[selectedApp]?.includes(plugin.id)) { clients.forEach((client) => { stopPlugin(client, plugin.id); const pluginKey = getPluginKey( client.id, {serial: client.query.device_id}, plugin.id, ); store.dispatch(clearMessageQueue(pluginKey)); }); store.dispatch(setPluginDisabled(plugin.id, selectedApp)); } else { clients.forEach((client) => { startPlugin(client, plugin); }); store.dispatch(setPluginEnabled(plugin.id, selectedApp)); } } function switchDevicePlugin(store: Store, plugin: PluginDefinition) { const {connections} = store.getState(); const devicesWithPlugin = connections.devices.filter((d) => d.supportsPlugin(plugin.details), ); if (connections.enabledDevicePlugins.has(plugin.id)) { devicesWithPlugin.forEach((d) => { d.unloadDevicePlugin(plugin.id); }); store.dispatch(setDevicePluginDisabled(plugin.id)); } else { devicesWithPlugin.forEach((d) => { d.loadDevicePlugin(plugin); }); store.dispatch(setDevicePluginEnabled(plugin.id)); } } function updateClientPlugin( store: Store, plugin: PluginDefinition, enable: boolean, ) { const clients = getAllClients(store.getState().connections); if (enable) { const selectedApp = getSelectedAppName(store); if (selectedApp) { store.dispatch(setPluginEnabled(plugin.id, selectedApp)); } } const clientsWithEnabledPlugin = clients.filter((c) => { return ( c.supportsPlugin(plugin.id) && store .getState() .connections.enabledPlugins[c.query.app]?.includes(plugin.id) ); }); const previousVersion = store.getState().plugins.clientPlugins.get(plugin.id); clientsWithEnabledPlugin.forEach((client) => { stopPlugin(client, plugin.id); }); clientsWithEnabledPlugin.forEach((client) => { startPlugin(client, plugin, true); }); store.dispatch(pluginLoaded(plugin)); if (previousVersion) { // unload previous version from Electron cache unloadPluginModule(previousVersion.details); } } function updateDevicePlugin( store: Store, plugin: PluginDefinition, enable: boolean, ) { if (enable) { store.dispatch(setDevicePluginEnabled(plugin.id)); } const connections = store.getState().connections; const devicesWithEnabledPlugin = connections.devices.filter((d) => d.supportsPlugin(plugin), ); devicesWithEnabledPlugin.forEach((d) => { d.unloadDevicePlugin(plugin.id); }); const previousVersion = store.getState().plugins.devicePlugins.get(plugin.id); if (previousVersion) { // unload previous version from Electron cache unloadPluginModule(previousVersion.details); } store.dispatch(pluginLoaded(plugin)); devicesWithEnabledPlugin.forEach((d) => { d.loadDevicePlugin(plugin); }); } 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); } } 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 unloadPluginModule(plugin: ActivatablePluginDetails) { if (plugin.isBundled) { // We cannot unload bundled plugin. return; } getRenderHostInstance().unloadModule?.(plugin.entry); }