move plugin management from ui-core to server-core

Summary:
Follow up of D32665064, this diff moves all plugin management logic from flipper-ui to flipper-server. Things like downloading, installing, querying new plugins.

Loading plugins is handled separately in the next diff.

Reviewed By: nikoant

Differential Revision: D32666537

fbshipit-source-id: 9786b82987f00180bb26200e38735b334dc4d5c3
This commit is contained in:
Michel Weststrate
2021-12-08 04:25:28 -08:00
committed by Facebook GitHub Bot
parent f9b72ac69e
commit 64747dc417
25 changed files with 441 additions and 276 deletions

View File

@@ -33,7 +33,6 @@
"flipper-common": "0.0.0",
"flipper-doctor": "0.0.0",
"flipper-plugin": "0.0.0",
"flipper-plugin-lib": "0.0.0",
"flipper-ui-core": "0.0.0",
"fs-extra": "^10.0.0",
"immer": "^9.0.6",

View File

@@ -14,23 +14,18 @@ import {
reportPlatformFailures,
reportUsage,
InstalledPluginDetails,
UpdateResult,
UpdatablePluginDetails,
} from 'flipper-common';
import reloadFlipper from '../../utils/reloadFlipper';
import {registerInstalledPlugins} from '../../reducers/plugins';
import {
UpdateResult,
getInstalledPlugins,
getUpdatablePlugins,
removePlugin,
UpdatablePluginDetails,
} from 'flipper-plugin-lib';
import {installPluginFromNpm} from 'flipper-plugin-lib';
import {State as AppState} from '../../reducers';
import {connect} from 'react-redux';
import {Dispatch, Action} from 'redux';
import PluginPackageInstaller from './PluginPackageInstaller';
import {Toolbar} from 'flipper-plugin';
import {Alert, Button, Input, Tooltip, Typography} from 'antd';
import {getRenderHostInstance} from '../../RenderHost';
const {Text, Link} = Typography;
@@ -327,8 +322,33 @@ export default connect<PropsFromState, DispatchFromProps, OwnProps, AppState>(
}),
(dispatch: Dispatch<Action<any>>) => ({
refreshInstalledPlugins: async () => {
const plugins = await getInstalledPlugins();
const plugins = await await getRenderHostInstance().flipperServer!.exec(
'plugins-get-installed-plugins',
);
dispatch(registerInstalledPlugins(plugins));
},
}),
)(PluginInstaller);
async function installPluginFromNpm(
name: string,
): Promise<InstalledPluginDetails> {
return await getRenderHostInstance().flipperServer!.exec(
'plugins-install-from-npm',
name,
);
}
async function removePlugin(name: string) {
return await getRenderHostInstance().flipperServer!.exec(
'plugins-remove-plugins',
[name],
);
}
async function getUpdatablePlugins(query: string | undefined) {
return await getRenderHostInstance().flipperServer!.exec(
'plugins-get-updatable-plugins',
query,
);
}

View File

@@ -17,8 +17,8 @@ import {
} from '../../ui';
import styled from '@emotion/styled';
import React, {useState} from 'react';
import {installPluginFromFile} from 'flipper-plugin-lib';
import {Toolbar, FileSelector} from 'flipper-plugin';
import {getRenderHostInstance} from '../../RenderHost';
const CenteredGlyph = styled(Glyph)({
margin: 'auto',
@@ -51,7 +51,10 @@ export default function PluginPackageInstaller({
setError(undefined);
setInProgress(true);
try {
await installPluginFromFile(path);
await getRenderHostInstance().flipperServer!.exec(
'plugins-install-from-file',
path,
);
await onInstall();
} catch (e) {
setError(e);

View File

@@ -7,19 +7,22 @@
* @format
*/
jest.mock('flipper-plugin-lib');
import {default as PluginInstaller} from '../PluginInstaller';
import React from 'react';
import {render, waitFor} from '@testing-library/react';
import configureStore from 'redux-mock-store';
import {Provider} from 'react-redux';
import type {PluginDetails} from 'flipper-common';
import {getUpdatablePlugins, UpdatablePluginDetails} from 'flipper-plugin-lib';
import type {PluginDetails, UpdatablePluginDetails} from 'flipper-common';
import {Store} from '../../../reducers';
import {mocked} from 'ts-jest/utils';
import {getRenderHostInstance} from '../../../RenderHost';
const getUpdatablePluginsMock = mocked(getUpdatablePlugins);
let getUpdatablePluginsMock: jest.Mock<any, any>;
beforeEach(() => {
// flipperServer get resets before each test, no need to do so explicitly
getUpdatablePluginsMock = getRenderHostInstance().flipperServer!.exec =
jest.fn();
});
function getStore(installedPlugins: PluginDetails[] = []): Store {
return configureStore([])({
@@ -70,10 +73,6 @@ const samplePluginDetails2: UpdatablePluginDetails = {
const SEARCH_RESULTS = [samplePluginDetails1, samplePluginDetails2];
afterEach(() => {
getUpdatablePluginsMock.mockClear();
});
test('load PluginInstaller list', async () => {
getUpdatablePluginsMock.mockReturnValue(Promise.resolve(SEARCH_RESULTS));
const component = (

View File

@@ -8,7 +8,6 @@
*/
jest.mock('../../../../app/src/defaultPlugins');
jest.mock('../../utils/loadDynamicPlugins');
import dispatcher, {
getDynamicPlugins,
checkDisabled,
@@ -23,11 +22,9 @@ import {getLogger} from 'flipper-common';
import configureStore from 'redux-mock-store';
import TestPlugin from './TestPlugin';
import {_SandyPluginDefinition} from 'flipper-plugin';
import {mocked} from 'ts-jest/utils';
import loadDynamicPlugins from '../../utils/loadDynamicPlugins';
import {getRenderHostInstance} from '../../RenderHost';
const loadDynamicPluginsMock = mocked(loadDynamicPlugins);
let loadDynamicPluginsMock: jest.Mock;
const mockStore = configureStore<State, {}>([])(
createRootReducer()(undefined, {type: 'INIT'}),
@@ -56,13 +53,11 @@ const sampleBundledPluginDetails: BundledPluginDetails = {
};
beforeEach(() => {
loadDynamicPluginsMock = getRenderHostInstance().flipperServer.exec =
jest.fn();
loadDynamicPluginsMock.mockResolvedValue([]);
});
afterEach(() => {
loadDynamicPluginsMock.mockClear();
});
test('dispatcher dispatches REGISTER_PLUGINS', async () => {
await dispatcher(mockStore, logger);
const actions = mockStore.getActions();
@@ -70,7 +65,6 @@ test('dispatcher dispatches REGISTER_PLUGINS', async () => {
});
test('getDynamicPlugins returns empty array on errors', async () => {
const loadDynamicPluginsMock = mocked(loadDynamicPlugins);
loadDynamicPluginsMock.mockRejectedValue(new Error('ooops'));
const res = await getDynamicPlugins();
expect(res).toEqual([]);

View File

@@ -7,15 +7,7 @@
* @format
*/
import {
getInstalledPluginDetails,
getPluginVersionInstallationDir,
installPluginFromFile,
} from 'flipper-plugin-lib';
import {
InstalledPluginDetails,
DownloadablePluginDetails,
} from 'flipper-common';
import {DownloadablePluginDetails} from 'flipper-common';
import {State, Store} from '../reducers/index';
import {
PluginDownloadStatus,
@@ -23,25 +15,12 @@ import {
pluginDownloadFinished,
} from '../reducers/pluginDownloads';
import {sideEffect} from '../utils/sideEffect';
import {default as axios} from 'axios';
import fs from 'fs-extra';
import path from 'path';
import tmp from 'tmp';
import {promisify} from 'util';
import {reportPlatformFailures, reportUsage} from 'flipper-common';
import {loadPlugin} from '../reducers/pluginManager';
import {showErrorNotification} from '../utils/notifications';
import {pluginInstalled} from '../reducers/plugins';
import {getAllClients} from '../reducers/connections';
// Adapter which forces node.js implementation for axios instead of browser implementation
// used by default in Electron. Node.js implementation is better, because it
// supports streams which can be used for direct downloading to disk.
const axiosHttpAdapter = require('axios/lib/adapters/http'); // eslint-disable-line import/no-commonjs
const getTempDirName = promisify(tmp.dir) as (
options?: tmp.DirOptions,
) => Promise<string>;
import {getRenderHostInstance} from '../RenderHost';
export default (store: Store) => {
sideEffect(
@@ -79,61 +58,18 @@ async function handlePluginDownload(
startedByUser: boolean,
store: Store,
) {
const {title, version, downloadUrl} = plugin;
const dispatch = store.dispatch;
const {name, title, version, downloadUrl} = plugin;
const installationDir = getPluginVersionInstallationDir(name, version);
console.log(
`Downloading plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
`Downloading plugin "${title}" v${version} from "${downloadUrl}".`,
);
const tmpDir = await getTempDirName();
const tmpFile = path.join(tmpDir, `${name}-${version}.tgz`);
let installedPlugin: InstalledPluginDetails | undefined;
try {
const cancelationSource = axios.CancelToken.source();
dispatch(pluginDownloadStarted({plugin, cancel: cancelationSource.cancel}));
if (await fs.pathExists(installationDir)) {
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;
const response = await axios.get(plugin.downloadUrl, {
adapter: axiosHttpAdapter,
cancelToken: cancelationSource.token,
responseType: 'stream',
headers: {
'Sec-Fetch-Site': 'none',
'Sec-Fetch-Mode': 'navigate',
},
onDownloadProgress: async (progressEvent) => {
const newPercentCompleted = !progressEvent.total
? 0
: Math.round((progressEvent.loaded * 100) / progressEvent.total);
if (newPercentCompleted - percentCompleted >= 20) {
percentCompleted = newPercentCompleted;
console.log(
`Downloading plugin "${title}" v${version} from "${downloadUrl}": ${percentCompleted}% completed (${progressEvent.loaded} from ${progressEvent.total})`,
);
}
},
});
if (response.headers['content-type'] !== 'application/octet-stream') {
throw new Error(
`It looks like you are not on VPN/Lighthouse. Unexpected content type received: ${response.headers['content-type']}.`,
);
}
const responseStream = response.data as fs.ReadStream;
const writeStream = responseStream.pipe(
fs.createWriteStream(tmpFile, {autoClose: true}),
);
await new Promise((resolve, reject) =>
writeStream.once('finish', resolve).once('error', reject),
);
installedPlugin = await installPluginFromFile(tmpFile);
dispatch(pluginInstalled(installedPlugin));
}
dispatch(pluginDownloadStarted({plugin}));
const installedPlugin = await getRenderHostInstance().flipperServer!.exec(
'plugin-start-download',
plugin,
);
dispatch(pluginInstalled(installedPlugin));
if (pluginIsDisabledForAllConnectedClients(store.getState(), plugin)) {
dispatch(
loadPlugin({
@@ -144,11 +80,11 @@ async function handlePluginDownload(
);
}
console.log(
`Successfully downloaded and installed plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
`Successfully downloaded and installed plugin "${title}" v${version} from "${downloadUrl}".`,
);
} catch (error) {
console.error(
`Failed to download plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
`Failed to download plugin "${title}" v${version} from "${downloadUrl}".`,
error,
);
if (startedByUser) {
@@ -160,7 +96,6 @@ async function handlePluginDownload(
throw error;
} finally {
dispatch(pluginDownloadFinished({plugin}));
await fs.remove(tmpDir);
}
}

View File

@@ -17,11 +17,6 @@ import {
SwitchPluginActionPayload,
PluginCommand,
} from '../reducers/pluginManager';
import {
getInstalledPlugins,
cleanupOldInstalledPluginVersions,
removePlugins,
} from 'flipper-plugin-lib';
import {sideEffect} from '../utils/sideEffect';
import {requirePlugin} from './plugins';
import {showErrorNotification} from '../utils/notifications';
@@ -49,13 +44,18 @@ import {
defaultEnabledBackgroundPlugins,
} from '../utils/pluginUtils';
import {getPluginKey} from '../utils/pluginKey';
const maxInstalledPluginVersionsToKeep = 2;
import {getRenderHostInstance} from '../RenderHost';
async function refreshInstalledPlugins(store: Store) {
await removePlugins(store.getState().plugins.uninstalledPluginNames.values());
await cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep);
const plugins = await getInstalledPlugins();
const flipperServer = getRenderHostInstance().flipperServer;
if (!flipperServer) {
throw new Error('Flipper Server not ready');
}
await flipperServer.exec(
'plugins-remove-plugins',
Array.from(store.getState().plugins.uninstalledPluginNames.values()),
);
const plugins = await flipperServer.exec('plugins-get-installed-plugins');
return store.dispatch(registerInstalledPlugins(plugins));
}

View File

@@ -8,7 +8,7 @@
*/
import type {Store} from '../reducers/index';
import type {Logger} from 'flipper-common';
import type {InstalledPluginDetails, Logger} from 'flipper-common';
import {PluginDefinition} from '../plugin';
import React from 'react';
import ReactDOM from 'react-dom';
@@ -25,8 +25,6 @@ import {
pluginsInitialized,
} from '../reducers/plugins';
import {FlipperBasePlugin} from '../plugin';
import fs from 'fs-extra';
import path from 'path';
import {notNull} from '../utils/typeUtils';
import {
ActivatablePluginDetails,
@@ -36,7 +34,6 @@ import {
import {tryCatchReportPluginFailures, reportUsage} from 'flipper-common';
import * as FlipperPluginSDK from 'flipper-plugin';
import {_SandyPluginDefinition} from 'flipper-plugin';
import loadDynamicPlugins from '../utils/loadDynamicPlugins';
import * as Immer from 'immer';
import * as antd from 'antd';
import * as emotion_styled from '@emotion/styled';
@@ -47,7 +44,6 @@ import * as crc32 from 'crc32';
import {isDevicePluginDefinition} from '../utils/pluginUtils';
import isPluginCompatible from '../utils/isPluginCompatible';
import isPluginVersionMoreRecent from '../utils/isPluginVersionMoreRecent';
import {getStaticPath} from '../utils/pathUtils';
import {createSandyPluginWrapper} from '../utils/createSandyPluginWrapper';
import {getRenderHostInstance} from '../RenderHost';
let defaultPluginsIndex: any = null;
@@ -152,25 +148,23 @@ async function getBundledPlugins(): Promise<Array<BundledPluginDetails>> {
if (process.env.NODE_ENV === 'test') {
return [];
}
// defaultPlugins that are included in the Flipper distributive.
// List of default bundled plugins is written at build time to defaultPlugins/bundled.json.
const pluginPath = getStaticPath(
path.join('defaultPlugins', 'bundled.json'),
{asarUnpacked: true},
);
let bundledPlugins: Array<BundledPluginDetails> = [];
try {
bundledPlugins = await fs.readJson(pluginPath);
// defaultPlugins that are included in the Flipper distributive.
// List of default bundled plugins is written at build time to defaultPlugins/bundled.json.
return await getRenderHostInstance().flipperServer!.exec(
'plugins-get-bundled-plugins',
);
} catch (e) {
console.error('Failed to load list of bundled plugins', e);
return [];
}
return bundledPlugins;
}
export async function getDynamicPlugins() {
export async function getDynamicPlugins(): Promise<InstalledPluginDetails[]> {
try {
return await loadDynamicPlugins();
return await getRenderHostInstance().flipperServer!.exec(
'plugins-load-dynamic-plugins',
);
} catch (e) {
console.error('Failed to load dynamic plugins', e);
return [];

View File

@@ -8,10 +8,8 @@
*/
import {DownloadablePluginDetails} from 'flipper-common';
import {getPluginVersionInstallationDir} from 'flipper-plugin-lib';
import {Actions} from '.';
import produce from 'immer';
import {Canceler} from 'axios';
export enum PluginDownloadStatus {
QUEUED = 'Queued',
@@ -24,7 +22,7 @@ export type DownloadablePluginState = {
startedByUser: boolean;
} & (
| {status: PluginDownloadStatus.QUEUED}
| {status: PluginDownloadStatus.STARTED; cancel: Canceler}
| {status: PluginDownloadStatus.STARTED}
);
// We use plugin installation path as key as it is unique for each plugin version.
@@ -42,7 +40,6 @@ export type PluginDownloadStarted = {
type: 'PLUGIN_DOWNLOAD_STARTED';
payload: {
plugin: DownloadablePluginDetails;
cancel: Canceler;
};
};
@@ -67,10 +64,7 @@ export default function reducer(
switch (action.type) {
case 'PLUGIN_DOWNLOAD_START': {
const {plugin, startedByUser} = action.payload;
const installationDir = getPluginVersionInstallationDir(
plugin.name,
plugin.version,
);
const installationDir = getDownloadKey(plugin.name, plugin.version);
const downloadState = state[installationDir];
if (downloadState) {
// If download is already in progress - re-use the existing state.
@@ -90,11 +84,8 @@ export default function reducer(
});
}
case 'PLUGIN_DOWNLOAD_STARTED': {
const {plugin, cancel} = action.payload;
const installationDir = getPluginVersionInstallationDir(
plugin.name,
plugin.version,
);
const {plugin} = action.payload;
const installationDir = getDownloadKey(plugin.name, plugin.version);
const downloadState = state[installationDir];
if (downloadState?.status !== PluginDownloadStatus.QUEUED) {
console.warn(
@@ -107,16 +98,12 @@ export default function reducer(
status: PluginDownloadStatus.STARTED,
plugin,
startedByUser: downloadState.startedByUser,
cancel,
};
});
}
case 'PLUGIN_DOWNLOAD_FINISHED': {
const {plugin} = action.payload;
const installationDir = getPluginVersionInstallationDir(
plugin.name,
plugin.version,
);
const installationDir = getDownloadKey(plugin.name, plugin.version);
return produce(state, (draft) => {
delete draft[installationDir];
});
@@ -136,9 +123,12 @@ export const startPluginDownload = (payload: {
export const pluginDownloadStarted = (payload: {
plugin: DownloadablePluginDetails;
cancel: Canceler;
}): Action => ({type: 'PLUGIN_DOWNLOAD_STARTED', payload});
export const pluginDownloadFinished = (payload: {
plugin: DownloadablePluginDetails;
}): Action => ({type: 'PLUGIN_DOWNLOAD_FINISHED', payload});
function getDownloadKey(name: string, version: string) {
return name.replace('/', '__') + '@' + version;
}

View File

@@ -1,85 +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
*/
import path from 'path';
import fs from 'fs-extra';
import {
getSourcePlugins,
moveInstalledPluginsFromLegacyDir,
getAllInstalledPluginVersions,
getAllInstalledPluginsInDir,
} from 'flipper-plugin-lib';
import {InstalledPluginDetails} from 'flipper-common';
import {getStaticPath} from '../utils/pathUtils';
// Load "dynamic" plugins, e.g. those which are either pre-installed (default), installed or loaded from sources (for development).
// This opposed to "bundled" plugins which are included into Flipper bundle.
export default async function loadDynamicPlugins(): Promise<
InstalledPluginDetails[]
> {
if (process.env.NODE_ENV === 'test') {
return [];
}
if (process.env.FLIPPER_FAST_REFRESH) {
console.log(
'❌ Skipping loading of dynamic plugins because Fast Refresh is enabled. Fast Refresh only works with bundled plugins.',
);
return [];
}
await moveInstalledPluginsFromLegacyDir().catch((ex) =>
console.error(
'Eror while migrating installed plugins from legacy folder',
ex,
),
);
const bundledPlugins = new Set<string>(
(
await fs.readJson(
getStaticPath(path.join('defaultPlugins', 'bundled.json'), {
asarUnpacked: true,
}),
)
).map((p: any) => p.name) as string[],
);
const [installedPlugins, unfilteredSourcePlugins] = await Promise.all([
process.env.FLIPPER_NO_PLUGIN_MARKETPLACE
? Promise.resolve([])
: getAllInstalledPluginVersions(),
getSourcePlugins(),
]);
const sourcePlugins = unfilteredSourcePlugins.filter(
(p) => !bundledPlugins.has(p.name),
);
const defaultPluginsDir = getStaticPath('defaultPlugins', {
asarUnpacked: true,
});
const defaultPlugins = await getAllInstalledPluginsInDir(defaultPluginsDir);
if (defaultPlugins.length > 0) {
console.log(
`✅ Loaded ${defaultPlugins.length} default plugins: ${defaultPlugins
.map((x) => x.title)
.join(', ')}.`,
);
}
if (installedPlugins.length > 0) {
console.log(
`✅ Loaded ${installedPlugins.length} installed plugins: ${Array.from(
new Set(installedPlugins.map((x) => x.title)),
).join(', ')}.`,
);
}
if (sourcePlugins.length > 0) {
console.log(
`✅ Loaded ${sourcePlugins.length} source plugins: ${sourcePlugins
.map((x) => x.title)
.join(', ')}.`,
);
}
return [...defaultPlugins, ...installedPlugins, ...sourcePlugins];
}

View File

@@ -16,6 +16,9 @@ import fs from 'fs';
import config from '../fb-stubs/config';
import {getRenderHostInstance} from '../RenderHost';
/**
* @deprecated
*/
export function getStaticPath(
relativePath: string = '.',
{asarUnpacked}: {asarUnpacked: boolean} = {asarUnpacked: false},
@@ -31,6 +34,9 @@ export function getStaticPath(
: absolutePath;
}
/**
* @deprecated
*/
export function getChangelogPath() {
const changelogPath = getStaticPath(config.isFBBuild ? 'facebook' : '.');
if (fs.existsSync(changelogPath)) {

View File

@@ -16,9 +16,6 @@
{
"path": "../flipper-plugin"
},
{
"path": "../plugin-lib"
},
{
"path": "../test-utils"
}