diff --git a/desktop/flipper-ui-core/src/dispatcher/handleOpenPluginDeeplink.tsx b/desktop/flipper-ui-core/src/dispatcher/handleOpenPluginDeeplink.tsx index b327ae598..219d0c586 100644 --- a/desktop/flipper-ui-core/src/dispatcher/handleOpenPluginDeeplink.tsx +++ b/desktop/flipper-ui-core/src/dispatcher/handleOpenPluginDeeplink.tsx @@ -23,7 +23,7 @@ import { import {getUpdateAvailableMessage} from '../chrome/UpdateIndicator'; import {Typography} from 'antd'; import {getPluginStatus, PluginStatus} from '../utils/pluginUtils'; -import {loadPluginsFromMarketplace} from './fb-stubs/pluginMarketplace'; +import {loadPluginsFromMarketplace} from './pluginMarketplace'; import {loadPlugin, switchPlugin} from '../reducers/pluginManager'; import {startPluginDownload} from '../reducers/pluginDownloads'; import isProduction from '../utils/isProduction'; diff --git a/desktop/flipper-ui-core/src/dispatcher/index.tsx b/desktop/flipper-ui-core/src/dispatcher/index.tsx index be128e8bc..fe5c21d3f 100644 --- a/desktop/flipper-ui-core/src/dispatcher/index.tsx +++ b/desktop/flipper-ui-core/src/dispatcher/index.tsx @@ -15,7 +15,7 @@ import plugins from './plugins'; import user from './fb-stubs/user'; import pluginManager from './pluginManager'; import reactNative from './reactNative'; -import pluginMarketplace from './fb-stubs/pluginMarketplace'; +import pluginMarketplace from './pluginMarketplace'; import pluginDownloads from './pluginDownloads'; import info from '../utils/info'; import pluginChangeListener from './pluginsChangeListener'; diff --git a/desktop/flipper-ui-core/src/dispatcher/pluginMarketplace.tsx b/desktop/flipper-ui-core/src/dispatcher/pluginMarketplace.tsx new file mode 100644 index 000000000..84ce505cb --- /dev/null +++ b/desktop/flipper-ui-core/src/dispatcher/pluginMarketplace.tsx @@ -0,0 +1,209 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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 {Store} from '../reducers/index'; +import {loadAvailablePlugins} from '../fb-stubs/pluginMarketplaceAPI'; +import { + MarketplacePluginDetails, + registerMarketplacePlugins, +} from '../reducers/plugins'; +import {getFlipperLib} from 'flipper-plugin'; +import {DownloadablePluginDetails} from 'flipper-common'; +import semver from 'semver'; +import {startPluginDownload} from '../reducers/pluginDownloads'; +import {sideEffect} from '../utils/sideEffect'; +import {switchPlugin} from '../reducers/pluginManager'; +import {setPluginEnabled} from '../reducers/connections'; +import isPluginCompatible from '../utils/isPluginCompatible'; +import {selectCompatibleMarketplaceVersions} from './plugins'; +import isPluginVersionMoreRecent from '../utils/isPluginVersionMoreRecent'; +import {isConnectivityOrAuthError} from 'flipper-common'; +import {isLoggedIn} from '../fb-stubs/user'; +import {getRenderHostInstance} from '..'; + +export const pollingIntervalMs = getRenderHostInstance().serverConfig.env + .FLIPPER_PLUGIN_AUTO_UPDATE_POLLING_INTERVAL + ? parseInt( + getRenderHostInstance().serverConfig.env + .FLIPPER_PLUGIN_AUTO_UPDATE_POLLING_INTERVAL!, + 10, + ) // for manual testing we could set smaller interval + : 300000; // 5 min by default + +function isAutoUpdateDisabled() { + return ( + !getFlipperLib().isFB || + getRenderHostInstance().GK('flipper_disable_plugin_auto_update') || + getRenderHostInstance().serverConfig.env.FLIPPER_NO_PLUGIN_AUTO_UPDATE !== + undefined + ); +} + +function isPluginMarketplaceDisabled() { + return ( + !getFlipperLib().isFB || + getRenderHostInstance().GK('flipper_disable_plugin_marketplace') || + getRenderHostInstance().serverConfig.env.FLIPPER_NO_PLUGIN_MARKETPLACE + ); +} + +export default (store: Store) => { + if (isPluginMarketplaceDisabled()) { + console.warn( + 'Loading plugins from Plugin Marketplace disabled by GK or env var', + ); + store.dispatch(registerMarketplacePlugins([])); + return; + } + // Run the first refresh immediately and then every time when user is logged + sideEffect( + store, + { + name: 'refreshMarketplacePluginsWhenUserLogged', + throttleMs: 1000, + fireImmediately: true, + }, + (state) => state.user, + (_, store) => refreshMarketplacePlugins(store), + ); + // Additionally schedule refreshes with the given interval + const handle = setInterval( + refreshMarketplacePlugins, + pollingIntervalMs, + store, + ); + // Try to auto-install plugins on every connected / disconnected app + sideEffect( + store, + { + name: 'autoInstallPluginsOnClientConnected', + throttleMs: 1000, + fireImmediately: false, + }, + (state) => ({ + clients: state.connections.clients, + pluginsInitialized: state.plugins.initialized, + }), + (_, store) => + autoUpdatePlugins(store, store.getState().plugins.marketplacePlugins), + ); + return async () => { + clearInterval(handle); + }; +}; + +export async function loadPluginsFromMarketplace(): Promise< + MarketplacePluginDetails[] +> { + const availablePlugins = await loadAvailablePlugins(); + return selectCompatibleMarketplaceVersions(availablePlugins); +} + +async function refreshMarketplacePlugins(store: Store): Promise { + if (getFlipperLib().isFB && !isLoggedIn().get()) { + // inside FB we cannot refresh when user is not logged + return; + } + try { + const plugins = await loadPluginsFromMarketplace(); + store.dispatch(registerMarketplacePlugins(plugins)); + autoUpdatePlugins(store, plugins); + } catch (err) { + if (isConnectivityOrAuthError(err)) { + // This is handled elsewhere and we don't need to create another warning or error for it. + console.warn( + 'Connectivity or auth error while refreshing plugins from Marketplace', + err, + ); + } else { + console.error('Error while refreshing plugins from Marketplace', err); + } + } +} + +export function autoUpdatePlugins( + store: Store, + marketplacePlugins: DownloadablePluginDetails[], +) { + const state = store.getState(); + if (!state.plugins.initialized) { + // skip auto-updating plugins if they are not initialized yet + return; + } + const {loadedPlugins, installedPlugins, uninstalledPluginNames} = + state.plugins; + const autoInstalledPlugins = new Set(); + for (const client of state.connections.clients.values()) { + const enabledPlugins = state.connections.enabledPlugins[client.query.app]; + if (enabledPlugins) { + // If we already have persisted list of enabled plugins - + // we should install those of them which are enabled, but not installed by some reason. + enabledPlugins.forEach((p) => { + if (client.supportsPlugin(p)) { + autoInstalledPlugins.add(p); + } + }); + } else { + // If there is no persisted list of enabled plugins this means that user + // opened this app for first time. In such case we should enable and install + // plugins which are enabled by default. + for (const plugin of marketplacePlugins) { + if (plugin.isEnabledByDefault && client.supportsPlugin(plugin.id)) { + autoInstalledPlugins.add(plugin.id); + const loadedPluginInstance = state.plugins.clientPlugins.get( + plugin.id, + ); + if (loadedPluginInstance) { + // If plugin was already installed before (e.g. for debugging another mobile app), + // then we should switch its state to "loaded" for the current app. + store.dispatch( + switchPlugin({ + plugin: loadedPluginInstance, + selectedApp: client.query.app, + }), + ); + } else { + // If plugin was not installed before, then we should mark it as enabled + // to ensure it is automatically loaded after downloaded from Marketplace. + store.dispatch(setPluginEnabled(plugin.id, client.query.app)); + } + } + } + } + } + if (isAutoUpdateDisabled() || !isLoggedIn().get()) { + return; + } + for (const plugin of marketplacePlugins) { + if (uninstalledPluginNames.has(plugin.name)) { + // Skip if plugin is marked as uninstalled + continue; + } + if (!isPluginCompatible(plugin)) { + // Skip if new plugin version is not compatible with the current Flipper version + continue; + } + const loadedPlugin = loadedPlugins.get(plugin.id); + if (loadedPlugin && !isPluginVersionMoreRecent(plugin, loadedPlugin)) { + // Skip if plugin is installed and new version is less or equal to the installed one + continue; + } + if (!loadedPlugin && !autoInstalledPlugins.has(plugin.id)) { + // Skip if plugin is not installed and not in the list of auto-installable plugins + continue; + } + const installedVersion = installedPlugins.get(plugin.name)?.version; + if (installedVersion && semver.gte(installedVersion, plugin.version)) { + // Skip if the same or newer version already downloaded + continue; + } + // Finally if we are good to go - dispatch downloading of the updated version + store.dispatch(startPluginDownload({plugin, startedByUser: false})); + } +} diff --git a/desktop/flipper-ui-core/src/dispatcher/fb-stubs/pluginMarketplace.tsx b/desktop/flipper-ui-core/src/fb-stubs/pluginMarketplaceAPI.tsx similarity index 50% rename from desktop/flipper-ui-core/src/dispatcher/fb-stubs/pluginMarketplace.tsx rename to desktop/flipper-ui-core/src/fb-stubs/pluginMarketplaceAPI.tsx index a52b07141..3ac40320f 100644 --- a/desktop/flipper-ui-core/src/dispatcher/fb-stubs/pluginMarketplace.tsx +++ b/desktop/flipper-ui-core/src/fb-stubs/pluginMarketplaceAPI.tsx @@ -7,10 +7,10 @@ * @format */ -export async function loadPluginsFromMarketplace() { - // Marketplace is not implemented in public version of Flipper -} +import {MarketplacePluginDetails} from '../reducers/plugins'; -export default () => { - // Marketplace is not implemented in public version of Flipper -}; +export async function loadAvailablePlugins(): Promise< + MarketplacePluginDetails[] +> { + return []; +}