Files
flipper/src/chrome/PluginInstaller.tsx
John Knox 0479309dd1 Don't fail when dir doesn't exist
Summary:
Noticed a bunch of failures [here](https://our.intern.facebook.com/intern/scuba/query/?dataset=flipper_session_event_stats_scuba&drillstate=%7B%22sampleCols%22%3A[%22errors%22%2C%22event%22]%2C%22cols%22%3A[]%2C%22derivedCols%22%3A[%7B%22name%22%3A%22perfect_session%22%2C%22sql%22%3A%22CAST_AS_DOUBLE(no_failures)%22%2C%22type%22%3A%22Numeric%22%7D]%2C%22mappedCols%22%3A[]%2C%22enumCols%22%3A[]%2C%22return_remainder%22%3Afalse%2C%22should_pivot%22%3Afalse%2C%22is_timeseries%22%3Atrue%2C%22hideEmptyColumns%22%3Afalse%2C%22start%22%3A%22-90%20days%22%2C%22samplingRatio%22%3A1%2C%22compare%22%3A%22comparison%22%2C%22axes%22%3A%22linked%22%2C%22bucket%22%3A%221%22%2C%22overlay_types%22%3A[]%2C%22minBucketSamples%22%3A%22%22%2C%22dimensions%22%3A[%22time%22%2C%22event%22]%2C%22scale_type%22%3A%22absolute%22%2C%22num_samples%22%3A%22100%22%2C%22metric%22%3A%22avg%22%2C%22fill_missing_buckets%22%3A%22connect%22%2C%22smoothing_bucket%22%3A%221%22%2C%22top%22%3A20%2C%22markers%22%3A%22%22%2C%22timezone%22%3A%22America%2FLos_Angeles%22%2C%22end%22%3A%22now%22%2C%22time_bucket%22%3A%22604800%22%2C%22compare_mode%22%3A%22normal%22%2C%22aggregateList%22%3A[]%2C%22param_dimensions%22%3A[]%2C%22modifiers%22%3A[]%2C%22order%22%3A%22none%22%2C%22order_desc%22%3Atrue%2C%22filterMode%22%3A%22DEFAULT%22%2C%22constraints%22%3A[[%7B%22column%22%3A%22plugin%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A[%22[%5C%22null%5C%22]%22]%7D%2C%7B%22column%22%3A%22is_headless%22%2C%22op%22%3A%22!substr%22%2C%22value%22%3A[%22[%5C%22true%5C%22]%22]%7D%2C%7B%22column%22%3A%22event%22%2C%22op%22%3A%22substr%22%2C%22value%22%3A[%22[%5C%22getInstalledPlugins%5C%22]%22]%7D]]%2C%22c_constraints%22%3A[[]]%2C%22b_constraints%22%3A[[]]%2C%22metrik_view_params%22%3A%7B%22xaxis_type%22%3A%22auto%22%2C%22should_use_legacy_colors%22%3Afalse%2C%22view%22%3A%22Samples%22%2C%22width%22%3A%222126%22%2C%22height%22%3A%221132%22%2C%22y_max_hint%22%3A%221%22%2C%22yaxis_settings%22%3A[%7B%22yaxis_title%22%3A%22%22%2C%22yaxis_series_name%22%3A%22%22%2C%22yaxis_width%22%3A%220%22%2C%22yaxis_format%22%3A%22%25P%22%7D]%2C%22tooltip_outside%22%3Atrue%2C%22state%22%3A%22published%22%2C%22use_y_axis_hints_as_limits%22%3Atrue%2C%22legend_mode%22%3A%22nongrid%22%2C%22connect_nulls%22%3Atrue%2C%22yaxismin%22%3A0%2C%22title%22%3A%22Operation%20success%20rate%20over%20time%20(human)%22%2C%22tooltip_disabled%22%3Atrue%2C%22timezone_offset%22%3A420%2C%22y_min_hint%22%3A0%2C%22legend_position%22%3A%22none%22%2C%22title_use_v2%22%3Atrue%7D%7D&pool=uber&view=Samples&dashboard_id&tab_id&widget_id&widget_piece_id).

This fixes it.

Reviewed By: passy

Differential Revision: D17877108

fbshipit-source-id: 85586a1ce65033b98d793746c611b44f68e13eff
2019-10-11 06:21:47 -07:00

387 lines
9.6 KiB
TypeScript

/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import {
FlexColumn,
styled,
ManagedTable_immutable,
Toolbar,
SearchInput,
SearchBox,
Button,
colors,
Spacer,
TableRows_immutable,
FlexRow,
Glyph,
Link,
Text,
LoadingIndicator,
Tooltip,
} from 'flipper';
import React, {useCallback, useState, useMemo, useEffect} from 'react';
import {remote} from 'electron';
import {List} from 'immutable';
import algoliasearch from 'algoliasearch';
import path from 'path';
import fs from 'fs-extra';
import {promisify} from 'util';
import {homedir} from 'os';
import {PluginManager as PM} from 'live-plugin-manager';
import {reportPlatformFailures, reportUsage} from '../utils/metrics';
const PLUGIN_DIR = path.join(homedir(), '.flipper', 'thirdparty');
const ALGOLIA_APPLICATION_ID = 'OFCNCOG2CU';
const ALGOLIA_API_KEY = 'f54e21fa3a2a0160595bb058179bfb1e';
const TAG = 'PluginInstaller';
const PluginManager = new PM({
ignoredDependencies: ['flipper', 'react', 'react-dom', '@types/*'],
});
export type PluginDefinition = {
name: string;
version: string;
description: string;
};
const EllipsisText = styled(Text)({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
const columnSizes = {
name: '25%',
version: '10%',
description: 'flex',
install: '15%',
};
const columns = {
name: {
value: 'Name',
},
version: {
value: 'Version',
},
description: {
value: 'Description',
},
install: {
value: '',
},
};
const Container = styled(FlexColumn)({
height: 300,
backgroundColor: colors.white,
borderRadius: 4,
overflow: 'hidden',
border: `1px solid ${colors.macOSTitleBarButtonBorder}`,
});
const RestartBar = styled(FlexColumn)({
backgroundColor: colors.red,
color: colors.white,
fontWeight: 500,
padding: 10,
cursor: 'pointer',
textAlign: 'center',
});
type Props = {
searchIndexFactory: () => algoliasearch.Index;
getInstalledPlugins: () => Promise<Map<string, PluginDefinition>>;
autoHeight: boolean;
};
const defaultProps: Props = {
searchIndexFactory: () => {
const client = algoliasearch(ALGOLIA_APPLICATION_ID, ALGOLIA_API_KEY);
return client.initIndex('npm-search');
},
getInstalledPlugins: _getInstalledPlugins,
autoHeight: false,
};
const PluginInstaller = function props(props: Props) {
const [restartRequired, setRestartRequired] = useState(false);
const [query, setQuery] = useState('');
const rows = useNPMSearch(
setRestartRequired,
query,
setQuery,
props.searchIndexFactory,
props.getInstalledPlugins,
);
const restartApp = useCallback(() => {
remote.app.relaunch();
remote.app.exit();
}, []);
return (
<Container>
{restartRequired && (
<RestartBar onClick={restartApp}>
To activate this plugin, Flipper needs to restart. Click here to
restart!
</RestartBar>
)}
<Toolbar>
<SearchBox>
<SearchInput
onChange={e => setQuery(e.target.value)}
value={query}
placeholder="Search Flipper plugins..."
/>
</SearchBox>
</Toolbar>
<ManagedTable_immutable
rowLineHeight={28}
floating={false}
multiline={true}
columnSizes={columnSizes}
columns={columns}
highlightableRows={false}
highlightedRows={new Set()}
autoHeight={props.autoHeight}
rows={rows}
/>
</Container>
);
};
PluginInstaller.defaultProps = defaultProps;
export default PluginInstaller;
const TableButton = styled(Button)({
marginTop: 2,
});
const Spinner = styled(LoadingIndicator)({
marginTop: 6,
});
const AlignedGlyph = styled(Glyph)({
marginTop: 6,
});
function InstallButton(props: {
name: string;
version: string;
onInstall: () => void;
installed: boolean;
}) {
type InstallAction =
| {kind: 'Install'}
| {kind: 'Waiting'}
| {kind: 'Remove'}
| {kind: 'Error'; error: string};
const catchError = (fn: () => Promise<void>) => async () => {
try {
await fn();
} catch (err) {
setAction({kind: 'Error', error: err.toString()});
}
};
const onInstall = useCallback(
catchError(async () => {
reportUsage(`${TAG}:install`, undefined, props.name);
setAction({kind: 'Waiting'});
await fs.ensureDir(PLUGIN_DIR);
// create empty watchman config (required by metro's file watcher)
await fs.writeFile(path.join(PLUGIN_DIR, '.watchmanconfig'), '{}');
// install the plugin and all it's dependencies into node_modules
PluginManager.options.pluginsPath = path.join(
PLUGIN_DIR,
props.name,
'node_modules',
);
await PluginManager.install(props.name);
// move the plugin itself out of the node_modules folder
const pluginDir = path.join(
PLUGIN_DIR,
props.name,
'node_modules',
props.name,
);
const pluginFiles = await fs.readdir(pluginDir);
await Promise.all(
pluginFiles.map(f =>
fs.move(path.join(pluginDir, f), path.join(pluginDir, '..', '..', f)),
),
);
props.onInstall();
setAction({kind: 'Remove'});
}),
[props.name, props.version],
);
const onRemove = useCallback(
catchError(async () => {
reportUsage(`${TAG}:remove`, undefined, props.name);
setAction({kind: 'Waiting'});
await fs.remove(path.join(PLUGIN_DIR, props.name));
props.onInstall();
setAction({kind: 'Install'});
}),
[props.name],
);
const [action, setAction] = useState<InstallAction>(
props.installed ? {kind: 'Remove'} : {kind: 'Install'},
);
if (action.kind === 'Waiting') {
return <Spinner size={16} />;
}
if (action.kind === 'Error') {
const glyph = (
<AlignedGlyph color={colors.orange} size={16} name="caution-triangle" />
);
return (
<Tooltip
options={{position: 'toRight'}}
title={`Something went wrong: ${action.error}`}
children={glyph}
/>
);
}
return (
<TableButton
compact
type={action.kind === 'Install' ? 'primary' : undefined}
onClick={
action.kind === 'Install'
? () => reportPlatformFailures(onInstall(), `${TAG}:install`)
: () => reportPlatformFailures(onRemove(), `${TAG}:remove`)
}>
{action.kind}
</TableButton>
);
}
function useNPMSearch(
setRestartRequired: (restart: boolean) => void,
query: string,
setQuery: (query: string) => void,
searchClientFactory: () => algoliasearch.Index,
getInstalledPlugins: () => Promise<Map<string, PluginDefinition>>,
): TableRows_immutable {
const index = useMemo(searchClientFactory, []);
const [installedPlugins, setInstalledPlugins] = useState(
new Map<string, PluginDefinition>(),
);
const getAndSetInstalledPlugins = () =>
reportPlatformFailures(
getInstalledPlugins(),
`${TAG}:getInstalledPlugins`,
).then(setInstalledPlugins);
useEffect(() => {
reportUsage(`${TAG}:open`);
getAndSetInstalledPlugins();
}, []);
const onInstall = useCallback(async () => {
getAndSetInstalledPlugins();
setRestartRequired(true);
}, []);
const createRow = useCallback(
(h: PluginDefinition) => ({
key: h.name,
columns: {
name: {value: <EllipsisText>{h.name}</EllipsisText>},
version: {
value: <EllipsisText>{h.version}</EllipsisText>,
align: 'flex-end' as 'flex-end',
},
description: {
value: (
<FlexRow grow>
<EllipsisText>{h.description}</EllipsisText>
<Spacer />
<Link href={`https://yarnpkg.com/en/package/${h.name}`}>
<Glyph color={colors.light20} name="info-circle" size={16} />
</Link>
</FlexRow>
),
},
install: {
value: (
<InstallButton
name={h.name}
version={h.version}
onInstall={onInstall}
installed={installedPlugins.has(h.name)}
/>
),
align: 'center' as 'center',
},
},
}),
[installedPlugins],
);
const [searchResults, setSearchResults] = useState<PluginDefinition[]>([]);
useEffect(() => {
(async () => {
const {hits} = await reportPlatformFailures(
index.search({
query,
filters: 'keywords:flipper-plugin',
hitsPerPage: 20,
}),
`${TAG}:queryIndex`,
);
setSearchResults(hits.filter(hit => !installedPlugins.has(hit.name)));
setQuery(query);
})();
}, [query, installedPlugins]);
const results = Array.from(installedPlugins.values()).concat(searchResults);
return List(results.map(createRow));
}
async function _getInstalledPlugins(): Promise<Map<string, PluginDefinition>> {
const pluginDirExists = await promisify(fs.exists)(PLUGIN_DIR);
if (!pluginDirExists) {
return new Map();
}
const dirs = await fs.readdir(PLUGIN_DIR);
const plugins = await Promise.all<[string, PluginDefinition]>(
dirs.map(
name =>
new Promise(async (resolve, reject) => {
if (!(await fs.lstat(path.join(PLUGIN_DIR, name))).isDirectory()) {
return resolve(undefined);
}
const packageJSON = await fs.readFile(
path.join(PLUGIN_DIR, name, 'package.json'),
);
try {
resolve([name, JSON.parse(packageJSON.toString())]);
} catch (e) {
reject(e);
}
}),
),
);
return new Map(plugins.filter(Boolean));
}