Sandy-based plugin auto-update UI

Summary:
New UX/UI for plugin auto-updates based on Sandy:
- disabled plugins auto-updated silently without any notifications as there is no active state for them so there is nothing to loose.
- enabled plugins can have some state and user can actually work with them, so we cannot reload them automatically. Instead, we show notification in the top of the plugin container asking user to reload the plugin when she is ready.
- if the auto-updated plugin failed to reload - show error notification.
- for non-sandy we continue using notifications as before.

Reviewed By: mweststrate

Differential Revision: D25530384

fbshipit-source-id: de3d0565ef0b930c9343b9e0ed07a4acb51885be
This commit is contained in:
Anton Nikolaev
2020-12-15 09:28:58 -08:00
committed by Facebook GitHub Bot
parent 5383017299
commit bd01b58566
15 changed files with 381 additions and 95 deletions

View File

@@ -46,10 +46,17 @@ import {Message} from './reducers/pluginMessageQueue';
import {Idler} from './utils/Idler'; import {Idler} from './utils/Idler';
import {processMessageQueue} from './utils/messageQueue'; import {processMessageQueue} from './utils/messageQueue';
import {ToggleButton, SmallText, Layout} from './ui'; import {ToggleButton, SmallText, Layout} from './ui';
import {TrackingScope, _SandyPluginRenderer} from 'flipper-plugin'; import {theme, TrackingScope, _SandyPluginRenderer} from 'flipper-plugin';
import {isDevicePluginDefinition} from './utils/pluginUtils'; import {isDevicePluginDefinition} from './utils/pluginUtils';
import ArchivedDevice from './devices/ArchivedDevice'; import ArchivedDevice from './devices/ArchivedDevice';
import {ContentContainer} from './sandy-chrome/ContentContainer'; import {ContentContainer} from './sandy-chrome/ContentContainer';
import {Alert, Typography} from 'antd';
import {InstalledPluginDetails} from 'plugin-lib';
import semver from 'semver';
import {activatePlugin} from './reducers/pluginManager';
import {produce} from 'immer';
const {Text, Link} = Typography;
const Container = styled(FlexColumn)({ const Container = styled(FlexColumn)({
width: 0, width: 0,
@@ -109,6 +116,7 @@ type StateFromProps = {
pendingMessages: Message[] | undefined; pendingMessages: Message[] | undefined;
pluginIsEnabled: boolean; pluginIsEnabled: boolean;
settingsState: Settings; settingsState: Settings;
latestInstalledVersion: InstalledPluginDetails | undefined;
}; };
type DispatchFromProps = { type DispatchFromProps = {
@@ -120,17 +128,24 @@ type DispatchFromProps = {
setPluginState: (payload: {pluginKey: string; state: any}) => void; setPluginState: (payload: {pluginKey: string; state: any}) => void;
setStaticView: (payload: StaticView) => void; setStaticView: (payload: StaticView) => void;
starPlugin: typeof starPlugin; starPlugin: typeof starPlugin;
activatePlugin: typeof activatePlugin;
}; };
type Props = StateFromProps & DispatchFromProps & OwnProps; type Props = StateFromProps & DispatchFromProps & OwnProps;
type State = { type State = {
progress: {current: number; total: number}; progress: {current: number; total: number};
autoUpdateAlertSuppressed: Set<string>;
}; };
class PluginContainer extends PureComponent<Props, State> { class PluginContainer extends PureComponent<Props, State> {
static contextType = ReactReduxContext; static contextType = ReactReduxContext;
constructor(props: Props) {
super(props);
this.reloadPlugin = this.reloadPlugin.bind(this);
}
plugin: plugin:
| FlipperPlugin<any, any, any> | FlipperPlugin<any, any, any>
| FlipperDevicePlugin<any, any, any> | FlipperDevicePlugin<any, any, any>
@@ -160,7 +175,10 @@ class PluginContainer extends PureComponent<Props, State> {
idler?: Idler; idler?: Idler;
pluginBeingProcessed: string | null = null; pluginBeingProcessed: string | null = null;
state = {progress: {current: 0, total: 0}}; state = {
progress: {current: 0, total: 0},
autoUpdateAlertSuppressed: new Set<string>(),
};
get store(): MiddlewareAPI { get store(): MiddlewareAPI {
return this.context.store; return this.context.store;
@@ -200,7 +218,11 @@ class PluginContainer extends PureComponent<Props, State> {
if (pluginKey !== this.pluginBeingProcessed) { if (pluginKey !== this.pluginBeingProcessed) {
this.pluginBeingProcessed = pluginKey; this.pluginBeingProcessed = pluginKey;
this.cancelCurrentQueue(); this.cancelCurrentQueue();
this.setState({progress: {current: 0, total: 0}}); this.setState((state) =>
produce(state, (draft) => {
draft.progress = {current: 0, total: 0};
}),
);
// device plugins don't have connections so no message queues // device plugins don't have connections so no message queues
if (!activePlugin || isDevicePluginDefinition(activePlugin)) { if (!activePlugin || isDevicePluginDefinition(activePlugin)) {
return; return;
@@ -222,7 +244,11 @@ class PluginContainer extends PureComponent<Props, State> {
pluginKey, pluginKey,
this.store, this.store,
(progress) => { (progress) => {
this.setState({progress}); this.setState((state) =>
produce(state, (draft) => {
draft.progress = progress;
}),
);
}, },
this.idler, this.idler,
).then((completed) => { ).then((completed) => {
@@ -353,6 +379,17 @@ class PluginContainer extends PureComponent<Props, State> {
); );
} }
reloadPlugin() {
const {activatePlugin, latestInstalledVersion} = this.props;
if (latestInstalledVersion) {
activatePlugin({
plugin: latestInstalledVersion,
enable: false,
notifyIfFailed: true,
});
}
}
renderPlugin() { renderPlugin() {
const { const {
pluginState, pluginState,
@@ -364,12 +401,20 @@ class PluginContainer extends PureComponent<Props, State> {
selectedApp, selectedApp,
settingsState, settingsState,
isSandy, isSandy,
latestInstalledVersion,
} = this.props; } = this.props;
if (!activePlugin || !target || !pluginKey) { if (!activePlugin || !target || !pluginKey) {
console.warn(`No selected plugin. Rendering empty!`); console.warn(`No selected plugin. Rendering empty!`);
return this.renderNoPluginActive(); return this.renderNoPluginActive();
} }
let pluginElement: null | React.ReactElement<any>; let pluginElement: null | React.ReactElement<any>;
const showUpdateAlert =
latestInstalledVersion &&
activePlugin &&
!this.state.autoUpdateAlertSuppressed.has(
`${latestInstalledVersion.name}@${latestInstalledVersion.version}`,
) &&
semver.gt(latestInstalledVersion.version, activePlugin.version);
if (isSandyPlugin(activePlugin)) { if (isSandyPlugin(activePlugin)) {
// Make sure we throw away the container for different pluginKey! // Make sure we throw away the container for different pluginKey!
const instance = target.sandyPluginStates.get(activePlugin.id); const instance = target.sandyPluginStates.get(activePlugin.id);
@@ -438,6 +483,34 @@ class PluginContainer extends PureComponent<Props, State> {
); );
} }
return isSandy ? ( return isSandy ? (
<Layout.Top>
<div>
{showUpdateAlert && (
<Alert
message={
<Text>
Plugin "{activePlugin.title}" v
{latestInstalledVersion?.version} downloaded and ready to
install. <Link onClick={this.reloadPlugin}>Reload</Link> to
start using new version.
</Text>
}
type="info"
onClose={() =>
this.setState((state) =>
produce(state, (draft) => {
draft.autoUpdateAlertSuppressed.add(
`${latestInstalledVersion?.name}@${latestInstalledVersion?.version}`,
);
}),
)
}
style={{marginBottom: theme.space.large}}
showIcon
closable
/>
)}
</div>
<Layout.Right> <Layout.Right>
<ErrorBoundary <ErrorBoundary
heading={`Plugin "${ heading={`Plugin "${
@@ -447,6 +520,7 @@ class PluginContainer extends PureComponent<Props, State> {
</ErrorBoundary> </ErrorBoundary>
<SidebarContainer id="detailsSidebar" /> <SidebarContainer id="detailsSidebar" />
</Layout.Right> </Layout.Right>
</Layout.Top>
) : ( ) : (
<React.Fragment> <React.Fragment>
<Container key="plugin"> <Container key="plugin">
@@ -475,6 +549,7 @@ export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
}, },
pluginStates, pluginStates,
plugins: {devicePlugins, clientPlugins}, plugins: {devicePlugins, clientPlugins},
pluginManager: {installedPlugins},
pluginMessageQueue, pluginMessageQueue,
settingsState, settingsState,
}) => { }) => {
@@ -525,6 +600,9 @@ export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
pendingMessages, pendingMessages,
pluginIsEnabled, pluginIsEnabled,
settingsState, settingsState,
latestInstalledVersion: installedPlugins.get(
activePlugin?.packageName ?? '',
),
}; };
return s; return s;
}, },
@@ -533,5 +611,6 @@ export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
selectPlugin, selectPlugin,
setStaticView, setStaticView,
starPlugin, starPlugin,
activatePlugin,
}, },
)(PluginContainer); )(PluginContainer);

View File

@@ -30,6 +30,7 @@ import {reportUsage} from '../utils/metrics';
import {Modal, message} from 'antd'; import {Modal, message} from 'antd';
import {Layout, withTrackingScope, _NuxManagerContext} from 'flipper-plugin'; import {Layout, withTrackingScope, _NuxManagerContext} from 'flipper-plugin';
import GK from '../fb-stubs/GK'; import GK from '../fb-stubs/GK';
import ReleaseChannel from '../ReleaseChannel';
const Container = styled(FlexColumn)({ const Container = styled(FlexColumn)({
padding: 20, padding: 20,
@@ -137,6 +138,9 @@ class SettingsSheet extends Component<Props, State> {
disableSandy, disableSandy,
darkMode, darkMode,
} = this.state.updatedSettings; } = this.state.updatedSettings;
const {releaseChannel} = this.state.updatedLauncherSettings;
const {useSandy} = this.props; const {useSandy} = this.props;
const settingsPristine = const settingsPristine =
@@ -255,7 +259,9 @@ class SettingsSheet extends Component<Props, State> {
}); });
}} }}
/> />
{GK.get('flipper_sandy') && !disableSandy && ( {(GK.get('flipper_sandy') ||
releaseChannel == ReleaseChannel.INSIDERS) &&
!disableSandy && (
<ToggledSection <ToggledSection
label="Enable dark theme (experimental)" label="Enable dark theme (experimental)"
toggled={darkMode} toggled={darkMode}

View File

@@ -36,7 +36,7 @@ import {
getUpdatablePlugins, getUpdatablePlugins,
removePlugin, removePlugin,
UpdatablePluginDetails, UpdatablePluginDetails,
PluginDetails, InstalledPluginDetails,
} from 'flipper-plugin-lib'; } from 'flipper-plugin-lib';
import {installPluginFromNpm} from 'flipper-plugin-lib'; import {installPluginFromNpm} from 'flipper-plugin-lib';
import {State as AppState} from '../../reducers'; import {State as AppState} from '../../reducers';
@@ -92,7 +92,7 @@ const RestartBar = styled(FlexColumn)({
}); });
type PropsFromState = { type PropsFromState = {
installedPlugins: PluginDetails[]; installedPlugins: Map<string, InstalledPluginDetails>;
}; };
type DispatchFromProps = { type DispatchFromProps = {
@@ -289,7 +289,7 @@ function InstallButton(props: {
function useNPMSearch( function useNPMSearch(
query: string, query: string,
onInstall: () => void, onInstall: () => void,
installedPlugins: PluginDetails[], installedPlugins: Map<string, InstalledPluginDetails>,
): TableRows_immutable { ): TableRows_immutable {
useEffect(() => { useEffect(() => {
reportUsage(`${TAG}:open`); reportUsage(`${TAG}:open`);

View File

@@ -1,12 +0,0 @@
/**
* 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
*/
export default () => {
// Auto-updates of plugins not implemented in public version of Flipper
};

View File

@@ -20,7 +20,6 @@ import plugins from './plugins';
import user from './user'; import user from './user';
import pluginManager from './pluginManager'; import pluginManager from './pluginManager';
import reactNative from './reactNative'; import reactNative from './reactNative';
import pluginAutoUpdate from './fb-stubs/pluginAutoUpdate';
import pluginMarketplace from './fb-stubs/pluginMarketplace'; import pluginMarketplace from './fb-stubs/pluginMarketplace';
import pluginDownloads from './pluginDownloads'; import pluginDownloads from './pluginDownloads';
@@ -48,7 +47,6 @@ export default function (store: Store, logger: Logger): () => Promise<void> {
user, user,
pluginManager, pluginManager,
reactNative, reactNative,
pluginAutoUpdate,
pluginMarketplace, pluginMarketplace,
pluginDownloads, pluginDownloads,
].filter(notNull); ].filter(notNull);

View File

@@ -11,9 +11,10 @@ import {
DownloadablePluginDetails, DownloadablePluginDetails,
getInstalledPluginDetails, getInstalledPluginDetails,
getPluginVersionInstallationDir, getPluginVersionInstallationDir,
InstalledPluginDetails,
installPluginFromFile, installPluginFromFile,
} from 'flipper-plugin-lib'; } from 'flipper-plugin-lib';
import {Store} from '../reducers/index'; import {Actions, State, Store} from '../reducers/index';
import { import {
PluginDownloadStatus, PluginDownloadStatus,
pluginDownloadStarted, pluginDownloadStarted,
@@ -26,12 +27,16 @@ 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, setStaticView} from '../reducers/connections'; import {registerPluginUpdate, selectPlugin} from '../reducers/connections';
import {notification, Typography} from 'antd'; import {Button} from 'antd';
import React from 'react'; import React from 'react';
import {ConsoleLogs} from '../chrome/ConsoleLogs'; import {reportUsage} from '../utils/metrics';
import {addNotification, removeNotification} from '../reducers/notifications';
const {Text, Link} = Typography; import reloadFlipper from '../utils/reloadFlipper';
import {activatePlugin, pluginInstalled} from '../reducers/pluginManager';
import {Dispatch} from 'redux';
import {showErrorNotification} from '../utils/notifications';
import isSandyEnabled from '../utils/isSandyEnabled';
// 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
@@ -71,6 +76,7 @@ async function handlePluginDownload(
); );
const tmpDir = await getTempDirName(); const tmpDir = await getTempDirName();
const tmpFile = path.join(tmpDir, `${name}-${version}.tgz`); const tmpFile = path.join(tmpDir, `${name}-${version}.tgz`);
let installedPlugin: InstalledPluginDetails | undefined;
try { try {
const cancellationSource = axios.CancelToken.source(); const cancellationSource = axios.CancelToken.source();
dispatch( dispatch(
@@ -80,6 +86,7 @@ async function handlePluginDownload(
console.log( console.log(
`Using existing files instead of downloading plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}"`, `Using existing files instead of downloading plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}"`,
); );
installedPlugin = await getInstalledPluginDetails(installationDir);
} else { } else {
await fs.ensureDir(tmpDir); await fs.ensureDir(tmpDir);
let percentCompleted = 0; let percentCompleted = 0;
@@ -111,17 +118,19 @@ async function handlePluginDownload(
await new Promise((resolve, reject) => await new Promise((resolve, reject) =>
writeStream.once('finish', resolve).once('error', reject), writeStream.once('finish', resolve).once('error', reject),
); );
await installPluginFromFile(tmpFile); installedPlugin = await installPluginFromFile(tmpFile);
dispatch(pluginInstalled(installedPlugin));
} }
const installedPlugin = await getInstalledPluginDetails(installationDir); if (pluginIsDisabledForAllConnectedClients(store.getState(), plugin)) {
if (!store.getState().plugins.clientPlugins.has(plugin.id)) {
const pluginDefinition = requirePlugin(installedPlugin);
dispatch( dispatch(
registerPluginUpdate({ activatePlugin({
plugin: pluginDefinition, plugin: installedPlugin,
enablePlugin: startedByUser, enable: startedByUser,
notifyIfFailed: startedByUser,
}), }),
); );
} else if (!isSandyEnabled()) {
notifyAboutUpdatedPluginNonSandy(installedPlugin, store.dispatch);
} }
console.log( console.log(
`Successfully downloaded and installed plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`, `Successfully downloaded and installed plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
@@ -132,22 +141,80 @@ async function handlePluginDownload(
error, error,
); );
if (startedByUser) { if (startedByUser) {
notification.error({ showErrorNotification(
message: `Failed to install plugin "${title}".`, `Failed to download plugin "${title}" v${version}.`,
description: ( );
<Text>
See{' '}
<Link onClick={() => dispatch(setStaticView(ConsoleLogs))}>
logs
</Link>{' '}
for details.
</Text>
),
placement: 'bottomLeft',
});
} }
} finally { } finally {
dispatch(pluginDownloadFinished({plugin})); dispatch(pluginDownloadFinished({plugin}));
await fs.remove(tmpDir); await fs.remove(tmpDir);
} }
} }
function pluginIsDisabledForAllConnectedClients(
state: State,
plugin: DownloadablePluginDetails,
) {
return (
!state.plugins.clientPlugins.has(plugin.id) ||
!state.connections.clients.some((c) =>
state.connections.userStarredPlugins[c.query.app]?.includes(plugin.id),
)
);
}
function notifyAboutUpdatedPluginNonSandy(
plugin: InstalledPluginDetails,
dispatch: Dispatch<Actions>,
) {
const {name, version, title, id} = plugin;
const reloadPluginAndRemoveNotification = () => {
reportUsage('plugin-auto-update:notification:reloadClicked', undefined, id);
dispatch(
registerPluginUpdate({
plugin: requirePlugin(plugin),
enablePlugin: false,
}),
);
dispatch(
removeNotification({
pluginId: 'plugin-auto-update',
client: null,
notificationId: `auto-update.${name}.${version}`,
}),
);
dispatch(
selectPlugin({
selectedPlugin: id,
deepLinkPayload: null,
}),
);
};
const reloadAll = () => {
reportUsage('plugin-auto-update:notification:reloadAllClicked');
reloadFlipper();
};
dispatch(
addNotification({
pluginId: 'plugin-auto-update',
client: null,
notification: {
id: `auto-update.${name}.${version}`,
title: `${title} ${version} is ready to install`,
message: (
<div>
{title} {version} has been downloaded. Reload is required to apply
the update.{' '}
<Button onClick={reloadPluginAndRemoveNotification}>
Reload Plugin
</Button>
<Button onClick={reloadAll}>Reload Flipper</Button>
</div>
),
severity: 'warning',
timestamp: Date.now(),
category: `Plugin Auto Update`,
},
}),
);
}

View File

@@ -9,12 +9,19 @@
import {Store} from '../reducers/index'; import {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger'; import {Logger} from '../fb-interfaces/Logger';
import {registerInstalledPlugins} from '../reducers/pluginManager'; import {
pluginActivationHandled,
registerInstalledPlugins,
} from '../reducers/pluginManager';
import { import {
getInstalledPlugins, getInstalledPlugins,
cleanupOldInstalledPluginVersions, cleanupOldInstalledPluginVersions,
removePlugins, removePlugins,
} from 'flipper-plugin-lib'; } from 'flipper-plugin-lib';
import {sideEffect} from '../utils/sideEffect';
import {requirePlugin} from './plugins';
import {registerPluginUpdate} from '../reducers/connections';
import {showErrorNotification} from '../utils/notifications';
const maxInstalledPluginVersionsToKeep = 2; const maxInstalledPluginVersionsToKeep = 2;
@@ -32,4 +39,35 @@ export default (store: Store, _logger: Logger) => {
window.requestIdleCallback(() => { window.requestIdleCallback(() => {
refreshInstalledPlugins(store); refreshInstalledPlugins(store);
}); });
sideEffect(
store,
{name: 'handlePluginActivation', throttleMs: 1000, fireImmediately: true},
(state) => state.pluginManager.pluginActivationQueue,
(queue, store) => {
for (const request of queue) {
try {
const plugin = requirePlugin(request.plugin);
const enablePlugin = request.enable;
store.dispatch(
registerPluginUpdate({
plugin,
enablePlugin,
}),
);
} catch (err) {
console.error(
`Failed to activate plugin ${request.plugin.title} v${request.plugin.version}`,
err,
);
if (request.notifyIfFailed) {
showErrorNotification(
`Failed to load plugin "${request.plugin.title}" v${request.plugin.version}`,
);
}
}
}
store.dispatch(pluginActivationHandled(queue.length));
},
);
}; };

View File

@@ -20,7 +20,6 @@ import {Store} from './reducers/index';
import dispatcher from './dispatcher/index'; import dispatcher from './dispatcher/index';
import TooltipProvider from './ui/components/TooltipProvider'; import TooltipProvider from './ui/components/TooltipProvider';
import config from './utils/processConfig'; import config from './utils/processConfig';
import appConfig from '../src/fb-stubs/config';
import {initLauncherHooks} from './utils/launcher'; import {initLauncherHooks} from './utils/launcher';
import {setPersistor} from './utils/persistor'; import {setPersistor} from './utils/persistor';
import React from 'react'; import React from 'react';
@@ -43,7 +42,7 @@ import {
_LoggerContext, _LoggerContext,
} from 'flipper-plugin'; } from 'flipper-plugin';
import isProduction from './utils/isProduction'; import isProduction from './utils/isProduction';
import ReleaseChannel from './ReleaseChannel'; import isSandyEnabled from './utils/isSandyEnabled';
if (process.env.NODE_ENV === 'development' && os.platform() === 'darwin') { if (process.env.NODE_ENV === 'development' && os.platform() === 'darwin') {
// By default Node.JS has its internal certificate storage and doesn't use // By default Node.JS has its internal certificate storage and doesn't use
@@ -125,10 +124,7 @@ function init() {
store, store,
{name: 'loadTheme', fireImmediately: true, throttleMs: 500}, {name: 'loadTheme', fireImmediately: true, throttleMs: 500},
(state) => ({ (state) => ({
sandy: sandy: isSandyEnabled(),
(GK.get('flipper_sandy') ||
appConfig.getReleaseChannel() === ReleaseChannel.INSIDERS) &&
!state.settingsState.disableSandy,
dark: state.settingsState.darkMode, dark: state.settingsState.darkMode,
}), }),
(theme) => { (theme) => {

View File

@@ -12,7 +12,7 @@ import {InstalledPluginDetails} from 'flipper-plugin-lib';
test('reduce empty registerInstalledPlugins', () => { test('reduce empty registerInstalledPlugins', () => {
const result = reducer(undefined, registerInstalledPlugins([])); const result = reducer(undefined, registerInstalledPlugins([]));
expect(result.installedPlugins).toEqual([]); expect(result.installedPlugins).toEqual(new Map());
}); });
const EXAMPLE_PLUGIN = { const EXAMPLE_PLUGIN = {
@@ -32,7 +32,9 @@ const EXAMPLE_PLUGIN = {
test('reduce registerInstalledPlugins, clear again', () => { test('reduce registerInstalledPlugins, clear again', () => {
const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN])); const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN]));
expect(result.installedPlugins).toEqual([EXAMPLE_PLUGIN]); expect(result.installedPlugins).toEqual(
new Map([[EXAMPLE_PLUGIN.name, EXAMPLE_PLUGIN]]),
);
const result2 = reducer(result, registerInstalledPlugins([])); const result2 = reducer(result, registerInstalledPlugins([]));
expect(result2.installedPlugins).toEqual([]); expect(result2.installedPlugins).toEqual(new Map());
}); });

View File

@@ -8,13 +8,24 @@
*/ */
import {Actions} from './'; import {Actions} from './';
import {InstalledPluginDetails} from 'flipper-plugin-lib'; import {
ActivatablePluginDetails,
InstalledPluginDetails,
} from 'flipper-plugin-lib';
import {PluginDefinition} from '../plugin'; import {PluginDefinition} from '../plugin';
import {produce} from 'immer'; import {produce} from 'immer';
import semver from 'semver';
export type State = { export type State = {
installedPlugins: InstalledPluginDetails[]; installedPlugins: Map<string, InstalledPluginDetails>;
uninstalledPlugins: Set<string>; uninstalledPlugins: Set<string>;
pluginActivationQueue: PluginActivationRequest[];
};
export type PluginActivationRequest = {
plugin: ActivatablePluginDetails;
enable: boolean;
notifyIfFailed: boolean;
}; };
export type Action = export type Action =
@@ -26,11 +37,24 @@ export type Action =
// Implemented by rootReducer in `store.tsx` // Implemented by rootReducer in `store.tsx`
type: 'UNINSTALL_PLUGIN'; type: 'UNINSTALL_PLUGIN';
payload: PluginDefinition; payload: PluginDefinition;
}
| {
type: 'PLUGIN_INSTALLED';
payload: InstalledPluginDetails;
}
| {
type: 'ACTIVATE_PLUGINS';
payload: PluginActivationRequest[];
}
| {
type: 'PLUGIN_ACTIVATION_HANDLED';
payload: number;
}; };
const INITIAL_STATE: State = { const INITIAL_STATE: State = {
installedPlugins: [], installedPlugins: new Map<string, InstalledPluginDetails>(),
uninstalledPlugins: new Set<string>(), uninstalledPlugins: new Set<string>(),
pluginActivationQueue: [],
}; };
export default function reducer( export default function reducer(
@@ -39,10 +63,28 @@ export default function reducer(
): State { ): State {
if (action.type === 'REGISTER_INSTALLED_PLUGINS') { if (action.type === 'REGISTER_INSTALLED_PLUGINS') {
return produce(state, (draft) => { return produce(state, (draft) => {
draft.installedPlugins = action.payload.filter( draft.installedPlugins = new Map(
(p) => !state.uninstalledPlugins?.has(p.name), action.payload
.filter((p) => !state.uninstalledPlugins?.has(p.name))
.map((p) => [p.name, p]),
); );
}); });
} else if (action.type === 'PLUGIN_INSTALLED') {
const plugin = action.payload;
return produce(state, (draft) => {
const existing = draft.installedPlugins.get(plugin.name);
if (!existing || semver.gt(plugin.version, existing.version)) {
draft.installedPlugins.set(plugin.name, plugin);
}
});
} else if (action.type === 'ACTIVATE_PLUGINS') {
return produce(state, (draft) => {
draft.pluginActivationQueue.push(...action.payload);
});
} else if (action.type === 'PLUGIN_ACTIVATION_HANDLED') {
return produce(state, (draft) => {
draft.pluginActivationQueue.splice(0, action.payload);
});
} else { } else {
return {...state}; return {...state};
} }
@@ -59,3 +101,18 @@ export const uninstallPlugin = (payload: PluginDefinition): Action => ({
type: 'UNINSTALL_PLUGIN', type: 'UNINSTALL_PLUGIN',
payload, payload,
}); });
export const pluginInstalled = (payload: InstalledPluginDetails): Action => ({
type: 'PLUGIN_INSTALLED',
payload,
});
export const activatePlugin = (payload: PluginActivationRequest): Action => ({
type: 'ACTIVATE_PLUGINS',
payload: [payload],
});
export const pluginActivationHandled = (payload: number): Action => ({
type: 'PLUGIN_ACTIVATION_HANDLED',
payload,
});

View File

@@ -0,0 +1,21 @@
/**
* 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 config from '../fb-stubs/config';
import GK from '../fb-stubs/GK';
import ReleaseChannel from '../ReleaseChannel';
import {store} from '../store';
export default function isSandyEnabled() {
return (
(GK.get('flipper_sandy') ||
config.getReleaseChannel() === ReleaseChannel.INSIDERS) &&
!store.getState().settingsState.disableSandy
);
}

View File

@@ -0,0 +1,32 @@
/**
* 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 {notification, Typography} from 'antd';
import React from 'react';
import {ConsoleLogs} from '../chrome/ConsoleLogs';
import {setStaticView} from '../reducers/connections';
import {store} from '../store';
const {Text, Link} = Typography;
export function showErrorNotification(message: string) {
notification.error({
message,
description: (
<Text>
See{' '}
<Link onClick={() => store.dispatch(setStaticView(ConsoleLogs))}>
logs
</Link>{' '}
for details.
</Text>
),
placement: 'bottomLeft',
});
}

View File

@@ -8,20 +8,12 @@
*/ */
import {useStore} from '../../../app/src/utils/useStore'; import {useStore} from '../../../app/src/utils/useStore';
import config from '../fb-stubs/config'; import isSandyEnabled from './isSandyEnabled';
import GK from '../fb-stubs/GK';
import ReleaseChannel from '../ReleaseChannel';
/** /**
* This hook returns whether dark mode is currently being used. * This hook returns whether dark mode is currently being used.
* Generally should be avoided in favor of using the above theme object, * Generally should be avoided in favor of using the above theme object,
* which will provide colors that reflect the theme * which will provide colors that reflect the theme
*/ */
export function useIsDarkMode(): boolean { export function useIsDarkMode(): boolean {
return useStore( return useStore((state) => isSandyEnabled() && state.settingsState.darkMode);
(state) =>
(GK.get('flipper_sandy') ||
config.getReleaseChannel() === ReleaseChannel.INSIDERS) &&
!state.settingsState.disableSandy &&
state.settingsState.darkMode,
);
} }

View File

@@ -230,6 +230,7 @@ async function getInstalledPluginVersionDirs(): Promise<
pmap(dirs, (dir) => pmap(dirs, (dir) =>
fs fs
.readdir(dir) .readdir(dir)
.then((versionDirs) => versionDirs.filter((d) => semver.valid(d)))
.then((versionDirs) => .then((versionDirs) =>
versionDirs.sort((v1, v2) => semver.compare(v2, v1, true)), versionDirs.sort((v1, v2) => semver.compare(v2, v1, true)),
) )

View File

@@ -50,6 +50,11 @@ const argv = yargs
'[FB-internal only] Enable plugin auto-updates. The flag is disabled by default in dev mode. Env var FLIPPER_NO_PLUGIN_AUTO_UPDATE is equivalent to the command-line option "--no-plugin-auto-update"', '[FB-internal only] Enable plugin auto-updates. The flag is disabled by default in dev mode. Env var FLIPPER_NO_PLUGIN_AUTO_UPDATE is equivalent to the command-line option "--no-plugin-auto-update"',
type: 'boolean', type: 'boolean',
}, },
'plugin-auto-update-interval': {
describe:
'[FB-internal only] Set custom interval in milliseconds for plugin auto-update checks. Env var FLIPPER_PLUGIN_AUTO_UPDATE_POLLING_INTERVAL is equivalent to this command-line option.',
type: 'number',
},
'enabled-plugins': { 'enabled-plugins': {
describe: describe:
'Load only specified plugins and skip loading rest. This is useful when you are developing only one or few plugins. Plugins to load can be specified as a comma-separated list with either plugin id or name used as identifier, e.g. "--enabled-plugins network,inspector". The flag is not provided by default which means that all plugins loaded.', 'Load only specified plugins and skip loading rest. This is useful when you are developing only one or few plugins. Plugins to load can be specified as a comma-separated list with either plugin id or name used as identifier, e.g. "--enabled-plugins network,inspector". The flag is not provided by default which means that all plugins loaded.',
@@ -115,6 +120,10 @@ if (
process.env.FLIPPER_DISABLE_PLUGIN_AUTO_UPDATE = 'true'; process.env.FLIPPER_DISABLE_PLUGIN_AUTO_UPDATE = 'true';
} }
if (argv['plugin-auto-update-interval']) {
process.env.FLIPPER_PLUGIN_AUTO_UPDATE_POLLING_INTERVAL = `${argv['plugin-auto-update-interval']}`;
}
// Force participating in all GKs. Mostly intersting for Flipper team members. // Force participating in all GKs. Mostly intersting for Flipper team members.
if (argv['enable-all-gks'] === true) { if (argv['enable-all-gks'] === true) {
process.env.FLIPPER_ENABLE_ALL_GKS = 'true'; process.env.FLIPPER_ENABLE_ALL_GKS = 'true';