diff --git a/desktop/app/src/dispatcher/pluginDownloads.tsx b/desktop/app/src/dispatcher/pluginDownloads.tsx
index 72e919e5f..2526871b5 100644
--- a/desktop/app/src/dispatcher/pluginDownloads.tsx
+++ b/desktop/app/src/dispatcher/pluginDownloads.tsx
@@ -15,8 +15,7 @@ import {Store} from '../reducers/index';
import {
PluginDownloadStatus,
pluginDownloadStarted,
- pluginDownloadFailed,
- pluginDownloadSucceeded,
+ pluginDownloadFinished,
} from '../reducers/pluginDownloads';
import {sideEffect} from '../utils/sideEffect';
import {default as axios} from 'axios';
@@ -25,7 +24,12 @@ import path from 'path';
import tmp from 'tmp';
import {promisify} from 'util';
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
// used by default in Electron. Node.js implementation is better, because it
@@ -44,11 +48,7 @@ export default (store: Store) => {
(state, store) => {
for (const download of Object.values(state)) {
if (download.status === PluginDownloadStatus.QUEUED) {
- handlePluginDownload(
- download.plugin,
- download.enableDownloadedPlugin,
- store,
- );
+ handlePluginDownload(download.plugin, download.startedByUser, store);
}
}
},
@@ -58,7 +58,7 @@ export default (store: Store) => {
async function handlePluginDownload(
plugin: DownloadablePluginDetails,
- enableDownloadedPlugin: boolean,
+ startedByUser: boolean,
store: Store,
) {
const dispatch = store.dispatch;
@@ -115,21 +115,35 @@ async function handlePluginDownload(
dispatch(
registerPluginUpdate({
plugin: pluginDefinition,
- enablePlugin: enableDownloadedPlugin,
+ enablePlugin: startedByUser,
}),
);
}
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}));
+ if (startedByUser) {
+ notification.error({
+ message: `Failed to install plugin "${title}".`,
+ description: (
+
+ See{' '}
+ dispatch(setStaticView(ConsoleLogs))}>
+ logs
+ {' '}
+ for details.
+
+ ),
+ placement: 'bottomLeft',
+ });
+ }
} finally {
+ dispatch(pluginDownloadFinished({plugin}));
await fs.remove(targetDir);
}
}
diff --git a/desktop/app/src/reducers/pluginDownloads.tsx b/desktop/app/src/reducers/pluginDownloads.tsx
index c40601293..150f1b84d 100644
--- a/desktop/app/src/reducers/pluginDownloads.tsx
+++ b/desktop/app/src/reducers/pluginDownloads.tsx
@@ -20,11 +20,10 @@ export enum PluginDownloadStatus {
export type DownloadablePluginState = {
plugin: DownloadablePluginDetails;
- enableDownloadedPlugin: boolean;
+ startedByUser: 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.
@@ -34,7 +33,7 @@ export type PluginDownloadStart = {
type: 'PLUGIN_DOWNLOAD_START';
payload: {
plugin: DownloadablePluginDetails;
- enableDownloadedPlugin: boolean;
+ startedByUser: boolean;
};
};
@@ -46,26 +45,17 @@ export type PluginDownloadStarted = {
};
};
-export type PluginDownloadSucceeded = {
- type: 'PLUGIN_DOWNLOAD_SUCCEEDED';
+export type PluginDownloadFinished = {
+ type: 'PLUGIN_DOWNLOAD_FINISHED';
payload: {
plugin: DownloadablePluginDetails;
};
};
-export type PluginDownloadFailed = {
- type: 'PLUGIN_DOWNLOAD_FAILED';
- payload: {
- plugin: DownloadablePluginDetails;
- error: Error;
- };
-};
-
export type Action =
| PluginDownloadStart
| PluginDownloadStarted
- | PluginDownloadSucceeded
- | PluginDownloadFailed;
+ | PluginDownloadFinished;
const INITIAL_STATE: State = {};
@@ -75,24 +65,21 @@ export default function reducer(
): State {
switch (action.type) {
case 'PLUGIN_DOWNLOAD_START': {
- const {plugin, enableDownloadedPlugin} = action.payload;
+ const {plugin, startedByUser} = 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.
- ) {
+ if (downloadState) {
+ // If download is already in progress - re-use the existing state.
return produce(state, (draft) => {
draft[plugin.dir] = {
...downloadState,
- enableDownloadedPlugin:
- enableDownloadedPlugin || downloadState.enableDownloadedPlugin,
+ startedByUser: startedByUser || downloadState.startedByUser,
};
});
}
return produce(state, (draft) => {
draft[plugin.dir] = {
plugin,
- enableDownloadedPlugin: enableDownloadedPlugin,
+ startedByUser: startedByUser,
status: PluginDownloadStatus.QUEUED,
};
});
@@ -110,29 +97,12 @@ export default function reducer(
draft[plugin.dir] = {
status: PluginDownloadStatus.STARTED,
plugin,
- enableDownloadedPlugin: downloadState.enableDownloadedPlugin,
+ startedByUser: downloadState.startedByUser,
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': {
+ case 'PLUGIN_DOWNLOAD_FINISHED': {
const {plugin} = action.payload;
return produce(state, (draft) => {
delete draft[plugin.dir];
@@ -145,7 +115,7 @@ export default function reducer(
export const startPluginDownload = (payload: {
plugin: DownloadablePluginDetails;
- enableDownloadedPlugin: boolean;
+ startedByUser: boolean;
}): Action => ({
type: 'PLUGIN_DOWNLOAD_START',
payload,
@@ -156,11 +126,6 @@ export const pluginDownloadStarted = (payload: {
cancel: Canceler;
}): Action => ({type: 'PLUGIN_DOWNLOAD_STARTED', payload});
-export const pluginDownloadSucceeded = (payload: {
+export const pluginDownloadFinished = (payload: {
plugin: DownloadablePluginDetails;
-}): Action => ({type: 'PLUGIN_DOWNLOAD_SUCCEEDED', payload});
-
-export const pluginDownloadFailed = (payload: {
- plugin: DownloadablePluginDetails;
- error: Error;
-}): Action => ({type: 'PLUGIN_DOWNLOAD_FAILED', payload});
+}): Action => ({type: 'PLUGIN_DOWNLOAD_FINISHED', payload});
diff --git a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx
index 82e7a8a39..f01a0eb68 100644
--- a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx
+++ b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx
@@ -10,7 +10,13 @@
import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
import {Badge, Button, Menu, Tooltip, Typography} from 'antd';
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 {theme, NUX, Tracked} from 'flipper-plugin';
import {useDispatch, useStore} from '../../utils/useStore';
@@ -21,11 +27,14 @@ import Client from '../../Client';
import {State} from '../../reducers';
import BaseDevice from '../../devices/BaseDevice';
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 MetroDevice from '../../devices/MetroDevice';
-import {DownloadablePluginDetails} from 'plugin-lib/lib';
-import {startPluginDownload} from '../../reducers/pluginDownloads';
+import {
+ DownloadablePluginState,
+ PluginDownloadStatus,
+ startPluginDownload,
+} from '../../reducers/pluginDownloads';
import {uninstallPlugin} from '../../reducers/pluginManager';
const {SubMenu} = Menu;
@@ -43,6 +52,7 @@ export const PluginList = memo(function PluginList({
const dispatch = useDispatch();
const connections = useStore((state) => state.connections);
const plugins = useStore((state) => state.plugins);
+ const downloads = useStore((state) => state.pluginDownloads);
const {
devicePlugins,
@@ -50,7 +60,7 @@ export const PluginList = memo(function PluginList({
enabledPlugins,
disabledPlugins,
unavailablePlugins,
- uninstalledPlugins,
+ downloadablePlugins,
} = useMemoize(computePluginLists, [
activeDevice,
metroDevice,
@@ -60,6 +70,22 @@ export const PluginList = memo(function PluginList({
]);
const isArchived = !!activeDevice?.isArchived;
+ const annotatedDownloadablePlugins = useMemoize<
+ [Record, 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(
(pluginId) => {
dispatch(
@@ -99,15 +125,15 @@ export const PluginList = memo(function PluginList({
);
const handleInstallPlugin = useCallback(
(id: string) => {
- const plugin = uninstalledPlugins.find((p) => p.id === id)!;
+ const plugin = downloadablePlugins.find((p) => p.id === id)!;
dispatch(
startPluginDownload({
plugin,
- enableDownloadedPlugin: true,
+ startedByUser: true,
}),
);
},
- [uninstalledPlugins, dispatch],
+ [downloadablePlugins, dispatch],
);
const handleUninstallPlugin = useCallback(
(id: string) => {
@@ -234,8 +260,9 @@ export const PluginList = memo(function PluginList({
- {uninstalledPlugins.map((plugin) => (
+ hint="The plugins below are supported by the selected device / application, but not installed in Flipper.
+ To install plugin, hover it and click to the 'Download' icon.">
+ {annotatedDownloadablePlugins.map(([plugin, downloadStatus]) => (
}
+ icon={
+ downloadStatus ? (
+
+ ) : (
+
+ )
+ }
/>
}
disabled
@@ -432,7 +465,7 @@ export function computePluginLists(
const enabledPlugins: ClientPluginDefinition[] = [];
const disabledPlugins: ClientPluginDefinition[] = [];
const unavailablePlugins: [plugin: PluginDetails, reason: string][] = [];
- const uninstalledPlugins: DownloadablePluginDetails[] = [];
+ const downloadablePlugins: DownloadablePluginDetails[] = [];
if (device) {
// find all device plugins that aren't part of the current device / metro
@@ -504,7 +537,7 @@ export function computePluginLists(
);
uninstalledMarketplacePlugins.forEach((plugin) => {
if (client.supportsPlugin(plugin.id)) {
- uninstalledPlugins.push(plugin);
+ downloadablePlugins.push(plugin);
} else {
unavailablePlugins.push([
plugin,
@@ -521,7 +554,7 @@ export function computePluginLists(
unavailablePlugins.sort(([a], [b]) => {
return getPluginTitle(a) > getPluginTitle(b) ? 1 : -1;
});
- uninstalledPlugins.sort((a, b) => {
+ downloadablePlugins.sort((a, b) => {
return getPluginTitle(a) > getPluginTitle(b) ? 1 : -1;
});
@@ -531,7 +564,7 @@ export function computePluginLists(
enabledPlugins,
disabledPlugins,
unavailablePlugins,
- uninstalledPlugins,
+ downloadablePlugins,
};
}
diff --git a/desktop/app/src/sandy-chrome/appinspect/__tests__/PluginList.spec.tsx b/desktop/app/src/sandy-chrome/appinspect/__tests__/PluginList.spec.tsx
index a7484d12d..76785c16f 100644
--- a/desktop/app/src/sandy-chrome/appinspect/__tests__/PluginList.spec.tsx
+++ b/desktop/app/src/sandy-chrome/appinspect/__tests__/PluginList.spec.tsx
@@ -168,7 +168,7 @@ describe('basic findBestDevice with metro present', () => {
state.connections.userStarredPlugins,
),
).toEqual({
- uninstalledPlugins: [],
+ downloadablePlugins: [],
devicePlugins: [logsPlugin],
metroPlugins: [logsPlugin],
enabledPlugins: [],
@@ -237,12 +237,12 @@ describe('basic findBestDevice with metro present', () => {
noopPlugin,
);
- const supportedUninstalledPlugin = createMockDownloadablePluginDetails({
+ const supportedDownloadablePlugin = createMockDownloadablePluginDetails({
id: 'supportedUninstalledPlugin',
title: 'Supported Uninstalled Plugin',
});
- const unsupportedUninstalledPlugin = createMockDownloadablePluginDetails({
+ const unsupportedDownloadablePlugin = createMockDownloadablePluginDetails({
id: 'unsupportedUninstalledPlugin',
title: 'Unsupported Uninstalled Plugin',
});
@@ -258,8 +258,8 @@ describe('basic findBestDevice with metro present', () => {
flipper.store.dispatch(addGatekeepedPlugins([gateKeepedPlugin]));
flipper.store.dispatch(
registerMarketplacePlugins([
- supportedUninstalledPlugin,
- unsupportedUninstalledPlugin,
+ supportedDownloadablePlugin,
+ 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",
],
[
- unsupportedUninstalledPlugin,
+ unsupportedDownloadablePlugin,
"Plugin 'Unsupported Uninstalled Plugin' is not installed in Flipper and not supported by the client application",
],
],
- uninstalledPlugins: [supportedUninstalledPlugin],
+ downloadablePlugins: [supportedDownloadablePlugin],
});
flipper.store.dispatch(
diff --git a/desktop/app/src/ui/components/Link.tsx b/desktop/app/src/ui/components/Link.tsx
index 3bbbe8796..f5f077583 100644
--- a/desktop/app/src/ui/components/Link.tsx
+++ b/desktop/app/src/ui/components/Link.tsx
@@ -30,6 +30,7 @@ export default function Link(props: {
href: string;
children?: React.ReactNode;
style?: React.CSSProperties;
+ onClick?: ((event: React.MouseEvent) => void) | undefined;
}) {
const isSandy = useIsSandy();
const onClick = useCallback(
@@ -42,9 +43,9 @@ export default function Link(props: {
);
return isSandy ? (
-
+
) : (
-
+
{props.children || props.href}
);