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 {processMessageQueue} from './utils/messageQueue';
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 ArchivedDevice from './devices/ArchivedDevice';
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)({
width: 0,
@@ -109,6 +116,7 @@ type StateFromProps = {
pendingMessages: Message[] | undefined;
pluginIsEnabled: boolean;
settingsState: Settings;
latestInstalledVersion: InstalledPluginDetails | undefined;
};
type DispatchFromProps = {
@@ -120,17 +128,24 @@ type DispatchFromProps = {
setPluginState: (payload: {pluginKey: string; state: any}) => void;
setStaticView: (payload: StaticView) => void;
starPlugin: typeof starPlugin;
activatePlugin: typeof activatePlugin;
};
type Props = StateFromProps & DispatchFromProps & OwnProps;
type State = {
progress: {current: number; total: number};
autoUpdateAlertSuppressed: Set<string>;
};
class PluginContainer extends PureComponent<Props, State> {
static contextType = ReactReduxContext;
constructor(props: Props) {
super(props);
this.reloadPlugin = this.reloadPlugin.bind(this);
}
plugin:
| FlipperPlugin<any, any, any>
| FlipperDevicePlugin<any, any, any>
@@ -160,7 +175,10 @@ class PluginContainer extends PureComponent<Props, State> {
idler?: Idler;
pluginBeingProcessed: string | null = null;
state = {progress: {current: 0, total: 0}};
state = {
progress: {current: 0, total: 0},
autoUpdateAlertSuppressed: new Set<string>(),
};
get store(): MiddlewareAPI {
return this.context.store;
@@ -200,7 +218,11 @@ class PluginContainer extends PureComponent<Props, State> {
if (pluginKey !== this.pluginBeingProcessed) {
this.pluginBeingProcessed = pluginKey;
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
if (!activePlugin || isDevicePluginDefinition(activePlugin)) {
return;
@@ -222,7 +244,11 @@ class PluginContainer extends PureComponent<Props, State> {
pluginKey,
this.store,
(progress) => {
this.setState({progress});
this.setState((state) =>
produce(state, (draft) => {
draft.progress = progress;
}),
);
},
this.idler,
).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() {
const {
pluginState,
@@ -364,12 +401,20 @@ class PluginContainer extends PureComponent<Props, State> {
selectedApp,
settingsState,
isSandy,
latestInstalledVersion,
} = this.props;
if (!activePlugin || !target || !pluginKey) {
console.warn(`No selected plugin. Rendering empty!`);
return this.renderNoPluginActive();
}
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)) {
// Make sure we throw away the container for different pluginKey!
const instance = target.sandyPluginStates.get(activePlugin.id);
@@ -438,15 +483,44 @@ class PluginContainer extends PureComponent<Props, State> {
);
}
return isSandy ? (
<Layout.Right>
<ErrorBoundary
heading={`Plugin "${
activePlugin.title || 'Unknown'
}" encountered an error during render`}>
<ContentContainer>{pluginElement}</ContentContainer>
</ErrorBoundary>
<SidebarContainer id="detailsSidebar" />
</Layout.Right>
<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>
<ErrorBoundary
heading={`Plugin "${
activePlugin.title || 'Unknown'
}" encountered an error during render`}>
<ContentContainer>{pluginElement}</ContentContainer>
</ErrorBoundary>
<SidebarContainer id="detailsSidebar" />
</Layout.Right>
</Layout.Top>
) : (
<React.Fragment>
<Container key="plugin">
@@ -475,6 +549,7 @@ export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
},
pluginStates,
plugins: {devicePlugins, clientPlugins},
pluginManager: {installedPlugins},
pluginMessageQueue,
settingsState,
}) => {
@@ -525,6 +600,9 @@ export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
pendingMessages,
pluginIsEnabled,
settingsState,
latestInstalledVersion: installedPlugins.get(
activePlugin?.packageName ?? '',
),
};
return s;
},
@@ -533,5 +611,6 @@ export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
selectPlugin,
setStaticView,
starPlugin,
activatePlugin,
},
)(PluginContainer);

View File

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

View File

@@ -36,7 +36,7 @@ import {
getUpdatablePlugins,
removePlugin,
UpdatablePluginDetails,
PluginDetails,
InstalledPluginDetails,
} from 'flipper-plugin-lib';
import {installPluginFromNpm} from 'flipper-plugin-lib';
import {State as AppState} from '../../reducers';
@@ -92,7 +92,7 @@ const RestartBar = styled(FlexColumn)({
});
type PropsFromState = {
installedPlugins: PluginDetails[];
installedPlugins: Map<string, InstalledPluginDetails>;
};
type DispatchFromProps = {
@@ -289,7 +289,7 @@ function InstallButton(props: {
function useNPMSearch(
query: string,
onInstall: () => void,
installedPlugins: PluginDetails[],
installedPlugins: Map<string, InstalledPluginDetails>,
): TableRows_immutable {
useEffect(() => {
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 pluginManager from './pluginManager';
import reactNative from './reactNative';
import pluginAutoUpdate from './fb-stubs/pluginAutoUpdate';
import pluginMarketplace from './fb-stubs/pluginMarketplace';
import pluginDownloads from './pluginDownloads';
@@ -48,7 +47,6 @@ export default function (store: Store, logger: Logger): () => Promise<void> {
user,
pluginManager,
reactNative,
pluginAutoUpdate,
pluginMarketplace,
pluginDownloads,
].filter(notNull);

View File

@@ -11,9 +11,10 @@ import {
DownloadablePluginDetails,
getInstalledPluginDetails,
getPluginVersionInstallationDir,
InstalledPluginDetails,
installPluginFromFile,
} from 'flipper-plugin-lib';
import {Store} from '../reducers/index';
import {Actions, State, Store} from '../reducers/index';
import {
PluginDownloadStatus,
pluginDownloadStarted,
@@ -26,12 +27,16 @@ import path from 'path';
import tmp from 'tmp';
import {promisify} from 'util';
import {requirePlugin} from './plugins';
import {registerPluginUpdate, setStaticView} from '../reducers/connections';
import {notification, Typography} from 'antd';
import {registerPluginUpdate, selectPlugin} from '../reducers/connections';
import {Button} from 'antd';
import React from 'react';
import {ConsoleLogs} from '../chrome/ConsoleLogs';
const {Text, Link} = Typography;
import {reportUsage} from '../utils/metrics';
import {addNotification, removeNotification} from '../reducers/notifications';
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
// used by default in Electron. Node.js implementation is better, because it
@@ -71,6 +76,7 @@ async function handlePluginDownload(
);
const tmpDir = await getTempDirName();
const tmpFile = path.join(tmpDir, `${name}-${version}.tgz`);
let installedPlugin: InstalledPluginDetails | undefined;
try {
const cancellationSource = axios.CancelToken.source();
dispatch(
@@ -80,6 +86,7 @@ async function handlePluginDownload(
console.log(
`Using existing files instead of downloading plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}"`,
);
installedPlugin = await getInstalledPluginDetails(installationDir);
} else {
await fs.ensureDir(tmpDir);
let percentCompleted = 0;
@@ -111,17 +118,19 @@ async function handlePluginDownload(
await new Promise((resolve, reject) =>
writeStream.once('finish', resolve).once('error', reject),
);
await installPluginFromFile(tmpFile);
installedPlugin = await installPluginFromFile(tmpFile);
dispatch(pluginInstalled(installedPlugin));
}
const installedPlugin = await getInstalledPluginDetails(installationDir);
if (!store.getState().plugins.clientPlugins.has(plugin.id)) {
const pluginDefinition = requirePlugin(installedPlugin);
if (pluginIsDisabledForAllConnectedClients(store.getState(), plugin)) {
dispatch(
registerPluginUpdate({
plugin: pluginDefinition,
enablePlugin: startedByUser,
activatePlugin({
plugin: installedPlugin,
enable: startedByUser,
notifyIfFailed: startedByUser,
}),
);
} else if (!isSandyEnabled()) {
notifyAboutUpdatedPluginNonSandy(installedPlugin, store.dispatch);
}
console.log(
`Successfully downloaded and installed plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
@@ -132,22 +141,80 @@ async function handlePluginDownload(
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',
});
showErrorNotification(
`Failed to download plugin "${title}" v${version}.`,
);
}
} finally {
dispatch(pluginDownloadFinished({plugin}));
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 {Logger} from '../fb-interfaces/Logger';
import {registerInstalledPlugins} from '../reducers/pluginManager';
import {
pluginActivationHandled,
registerInstalledPlugins,
} from '../reducers/pluginManager';
import {
getInstalledPlugins,
cleanupOldInstalledPluginVersions,
removePlugins,
} 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;
@@ -32,4 +39,35 @@ export default (store: Store, _logger: Logger) => {
window.requestIdleCallback(() => {
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 TooltipProvider from './ui/components/TooltipProvider';
import config from './utils/processConfig';
import appConfig from '../src/fb-stubs/config';
import {initLauncherHooks} from './utils/launcher';
import {setPersistor} from './utils/persistor';
import React from 'react';
@@ -43,7 +42,7 @@ import {
_LoggerContext,
} from 'flipper-plugin';
import isProduction from './utils/isProduction';
import ReleaseChannel from './ReleaseChannel';
import isSandyEnabled from './utils/isSandyEnabled';
if (process.env.NODE_ENV === 'development' && os.platform() === 'darwin') {
// By default Node.JS has its internal certificate storage and doesn't use
@@ -125,10 +124,7 @@ function init() {
store,
{name: 'loadTheme', fireImmediately: true, throttleMs: 500},
(state) => ({
sandy:
(GK.get('flipper_sandy') ||
appConfig.getReleaseChannel() === ReleaseChannel.INSIDERS) &&
!state.settingsState.disableSandy,
sandy: isSandyEnabled(),
dark: state.settingsState.darkMode,
}),
(theme) => {

View File

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

View File

@@ -8,13 +8,24 @@
*/
import {Actions} from './';
import {InstalledPluginDetails} from 'flipper-plugin-lib';
import {
ActivatablePluginDetails,
InstalledPluginDetails,
} from 'flipper-plugin-lib';
import {PluginDefinition} from '../plugin';
import {produce} from 'immer';
import semver from 'semver';
export type State = {
installedPlugins: InstalledPluginDetails[];
installedPlugins: Map<string, InstalledPluginDetails>;
uninstalledPlugins: Set<string>;
pluginActivationQueue: PluginActivationRequest[];
};
export type PluginActivationRequest = {
plugin: ActivatablePluginDetails;
enable: boolean;
notifyIfFailed: boolean;
};
export type Action =
@@ -26,11 +37,24 @@ export type Action =
// Implemented by rootReducer in `store.tsx`
type: 'UNINSTALL_PLUGIN';
payload: PluginDefinition;
}
| {
type: 'PLUGIN_INSTALLED';
payload: InstalledPluginDetails;
}
| {
type: 'ACTIVATE_PLUGINS';
payload: PluginActivationRequest[];
}
| {
type: 'PLUGIN_ACTIVATION_HANDLED';
payload: number;
};
const INITIAL_STATE: State = {
installedPlugins: [],
installedPlugins: new Map<string, InstalledPluginDetails>(),
uninstalledPlugins: new Set<string>(),
pluginActivationQueue: [],
};
export default function reducer(
@@ -39,10 +63,28 @@ export default function reducer(
): State {
if (action.type === 'REGISTER_INSTALLED_PLUGINS') {
return produce(state, (draft) => {
draft.installedPlugins = action.payload.filter(
(p) => !state.uninstalledPlugins?.has(p.name),
draft.installedPlugins = new Map(
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 {
return {...state};
}
@@ -59,3 +101,18 @@ export const uninstallPlugin = (payload: PluginDefinition): Action => ({
type: 'UNINSTALL_PLUGIN',
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 config from '../fb-stubs/config';
import GK from '../fb-stubs/GK';
import ReleaseChannel from '../ReleaseChannel';
import isSandyEnabled from './isSandyEnabled';
/**
* This hook returns whether dark mode is currently being used.
* Generally should be avoided in favor of using the above theme object,
* which will provide colors that reflect the theme
*/
export function useIsDarkMode(): boolean {
return useStore(
(state) =>
(GK.get('flipper_sandy') ||
config.getReleaseChannel() === ReleaseChannel.INSIDERS) &&
!state.settingsState.disableSandy &&
state.settingsState.darkMode,
);
return useStore((state) => isSandyEnabled() && state.settingsState.darkMode);
}

View File

@@ -230,6 +230,7 @@ async function getInstalledPluginVersionDirs(): Promise<
pmap(dirs, (dir) =>
fs
.readdir(dir)
.then((versionDirs) => versionDirs.filter((d) => semver.valid(d)))
.then((versionDirs) =>
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"',
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': {
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.',
@@ -115,6 +120,10 @@ if (
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.
if (argv['enable-all-gks'] === true) {
process.env.FLIPPER_ENABLE_ALL_GKS = 'true';