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