/** * 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>; 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 ( {restartRequired && ( To activate this plugin, Flipper needs to restart. Click here to restart! )} setQuery(e.target.value)} value={query} placeholder="Search Flipper plugins..." /> ); }; 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) => 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( props.installed ? {kind: 'Remove'} : {kind: 'Install'}, ); if (action.kind === 'Waiting') { return ; } if (action.kind === 'Error') { const glyph = ( ); return ( ); } return ( reportPlatformFailures(onInstall(), `${TAG}:install`) : () => reportPlatformFailures(onRemove(), `${TAG}:remove`) }> {action.kind} ); } function useNPMSearch( setRestartRequired: (restart: boolean) => void, query: string, setQuery: (query: string) => void, searchClientFactory: () => algoliasearch.Index, getInstalledPlugins: () => Promise>, ): TableRows_immutable { const index = useMemo(searchClientFactory, []); const [installedPlugins, setInstalledPlugins] = useState( new Map(), ); 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: {h.name}}, version: { value: {h.version}, align: 'flex-end' as 'flex-end', }, description: { value: ( {h.description} ), }, install: { value: ( ), align: 'center' as 'center', }, }, }), [installedPlugins], ); const [searchResults, setSearchResults] = useState([]); 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> { 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)); }