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:
committed by
Facebook GitHub Bot
parent
5383017299
commit
bd01b58566
@@ -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,15 +483,44 @@ class PluginContainer extends PureComponent<Props, State> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return isSandy ? (
|
return isSandy ? (
|
||||||
<Layout.Right>
|
<Layout.Top>
|
||||||
<ErrorBoundary
|
<div>
|
||||||
heading={`Plugin "${
|
{showUpdateAlert && (
|
||||||
activePlugin.title || 'Unknown'
|
<Alert
|
||||||
}" encountered an error during render`}>
|
message={
|
||||||
<ContentContainer>{pluginElement}</ContentContainer>
|
<Text>
|
||||||
</ErrorBoundary>
|
Plugin "{activePlugin.title}" v
|
||||||
<SidebarContainer id="detailsSidebar" />
|
{latestInstalledVersion?.version} downloaded and ready to
|
||||||
</Layout.Right>
|
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>
|
||||||
|
<ErrorBoundary
|
||||||
|
heading={`Plugin "${
|
||||||
|
activePlugin.title || 'Unknown'
|
||||||
|
}" encountered an error during render`}>
|
||||||
|
<ContentContainer>{pluginElement}</ContentContainer>
|
||||||
|
</ErrorBoundary>
|
||||||
|
<SidebarContainer id="detailsSidebar" />
|
||||||
|
</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);
|
||||||
|
|||||||
@@ -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,20 +259,22 @@ class SettingsSheet extends Component<Props, State> {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{GK.get('flipper_sandy') && !disableSandy && (
|
{(GK.get('flipper_sandy') ||
|
||||||
<ToggledSection
|
releaseChannel == ReleaseChannel.INSIDERS) &&
|
||||||
label="Enable dark theme (experimental)"
|
!disableSandy && (
|
||||||
toggled={darkMode}
|
<ToggledSection
|
||||||
onChange={(enabled) => {
|
label="Enable dark theme (experimental)"
|
||||||
this.setState((prevState) => ({
|
toggled={darkMode}
|
||||||
updatedSettings: {
|
onChange={(enabled) => {
|
||||||
...prevState.updatedSettings,
|
this.setState((prevState) => ({
|
||||||
darkMode: enabled,
|
updatedSettings: {
|
||||||
},
|
...prevState.updatedSettings,
|
||||||
}));
|
darkMode: enabled,
|
||||||
}}
|
},
|
||||||
/>
|
}));
|
||||||
)}
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ToggledSection
|
<ToggledSection
|
||||||
label="React Native keyboard shortcuts"
|
label="React Native keyboard shortcuts"
|
||||||
toggled={reactNative.shortcuts.enabled}
|
toggled={reactNative.shortcuts.enabled}
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
|||||||
21
desktop/app/src/utils/isSandyEnabled.tsx
Normal file
21
desktop/app/src/utils/isSandyEnabled.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
32
desktop/app/src/utils/notifications.tsx
Normal file
32
desktop/app/src/utils/notifications.tsx
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user