Plugin download progress and error handling

Summary: Show progress reporting in sidebar during plugin download. Also handle plugin download errors and show them to user as notifications.

Reviewed By: mweststrate

Differential Revision: D25530385

fbshipit-source-id: 42bf0e65b4434d00c1465705ce9ec5c723df6841
This commit is contained in:
Anton Nikolaev
2020-12-15 09:28:58 -08:00
committed by Facebook GitHub Bot
parent c3d61cc32d
commit efb82e80b5
5 changed files with 100 additions and 87 deletions

View File

@@ -15,8 +15,7 @@ import {Store} from '../reducers/index';
import { import {
PluginDownloadStatus, PluginDownloadStatus,
pluginDownloadStarted, pluginDownloadStarted,
pluginDownloadFailed, pluginDownloadFinished,
pluginDownloadSucceeded,
} from '../reducers/pluginDownloads'; } from '../reducers/pluginDownloads';
import {sideEffect} from '../utils/sideEffect'; import {sideEffect} from '../utils/sideEffect';
import {default as axios} from 'axios'; import {default as axios} from 'axios';
@@ -25,7 +24,12 @@ import path from 'path';
import tmp from 'tmp'; import tmp from 'tmp';
import {promisify} from 'util'; import {promisify} from 'util';
import {requirePlugin} from './plugins'; import {requirePlugin} from './plugins';
import {registerPluginUpdate} from '../reducers/connections'; import {registerPluginUpdate, setStaticView} from '../reducers/connections';
import {notification, Typography} from 'antd';
import React from 'react';
import {ConsoleLogs} from '../chrome/ConsoleLogs';
const {Text, Link} = Typography;
// Adapter which forces node.js implementation for axios instead of browser implementation // Adapter which forces node.js implementation for axios instead of browser implementation
// used by default in Electron. Node.js implementation is better, because it // used by default in Electron. Node.js implementation is better, because it
@@ -44,11 +48,7 @@ export default (store: Store) => {
(state, store) => { (state, store) => {
for (const download of Object.values(state)) { for (const download of Object.values(state)) {
if (download.status === PluginDownloadStatus.QUEUED) { if (download.status === PluginDownloadStatus.QUEUED) {
handlePluginDownload( handlePluginDownload(download.plugin, download.startedByUser, store);
download.plugin,
download.enableDownloadedPlugin,
store,
);
} }
} }
}, },
@@ -58,7 +58,7 @@ export default (store: Store) => {
async function handlePluginDownload( async function handlePluginDownload(
plugin: DownloadablePluginDetails, plugin: DownloadablePluginDetails,
enableDownloadedPlugin: boolean, startedByUser: boolean,
store: Store, store: Store,
) { ) {
const dispatch = store.dispatch; const dispatch = store.dispatch;
@@ -115,21 +115,35 @@ async function handlePluginDownload(
dispatch( dispatch(
registerPluginUpdate({ registerPluginUpdate({
plugin: pluginDefinition, plugin: pluginDefinition,
enablePlugin: enableDownloadedPlugin, enablePlugin: startedByUser,
}), }),
); );
} }
console.log( console.log(
`Successfully downloaded and installed plugin "${title}" v${version} from "${downloadUrl}" to "${dir}".`, `Successfully downloaded and installed plugin "${title}" v${version} from "${downloadUrl}" to "${dir}".`,
); );
dispatch(pluginDownloadSucceeded({plugin}));
} catch (error) { } catch (error) {
console.error( console.error(
`Failed to download plugin "${title}" v${version} from "${downloadUrl}" to "${dir}".`, `Failed to download plugin "${title}" v${version} from "${downloadUrl}" to "${dir}".`,
error, error,
); );
dispatch(pluginDownloadFailed({plugin, error})); if (startedByUser) {
notification.error({
message: `Failed to install plugin "${title}".`,
description: (
<Text>
See{' '}
<Link onClick={() => dispatch(setStaticView(ConsoleLogs))}>
logs
</Link>{' '}
for details.
</Text>
),
placement: 'bottomLeft',
});
}
} finally { } finally {
dispatch(pluginDownloadFinished({plugin}));
await fs.remove(targetDir); await fs.remove(targetDir);
} }
} }

View File

@@ -20,11 +20,10 @@ export enum PluginDownloadStatus {
export type DownloadablePluginState = { export type DownloadablePluginState = {
plugin: DownloadablePluginDetails; plugin: DownloadablePluginDetails;
enableDownloadedPlugin: boolean; startedByUser: boolean;
} & ( } & (
| {status: PluginDownloadStatus.QUEUED} | {status: PluginDownloadStatus.QUEUED}
| {status: PluginDownloadStatus.STARTED; cancel: Canceler} | {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. // We use plugin installation path as key as it is unique for each plugin version.
@@ -34,7 +33,7 @@ export type PluginDownloadStart = {
type: 'PLUGIN_DOWNLOAD_START'; type: 'PLUGIN_DOWNLOAD_START';
payload: { payload: {
plugin: DownloadablePluginDetails; plugin: DownloadablePluginDetails;
enableDownloadedPlugin: boolean; startedByUser: boolean;
}; };
}; };
@@ -46,26 +45,17 @@ export type PluginDownloadStarted = {
}; };
}; };
export type PluginDownloadSucceeded = { export type PluginDownloadFinished = {
type: 'PLUGIN_DOWNLOAD_SUCCEEDED'; type: 'PLUGIN_DOWNLOAD_FINISHED';
payload: { payload: {
plugin: DownloadablePluginDetails; plugin: DownloadablePluginDetails;
}; };
}; };
export type PluginDownloadFailed = {
type: 'PLUGIN_DOWNLOAD_FAILED';
payload: {
plugin: DownloadablePluginDetails;
error: Error;
};
};
export type Action = export type Action =
| PluginDownloadStart | PluginDownloadStart
| PluginDownloadStarted | PluginDownloadStarted
| PluginDownloadSucceeded | PluginDownloadFinished;
| PluginDownloadFailed;
const INITIAL_STATE: State = {}; const INITIAL_STATE: State = {};
@@ -75,24 +65,21 @@ export default function reducer(
): State { ): State {
switch (action.type) { switch (action.type) {
case 'PLUGIN_DOWNLOAD_START': { case 'PLUGIN_DOWNLOAD_START': {
const {plugin, enableDownloadedPlugin} = action.payload; const {plugin, startedByUser} = action.payload;
const downloadState = state[plugin.dir]; const downloadState = state[plugin.dir];
if ( if (downloadState) {
downloadState && // If download is already in progress - re-use the existing state. // 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) => { return produce(state, (draft) => {
draft[plugin.dir] = { draft[plugin.dir] = {
...downloadState, ...downloadState,
enableDownloadedPlugin: startedByUser: startedByUser || downloadState.startedByUser,
enableDownloadedPlugin || downloadState.enableDownloadedPlugin,
}; };
}); });
} }
return produce(state, (draft) => { return produce(state, (draft) => {
draft[plugin.dir] = { draft[plugin.dir] = {
plugin, plugin,
enableDownloadedPlugin: enableDownloadedPlugin, startedByUser: startedByUser,
status: PluginDownloadStatus.QUEUED, status: PluginDownloadStatus.QUEUED,
}; };
}); });
@@ -110,29 +97,12 @@ export default function reducer(
draft[plugin.dir] = { draft[plugin.dir] = {
status: PluginDownloadStatus.STARTED, status: PluginDownloadStatus.STARTED,
plugin, plugin,
enableDownloadedPlugin: downloadState.enableDownloadedPlugin, startedByUser: downloadState.startedByUser,
cancel, cancel,
}; };
}); });
} }
case 'PLUGIN_DOWNLOAD_FAILED': { case 'PLUGIN_DOWNLOAD_FINISHED': {
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; const {plugin} = action.payload;
return produce(state, (draft) => { return produce(state, (draft) => {
delete draft[plugin.dir]; delete draft[plugin.dir];
@@ -145,7 +115,7 @@ export default function reducer(
export const startPluginDownload = (payload: { export const startPluginDownload = (payload: {
plugin: DownloadablePluginDetails; plugin: DownloadablePluginDetails;
enableDownloadedPlugin: boolean; startedByUser: boolean;
}): Action => ({ }): Action => ({
type: 'PLUGIN_DOWNLOAD_START', type: 'PLUGIN_DOWNLOAD_START',
payload, payload,
@@ -156,11 +126,6 @@ export const pluginDownloadStarted = (payload: {
cancel: Canceler; cancel: Canceler;
}): Action => ({type: 'PLUGIN_DOWNLOAD_STARTED', payload}); }): Action => ({type: 'PLUGIN_DOWNLOAD_STARTED', payload});
export const pluginDownloadSucceeded = (payload: { export const pluginDownloadFinished = (payload: {
plugin: DownloadablePluginDetails; plugin: DownloadablePluginDetails;
}): Action => ({type: 'PLUGIN_DOWNLOAD_SUCCEEDED', payload}); }): Action => ({type: 'PLUGIN_DOWNLOAD_FINISHED', payload});
export const pluginDownloadFailed = (payload: {
plugin: DownloadablePluginDetails;
error: Error;
}): Action => ({type: 'PLUGIN_DOWNLOAD_FAILED', payload});

View File

@@ -10,7 +10,13 @@
import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
import {Badge, Button, Menu, Tooltip, Typography} from 'antd'; import {Badge, Button, Menu, Tooltip, Typography} from 'antd';
import {InfoIcon, SidebarTitle} from '../LeftSidebar'; import {InfoIcon, SidebarTitle} from '../LeftSidebar';
import {PlusOutlined, MinusOutlined, DeleteOutlined} from '@ant-design/icons'; import {
PlusOutlined,
MinusOutlined,
DeleteOutlined,
LoadingOutlined,
DownloadOutlined,
} from '@ant-design/icons';
import {Glyph, Layout, styled} from '../../ui'; import {Glyph, Layout, styled} from '../../ui';
import {theme, NUX, Tracked} from 'flipper-plugin'; import {theme, NUX, Tracked} from 'flipper-plugin';
import {useDispatch, useStore} from '../../utils/useStore'; import {useDispatch, useStore} from '../../utils/useStore';
@@ -21,11 +27,14 @@ import Client from '../../Client';
import {State} from '../../reducers'; import {State} from '../../reducers';
import BaseDevice from '../../devices/BaseDevice'; import BaseDevice from '../../devices/BaseDevice';
import {getFavoritePlugins} from '../../chrome/mainsidebar/sidebarUtils'; import {getFavoritePlugins} from '../../chrome/mainsidebar/sidebarUtils';
import {PluginDetails} from 'flipper-plugin-lib'; import {PluginDetails, DownloadablePluginDetails} from 'flipper-plugin-lib';
import {useMemoize} from '../../utils/useMemoize'; import {useMemoize} from '../../utils/useMemoize';
import MetroDevice from '../../devices/MetroDevice'; import MetroDevice from '../../devices/MetroDevice';
import {DownloadablePluginDetails} from 'plugin-lib/lib'; import {
import {startPluginDownload} from '../../reducers/pluginDownloads'; DownloadablePluginState,
PluginDownloadStatus,
startPluginDownload,
} from '../../reducers/pluginDownloads';
import {uninstallPlugin} from '../../reducers/pluginManager'; import {uninstallPlugin} from '../../reducers/pluginManager';
const {SubMenu} = Menu; const {SubMenu} = Menu;
@@ -43,6 +52,7 @@ export const PluginList = memo(function PluginList({
const dispatch = useDispatch(); const dispatch = useDispatch();
const connections = useStore((state) => state.connections); const connections = useStore((state) => state.connections);
const plugins = useStore((state) => state.plugins); const plugins = useStore((state) => state.plugins);
const downloads = useStore((state) => state.pluginDownloads);
const { const {
devicePlugins, devicePlugins,
@@ -50,7 +60,7 @@ export const PluginList = memo(function PluginList({
enabledPlugins, enabledPlugins,
disabledPlugins, disabledPlugins,
unavailablePlugins, unavailablePlugins,
uninstalledPlugins, downloadablePlugins,
} = useMemoize(computePluginLists, [ } = useMemoize(computePluginLists, [
activeDevice, activeDevice,
metroDevice, metroDevice,
@@ -60,6 +70,22 @@ export const PluginList = memo(function PluginList({
]); ]);
const isArchived = !!activeDevice?.isArchived; const isArchived = !!activeDevice?.isArchived;
const annotatedDownloadablePlugins = useMemoize<
[Record<string, DownloadablePluginState>, DownloadablePluginDetails[]],
[plugin: DownloadablePluginDetails, downloadStatus?: PluginDownloadStatus][]
>(
(downloads, downloadablePlugins) => {
const downloadMap = new Map(
Object.values(downloads).map((x) => [x.plugin.id, x]),
);
return downloadablePlugins.map((plugin) => [
plugin,
downloadMap.get(plugin.id)?.status,
]);
},
[downloads, downloadablePlugins],
);
const handleAppPluginClick = useCallback( const handleAppPluginClick = useCallback(
(pluginId) => { (pluginId) => {
dispatch( dispatch(
@@ -99,15 +125,15 @@ export const PluginList = memo(function PluginList({
); );
const handleInstallPlugin = useCallback( const handleInstallPlugin = useCallback(
(id: string) => { (id: string) => {
const plugin = uninstalledPlugins.find((p) => p.id === id)!; const plugin = downloadablePlugins.find((p) => p.id === id)!;
dispatch( dispatch(
startPluginDownload({ startPluginDownload({
plugin, plugin,
enableDownloadedPlugin: true, startedByUser: true,
}), }),
); );
}, },
[uninstalledPlugins, dispatch], [downloadablePlugins, dispatch],
); );
const handleUninstallPlugin = useCallback( const handleUninstallPlugin = useCallback(
(id: string) => { (id: string) => {
@@ -234,8 +260,9 @@ export const PluginList = memo(function PluginList({
<PluginGroup <PluginGroup
key="uninstalled" key="uninstalled"
title="Detected in App" title="Detected in App"
hint="The plugins below are supported by the selected device / application, but not installed in Flipper."> hint="The plugins below are supported by the selected device / application, but not installed in Flipper.
{uninstalledPlugins.map((plugin) => ( To install plugin, hover it and click to the 'Download' icon.">
{annotatedDownloadablePlugins.map(([plugin, downloadStatus]) => (
<PluginEntry <PluginEntry
key={plugin.id} key={plugin.id}
plugin={plugin} plugin={plugin}
@@ -244,9 +271,15 @@ export const PluginList = memo(function PluginList({
actions={ actions={
<ActionButton <ActionButton
id={plugin.id} id={plugin.id}
title="Install and enable plugin" title="Download and install plugin"
onClick={handleInstallPlugin} onClick={handleInstallPlugin}
icon={<PlusOutlined size={16} style={{marginRight: 0}} />} icon={
downloadStatus ? (
<LoadingOutlined size={16} style={{marginRight: 0}} />
) : (
<DownloadOutlined size={16} style={{marginRight: 0}} />
)
}
/> />
} }
disabled disabled
@@ -432,7 +465,7 @@ export function computePluginLists(
const enabledPlugins: ClientPluginDefinition[] = []; const enabledPlugins: ClientPluginDefinition[] = [];
const disabledPlugins: ClientPluginDefinition[] = []; const disabledPlugins: ClientPluginDefinition[] = [];
const unavailablePlugins: [plugin: PluginDetails, reason: string][] = []; const unavailablePlugins: [plugin: PluginDetails, reason: string][] = [];
const uninstalledPlugins: DownloadablePluginDetails[] = []; const downloadablePlugins: DownloadablePluginDetails[] = [];
if (device) { if (device) {
// find all device plugins that aren't part of the current device / metro // find all device plugins that aren't part of the current device / metro
@@ -504,7 +537,7 @@ export function computePluginLists(
); );
uninstalledMarketplacePlugins.forEach((plugin) => { uninstalledMarketplacePlugins.forEach((plugin) => {
if (client.supportsPlugin(plugin.id)) { if (client.supportsPlugin(plugin.id)) {
uninstalledPlugins.push(plugin); downloadablePlugins.push(plugin);
} else { } else {
unavailablePlugins.push([ unavailablePlugins.push([
plugin, plugin,
@@ -521,7 +554,7 @@ export function computePluginLists(
unavailablePlugins.sort(([a], [b]) => { unavailablePlugins.sort(([a], [b]) => {
return getPluginTitle(a) > getPluginTitle(b) ? 1 : -1; return getPluginTitle(a) > getPluginTitle(b) ? 1 : -1;
}); });
uninstalledPlugins.sort((a, b) => { downloadablePlugins.sort((a, b) => {
return getPluginTitle(a) > getPluginTitle(b) ? 1 : -1; return getPluginTitle(a) > getPluginTitle(b) ? 1 : -1;
}); });
@@ -531,7 +564,7 @@ export function computePluginLists(
enabledPlugins, enabledPlugins,
disabledPlugins, disabledPlugins,
unavailablePlugins, unavailablePlugins,
uninstalledPlugins, downloadablePlugins,
}; };
} }

View File

@@ -168,7 +168,7 @@ describe('basic findBestDevice with metro present', () => {
state.connections.userStarredPlugins, state.connections.userStarredPlugins,
), ),
).toEqual({ ).toEqual({
uninstalledPlugins: [], downloadablePlugins: [],
devicePlugins: [logsPlugin], devicePlugins: [logsPlugin],
metroPlugins: [logsPlugin], metroPlugins: [logsPlugin],
enabledPlugins: [], enabledPlugins: [],
@@ -237,12 +237,12 @@ describe('basic findBestDevice with metro present', () => {
noopPlugin, noopPlugin,
); );
const supportedUninstalledPlugin = createMockDownloadablePluginDetails({ const supportedDownloadablePlugin = createMockDownloadablePluginDetails({
id: 'supportedUninstalledPlugin', id: 'supportedUninstalledPlugin',
title: 'Supported Uninstalled Plugin', title: 'Supported Uninstalled Plugin',
}); });
const unsupportedUninstalledPlugin = createMockDownloadablePluginDetails({ const unsupportedDownloadablePlugin = createMockDownloadablePluginDetails({
id: 'unsupportedUninstalledPlugin', id: 'unsupportedUninstalledPlugin',
title: 'Unsupported Uninstalled Plugin', title: 'Unsupported Uninstalled Plugin',
}); });
@@ -258,8 +258,8 @@ describe('basic findBestDevice with metro present', () => {
flipper.store.dispatch(addGatekeepedPlugins([gateKeepedPlugin])); flipper.store.dispatch(addGatekeepedPlugins([gateKeepedPlugin]));
flipper.store.dispatch( flipper.store.dispatch(
registerMarketplacePlugins([ registerMarketplacePlugins([
supportedUninstalledPlugin, supportedDownloadablePlugin,
unsupportedUninstalledPlugin, unsupportedDownloadablePlugin,
]), ]),
); );
@@ -297,11 +297,11 @@ describe('basic findBestDevice with metro present', () => {
"Plugin 'Unsupported Plugin' is installed in Flipper, but not supported by the client application", "Plugin 'Unsupported Plugin' is installed in Flipper, but not supported by the client application",
], ],
[ [
unsupportedUninstalledPlugin, unsupportedDownloadablePlugin,
"Plugin 'Unsupported Uninstalled Plugin' is not installed in Flipper and not supported by the client application", "Plugin 'Unsupported Uninstalled Plugin' is not installed in Flipper and not supported by the client application",
], ],
], ],
uninstalledPlugins: [supportedUninstalledPlugin], downloadablePlugins: [supportedDownloadablePlugin],
}); });
flipper.store.dispatch( flipper.store.dispatch(

View File

@@ -30,6 +30,7 @@ export default function Link(props: {
href: string; href: string;
children?: React.ReactNode; children?: React.ReactNode;
style?: React.CSSProperties; style?: React.CSSProperties;
onClick?: ((event: React.MouseEvent<any>) => void) | undefined;
}) { }) {
const isSandy = useIsSandy(); const isSandy = useIsSandy();
const onClick = useCallback( const onClick = useCallback(
@@ -42,9 +43,9 @@ export default function Link(props: {
); );
return isSandy ? ( return isSandy ? (
<AntOriginalLink {...props} onClick={onClick} /> <AntOriginalLink {...props} onClick={props.onClick ?? onClick} />
) : ( ) : (
<StyledLink onClick={onClick} style={props.style}> <StyledLink onClick={props.onClick ?? onClick} style={props.style}>
{props.children || props.href} {props.children || props.href}
</StyledLink> </StyledLink>
); );