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
This commit is contained in:
Anton Nikolaev
2020-12-15 09:28:58 -08:00
committed by Facebook GitHub Bot
parent ab441d8226
commit 97d37abbb2
7 changed files with 357 additions and 7 deletions

View File

@@ -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<void> {
reactNative,
pluginAutoUpdate,
pluginMarketplace,
pluginDownloads,
].filter(notNull);
const globalCleanup = dispatchers
.map((dispatcher) => dispatcher(store, logger))

View File

@@ -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<string>;
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);
}
}

View File

@@ -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,
});

View File

@@ -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<State, Actions>;
@@ -181,4 +187,5 @@ export default combineReducers<State, Actions>({
healthchecks,
),
usageTracking,
pluginDownloads,
});

View File

@@ -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<string, DownloadablePluginState>;
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});

View File

@@ -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 (
<Layout.Container>
<SidebarTitle>Plugins</SidebarTitle>
@@ -204,6 +216,14 @@ export const PluginList = memo(function PluginList({
plugin={plugin}
scrollTo={plugin.id === connections.selectedPlugin}
tooltip={getPluginTooltip(plugin)}
actions={
<ActionButton
id={plugin.id}
title="Install and Enable plugin"
onClick={handleInstallPlugin}
icon={<PlusOutlined size={16} style={{marginRight: 0}} />}
/>
}
disabled
/>
))}

View File

@@ -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