From 97d37abbb2aca60654bfdaa31ceaf2095b5d0d2f Mon Sep 17 00:00:00 2001 From: Anton Nikolaev Date: Tue, 15 Dec 2020 09:28:58 -0800 Subject: [PATCH] Install plugins from sidebar Summary: This diff adds "download" button to the plugins shown in "Detected in App" section, so they can be downloaded, installed and enabled just in one click. For now UI is very simple - there is no progress indication and no error handling for failed downloads. I'll add them in next diffs. Please note that we are explicitly "star" every installed plugin to enable it straight away without additional clicks in "disabled" section. Reviewed By: mweststrate Differential Revision: D25393472 fbshipit-source-id: 0a224ea6d03db0ee9a70b7fa35ede9616c03d824 --- desktop/app/src/dispatcher/index.tsx | 2 + .../app/src/dispatcher/pluginDownloads.tsx | 129 ++++++++++++++ desktop/app/src/reducers/connections.tsx | 10 +- desktop/app/src/reducers/index.tsx | 7 + desktop/app/src/reducers/pluginDownloads.tsx | 166 ++++++++++++++++++ .../sandy-chrome/appinspect/PluginList.tsx | 22 ++- desktop/app/src/store.tsx | 28 ++- 7 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 desktop/app/src/dispatcher/pluginDownloads.tsx create mode 100644 desktop/app/src/reducers/pluginDownloads.tsx diff --git a/desktop/app/src/dispatcher/index.tsx b/desktop/app/src/dispatcher/index.tsx index 5fc83f775..b66a95f2f 100644 --- a/desktop/app/src/dispatcher/index.tsx +++ b/desktop/app/src/dispatcher/index.tsx @@ -22,6 +22,7 @@ import pluginManager from './pluginManager'; import reactNative from './reactNative'; import pluginAutoUpdate from './fb-stubs/pluginAutoUpdate'; import pluginMarketplace from './fb-stubs/pluginMarketplace'; +import pluginDownloads from './pluginDownloads'; import {Logger} from '../fb-interfaces/Logger'; import {Store} from '../reducers/index'; @@ -49,6 +50,7 @@ export default function (store: Store, logger: Logger): () => Promise { reactNative, pluginAutoUpdate, pluginMarketplace, + pluginDownloads, ].filter(notNull); const globalCleanup = dispatchers .map((dispatcher) => dispatcher(store, logger)) diff --git a/desktop/app/src/dispatcher/pluginDownloads.tsx b/desktop/app/src/dispatcher/pluginDownloads.tsx new file mode 100644 index 000000000..67351d83b --- /dev/null +++ b/desktop/app/src/dispatcher/pluginDownloads.tsx @@ -0,0 +1,129 @@ +/** + * 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 { + DownloadablePluginDetails, + installPluginFromFile, +} from 'flipper-plugin-lib'; +import {Store} from '../reducers/index'; +import { + PluginDownloadStatus, + pluginDownloadStarted, + pluginDownloadFailed, + pluginDownloadSucceeded, +} from '../reducers/pluginDownloads'; +import {sideEffect} from '../utils/sideEffect'; +import {default as axios} from 'axios'; +import fs from 'fs-extra'; +import path from 'path'; +import tmp from 'tmp'; +import {promisify} from 'util'; +import {requirePlugin} from './plugins'; +import {registerPluginUpdate} from '../reducers/connections'; + +// Adapter which forces node.js implementation for axios instead of browser implementation +// used by default in Electron. Node.js implementation is better, because it +// supports streams which can be used for direct downloading to disk. +const axiosHttpAdapter = require('axios/lib/adapters/http'); // eslint-disable-line import/no-commonjs + +const getTempDirName = promisify(tmp.dir) as ( + options?: tmp.DirOptions, +) => Promise; + +export default (store: Store) => { + sideEffect( + store, + {name: 'handlePluginDownloads', throttleMs: 1000, fireImmediately: true}, + (state) => state.pluginDownloads, + (state, store) => { + for (const download of Object.values(state)) { + if (download.status === PluginDownloadStatus.QUEUED) { + handlePluginDownload( + download.plugin, + download.enableDownloadedPlugin, + store, + ); + } + } + }, + ); + return async () => {}; +}; + +async function handlePluginDownload( + plugin: DownloadablePluginDetails, + enableDownloadedPlugin: boolean, + store: Store, +) { + const dispatch = store.dispatch; + const {name, title, version, downloadUrl, dir} = plugin; + console.log( + `Downloading plugin "${title}" v${version} from "${downloadUrl}" to "${dir}".`, + ); + const targetDir = await getTempDirName(); + const targetFile = path.join(targetDir, `${name}-${version}.tgz`); + try { + const cancellationSource = axios.CancelToken.source(); + dispatch( + pluginDownloadStarted({plugin, cancel: cancellationSource.cancel}), + ); + await fs.ensureDir(targetDir); + let percentCompleted = 0; + const response = await axios.get(plugin.downloadUrl, { + adapter: axiosHttpAdapter, + cancelToken: cancellationSource.token, + responseType: 'stream', + onDownloadProgress: async (progressEvent) => { + const newPercentCompleted = !progressEvent.total + ? 0 + : Math.round((progressEvent.loaded * 100) / progressEvent.total); + if (newPercentCompleted - percentCompleted >= 20) { + percentCompleted = newPercentCompleted; + console.log( + `Downloading plugin "${title}" v${version} from "${downloadUrl}": ${percentCompleted}% completed (${progressEvent.loaded} from ${progressEvent.total})`, + ); + } + }, + }); + if (response.headers['content-type'] !== 'application/octet-stream') { + throw new Error( + `Unexpected content type ${response.headers['content-type']} received from ${plugin.downloadUrl}`, + ); + } + const responseStream = response.data as fs.ReadStream; + const writeStream = responseStream.pipe( + fs.createWriteStream(targetFile, {autoClose: true}), + ); + await new Promise((resolve, reject) => + writeStream.once('finish', resolve).once('error', reject), + ); + await installPluginFromFile(targetFile); + if (!store.getState().plugins.clientPlugins.has(plugin.id)) { + const pluginDefinition = requirePlugin(plugin); + dispatch( + registerPluginUpdate({ + plugin: pluginDefinition, + enablePlugin: enableDownloadedPlugin, + }), + ); + } + console.log( + `Successfully downloaded and installed plugin "${title}" v${version} from "${downloadUrl}" to "${dir}".`, + ); + dispatch(pluginDownloadSucceeded({plugin})); + } catch (error) { + console.error( + `Failed to download plugin "${title}" v${version} from "${downloadUrl}" to "${dir}".`, + error, + ); + dispatch(pluginDownloadFailed({plugin, error})); + } finally { + await fs.remove(targetDir); + } +} diff --git a/desktop/app/src/reducers/connections.tsx b/desktop/app/src/reducers/connections.tsx index c17288bb3..8f3c1a7d9 100644 --- a/desktop/app/src/reducers/connections.tsx +++ b/desktop/app/src/reducers/connections.tsx @@ -121,7 +121,10 @@ export type Action = | { // Implemented by rootReducer in `store.tsx` type: 'UPDATE_PLUGIN'; - payload: PluginDefinition; + payload: { + plugin: PluginDefinition; + enablePlugin: boolean; + }; }; const DEFAULT_PLUGIN = 'DeviceLogs'; @@ -400,7 +403,10 @@ export const selectClient = (clientId: string | null): Action => ({ payload: clientId, }); -export const registerPluginUpdate = (payload: PluginDefinition): Action => ({ +export const registerPluginUpdate = (payload: { + plugin: PluginDefinition; + enablePlugin: boolean; +}): Action => ({ type: 'UPDATE_PLUGIN', payload, }); diff --git a/desktop/app/src/reducers/index.tsx b/desktop/app/src/reducers/index.tsx index bdb189f32..8ebd05c26 100644 --- a/desktop/app/src/reducers/index.tsx +++ b/desktop/app/src/reducers/index.tsx @@ -52,6 +52,10 @@ import healthchecks, { Action as HealthcheckAction, State as HealthcheckState, } from './healthchecks'; +import pluginDownloads, { + State as PluginDownloadsState, + Action as PluginDownloadsAction, +} from './pluginDownloads'; import usageTracking, { Action as TrackingAction, State as TrackingState, @@ -83,6 +87,7 @@ export type Actions = | PluginManagerAction | HealthcheckAction | TrackingAction + | PluginDownloadsAction | {type: 'INIT'}; export type State = { @@ -99,6 +104,7 @@ export type State = { pluginManager: PluginManagerState; healthchecks: HealthcheckState & PersistPartial; usageTracking: TrackingState; + pluginDownloads: PluginDownloadsState; }; export type Store = ReduxStore; @@ -181,4 +187,5 @@ export default combineReducers({ healthchecks, ), usageTracking, + pluginDownloads, }); diff --git a/desktop/app/src/reducers/pluginDownloads.tsx b/desktop/app/src/reducers/pluginDownloads.tsx new file mode 100644 index 000000000..c40601293 --- /dev/null +++ b/desktop/app/src/reducers/pluginDownloads.tsx @@ -0,0 +1,166 @@ +/** + * 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 {DownloadablePluginDetails} from 'flipper-plugin-lib'; +import {Actions} from '.'; +import produce from 'immer'; +import {Canceler} from 'axios'; + +export enum PluginDownloadStatus { + QUEUED = 'Queued', + STARTED = 'Started', + FAILED = 'Failed', +} + +export type DownloadablePluginState = { + plugin: DownloadablePluginDetails; + enableDownloadedPlugin: boolean; +} & ( + | {status: PluginDownloadStatus.QUEUED} + | {status: PluginDownloadStatus.STARTED; cancel: Canceler} + | {status: PluginDownloadStatus.FAILED; error: Error} +); + +// We use plugin installation path as key as it is unique for each plugin version. +export type State = Record; + +export type PluginDownloadStart = { + type: 'PLUGIN_DOWNLOAD_START'; + payload: { + plugin: DownloadablePluginDetails; + enableDownloadedPlugin: boolean; + }; +}; + +export type PluginDownloadStarted = { + type: 'PLUGIN_DOWNLOAD_STARTED'; + payload: { + plugin: DownloadablePluginDetails; + cancel: Canceler; + }; +}; + +export type PluginDownloadSucceeded = { + type: 'PLUGIN_DOWNLOAD_SUCCEEDED'; + payload: { + plugin: DownloadablePluginDetails; + }; +}; + +export type PluginDownloadFailed = { + type: 'PLUGIN_DOWNLOAD_FAILED'; + payload: { + plugin: DownloadablePluginDetails; + error: Error; + }; +}; + +export type Action = + | PluginDownloadStart + | PluginDownloadStarted + | PluginDownloadSucceeded + | PluginDownloadFailed; + +const INITIAL_STATE: State = {}; + +export default function reducer( + state: State = INITIAL_STATE, + action: Actions, +): State { + switch (action.type) { + case 'PLUGIN_DOWNLOAD_START': { + const {plugin, enableDownloadedPlugin} = action.payload; + const downloadState = state[plugin.dir]; + if ( + downloadState && // If download is already in progress - re-use the existing state. + downloadState.status !== PluginDownloadStatus.FAILED // Note that for failed downloads we want to retry downloads. + ) { + return produce(state, (draft) => { + draft[plugin.dir] = { + ...downloadState, + enableDownloadedPlugin: + enableDownloadedPlugin || downloadState.enableDownloadedPlugin, + }; + }); + } + return produce(state, (draft) => { + draft[plugin.dir] = { + plugin, + enableDownloadedPlugin: enableDownloadedPlugin, + status: PluginDownloadStatus.QUEUED, + }; + }); + } + case 'PLUGIN_DOWNLOAD_STARTED': { + const {plugin, cancel} = action.payload; + const downloadState = state[plugin.dir]; + if (downloadState?.status !== PluginDownloadStatus.QUEUED) { + console.warn( + `Invalid state transition PLUGIN_DOWNLOAD_STARTED in status ${downloadState?.status} for download to directory ${plugin.dir}.`, + ); + return state; + } + return produce(state, (draft) => { + draft[plugin.dir] = { + status: PluginDownloadStatus.STARTED, + plugin, + enableDownloadedPlugin: downloadState.enableDownloadedPlugin, + cancel, + }; + }); + } + case 'PLUGIN_DOWNLOAD_FAILED': { + const {plugin, error} = action.payload; + const downloadState = state[plugin.dir]; + if (!downloadState) { + console.warn( + `Invalid state transition PLUGIN_DOWNLOAD_FAILED when there is no download in progress to directory ${plugin.dir}`, + ); + } + return produce(state, (draft) => { + draft[plugin.dir] = { + status: PluginDownloadStatus.FAILED, + plugin: downloadState.plugin, + enableDownloadedPlugin: downloadState.enableDownloadedPlugin, + error, + }; + }); + } + case 'PLUGIN_DOWNLOAD_SUCCEEDED': { + const {plugin} = action.payload; + return produce(state, (draft) => { + delete draft[plugin.dir]; + }); + } + default: + return state; + } +} + +export const startPluginDownload = (payload: { + plugin: DownloadablePluginDetails; + enableDownloadedPlugin: boolean; +}): Action => ({ + type: 'PLUGIN_DOWNLOAD_START', + payload, +}); + +export const pluginDownloadStarted = (payload: { + plugin: DownloadablePluginDetails; + cancel: Canceler; +}): Action => ({type: 'PLUGIN_DOWNLOAD_STARTED', payload}); + +export const pluginDownloadSucceeded = (payload: { + plugin: DownloadablePluginDetails; +}): Action => ({type: 'PLUGIN_DOWNLOAD_SUCCEEDED', payload}); + +export const pluginDownloadFailed = (payload: { + plugin: DownloadablePluginDetails; + error: Error; +}): Action => ({type: 'PLUGIN_DOWNLOAD_FAILED', payload}); diff --git a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx index 4cec4b76d..532ddb00b 100644 --- a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx @@ -25,6 +25,7 @@ import {PluginDetails} from 'flipper-plugin-lib'; import {useMemoize} from '../../utils/useMemoize'; import MetroDevice from '../../devices/MetroDevice'; import {DownloadablePluginDetails} from 'plugin-lib/lib'; +import {startPluginDownload} from '../../reducers/pluginDownloads'; const {SubMenu} = Menu; const {Text} = Typography; @@ -95,7 +96,18 @@ export const PluginList = memo(function PluginList({ }, [client, plugins.clientPlugins, dispatch], ); - + const handleInstallPlugin = useCallback( + (id: string) => { + const plugin = uninstalledPlugins.find((p) => p.id === id)!; + dispatch( + startPluginDownload({ + plugin, + enableDownloadedPlugin: true, + }), + ); + }, + [uninstalledPlugins, dispatch], + ); return ( Plugins @@ -204,6 +216,14 @@ export const PluginList = memo(function PluginList({ plugin={plugin} scrollTo={plugin.id === connections.selectedPlugin} tooltip={getPluginTooltip(plugin)} + actions={ + } + /> + } disabled /> ))} diff --git a/desktop/app/src/store.tsx b/desktop/app/src/store.tsx index e942e6097..cf1f67e35 100644 --- a/desktop/app/src/store.tsx +++ b/desktop/app/src/store.tsx @@ -76,11 +76,11 @@ export function rootReducer( } }); } else if (action.type === 'UPDATE_PLUGIN' && state) { - const plugin = action.payload; + const {plugin, enablePlugin} = action.payload; if (isDevicePluginDefinition(plugin)) { return updateDevicePlugin(state, plugin); } else { - return updateClientPlugin(state, plugin); + return updateClientPlugin(state, plugin, enablePlugin); } } @@ -127,13 +127,33 @@ function startPlugin( } } -function updateClientPlugin(state: StoreState, plugin: typeof FlipperPlugin) { +function updateClientPlugin( + state: StoreState, + plugin: typeof FlipperPlugin, + enable: boolean, +) { const clients = state.connections.clients; return produce(state, (draft) => { + if (enable) { + clients.forEach((c) => { + let enabledPlugins = draft.connections.userStarredPlugins[c.query.app]; + if ( + c.supportsPlugin(plugin.id) && + !enabledPlugins?.includes(plugin.id) + ) { + if (!enabledPlugins) { + enabledPlugins = [plugin.id]; + draft.connections.userStarredPlugins[c.query.app] = enabledPlugins; + } else { + enabledPlugins.push(plugin.id); + } + } + }); + } const clientsWithEnabledPlugin = clients.filter((c) => { return ( c.supportsPlugin(plugin.id) && - state.connections.userStarredPlugins[c.query.app]?.includes(plugin.id) + draft.connections.userStarredPlugins[c.query.app]?.includes(plugin.id) ); }); // stop plugin for each client where it is enabled