Move app/src (mostly) to flipper-ui-core/src

Summary:
This diff moves all UI code from app/src to app/flipper-ui-core. That is now slightly too much (e.g. node deps are not removed yet), but from here it should be easier to move things out again, as I don't want this diff to be open for too long to avoid too much merge conflicts.

* But at least flipper-ui-core is Electron free :)
* Killed all cross module imports as well, as they where now even more in the way
* Some unit test needed some changes, most not too big (but emotion hashes got renumbered in the snapshots, feel free to ignore that)
* Found some files that were actually meaningless (tsconfig in plugins, WatchTools files, that start generating compile errors, removed those

Follow up work:
* make flipper-ui-core configurable, and wire up flipper-server-core in Electron instead of here
* remove node deps (aigoncharov)
* figure out correct place to load GKs, plugins, make intern requests etc., and move to the correct module
* clean up deps

Reviewed By: aigoncharov

Differential Revision: D32427722

fbshipit-source-id: 14fe92e1ceb15b9dcf7bece367c8ab92df927a70
This commit is contained in:
Michel Weststrate
2021-11-16 05:25:40 -08:00
committed by Facebook GitHub Bot
parent 54b7ce9308
commit 7e50c0466a
293 changed files with 483 additions and 497 deletions

View File

@@ -0,0 +1,19 @@
/**
* 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 {FlipperPlugin} from '../../plugin';
export default class extends FlipperPlugin<any, any, any> {
static id = 'Static ID';
}
test('TestPlugin', () => {
// supress jest warning
expect(true).toBeTruthy();
});

View File

@@ -0,0 +1,46 @@
/**
* 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 {uriComponents} from '../../deeplink';
test('test parsing of deeplink URL', () => {
const url = 'flipper://app/plugin/meta/data';
const components = uriComponents(url);
expect(components).toEqual(['app', 'plugin', 'meta/data']);
});
test('test parsing of deeplink URL when arguments are less', () => {
const url = 'flipper://app/';
const components = uriComponents(url);
expect(components).toEqual(['app']);
});
test('test parsing of deeplink URL when url is null', () => {
// @ts-ignore
const components = uriComponents(null);
expect(components).toEqual([]);
});
test('test parsing of deeplink URL when pattern does not match', () => {
const url = 'Some random string';
const components = uriComponents(url);
expect(components).toEqual([]);
});
test('test parsing of deeplinkURL when there are query params', () => {
const url = 'flipper://null/React/?device=React%20Native';
const components = uriComponents(url);
expect(components).toEqual(['null', 'React', '?device=React Native']);
});
test('test parsing of deeplinkURL when there are query params without slash', () => {
const url = 'flipper://null/React?device=React%20Native';
const components = uriComponents(url);
expect(components).toEqual(['null', 'React', '?device=React Native']);
});

View File

@@ -0,0 +1,395 @@
/**
* 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
*/
jest.useFakeTimers();
import React from 'react';
import {renderMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
import {
_SandyPluginDefinition,
PluginClient,
TestUtils,
usePlugin,
createState,
useValue,
DevicePluginClient,
Dialog,
} from 'flipper-plugin';
import {parseOpenPluginParams} from '../handleOpenPluginDeeplink';
import {handleDeeplink} from '../../deeplink';
import {selectPlugin} from '../../reducers/connections';
let origAlertImpl: any;
let origConfirmImpl: any;
beforeEach(() => {
origAlertImpl = Dialog.alert;
origConfirmImpl = Dialog.confirm;
});
afterEach(() => {
Dialog.alert = origAlertImpl;
Dialog.confirm = origConfirmImpl;
});
test('open-plugin deeplink parsing', () => {
const testpayload = 'http://www.google/?test=c o%20o+l';
const testLink =
'flipper://open-plugin?plugin-id=graphql&client=facebook&devices=android,ios&chrome=1&payload=' +
encodeURIComponent(testpayload);
const res = parseOpenPluginParams(testLink);
expect(res).toEqual({
pluginId: 'graphql',
client: 'facebook',
devices: ['android', 'ios'],
payload: 'http://www.google/?test=c o o+l',
});
});
test('open-plugin deeplink parsing - 2', () => {
const testLink = 'flipper://open-plugin?plugin-id=graphql';
const res = parseOpenPluginParams(testLink);
expect(res).toEqual({
pluginId: 'graphql',
client: undefined,
devices: [],
payload: undefined,
});
});
test('open-plugin deeplink parsing - 3', () => {
expect(() =>
parseOpenPluginParams('flipper://open-plugin?'),
).toThrowErrorMatchingInlineSnapshot(`"Missing plugin-id param"`);
});
test('Triggering a deeplink will work', async () => {
const linksSeen: any[] = [];
const plugin = (client: PluginClient) => {
const linkState = createState('');
client.onDeepLink((link) => {
linksSeen.push(link);
linkState.set(String(link));
});
return {
linkState,
};
};
const definition = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
plugin,
Component() {
const instance = usePlugin(plugin);
const linkState = useValue(instance.linkState);
return <h1>{linkState || 'world'}</h1>;
},
},
);
const {renderer, client, store, logger} = await renderMockFlipperWithPlugin(
definition,
);
logger.track = jest.fn();
expect(linksSeen).toEqual([]);
await handleDeeplink(
store,
logger,
`flipper://open-plugin?plugin-id=${definition.id}&client=${client.query.app}&payload=universe`,
);
jest.runAllTimers();
expect(linksSeen).toEqual(['universe']);
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
<div
class="css-1x2cmzz-SandySplitContainer e1hsqii10"
>
<div />
<div
class="css-1knrt0j-SandySplitContainer e1hsqii10"
>
<div
class="css-1woty6b-Container"
>
<h1>
universe
</h1>
</div>
<div
class="css-724x97-View-FlexBox-FlexRow"
id="detailsSidebar"
/>
</div>
</div>
</div>
</body>
`);
expect(logger.track).toHaveBeenCalledTimes(2);
expect(logger.track).toHaveBeenCalledWith(
'usage',
'deeplink',
{
query:
'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe',
state: 'INIT',
},
undefined,
);
expect(logger.track).toHaveBeenCalledWith(
'usage',
'deeplink',
{
query:
'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe',
state: 'PLUGIN_OPEN_SUCCESS',
plugin: {
client: 'TestApp',
devices: [],
payload: 'universe',
pluginId: 'TestPlugin',
},
},
'TestPlugin',
);
});
test('triggering a deeplink without applicable device can wait for a device', async () => {
let lastOS: string = '';
const definition = TestUtils.createTestDevicePlugin(
{
Component() {
return <p>Hello</p>;
},
devicePlugin(c: DevicePluginClient) {
lastOS = c.device.os;
return {};
},
},
{
id: 'DevicePlugin',
supportedDevices: [{os: 'iOS'}],
},
);
const {renderer, store, logger, createDevice, device} =
await renderMockFlipperWithPlugin(definition);
store.dispatch(
selectPlugin({
selectedPlugin: 'nonexisting',
deepLinkPayload: null,
selectedDevice: device,
}),
);
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
No plugin selected
</div>
</body>
`);
const handlePromise = handleDeeplink(
store,
logger,
`flipper://open-plugin?plugin-id=${definition.id}&devices=iOS`,
);
jest.runAllTimers();
// No device yet available (dialogs are not renderable atm)
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
No plugin selected
</div>
</body>
`);
// create a new device
createDevice({serial: 'device2', os: 'iOS'});
// wizard should continue automatically
await handlePromise;
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
<div
class="css-1x2cmzz-SandySplitContainer e1hsqii10"
>
<div />
<div
class="css-1knrt0j-SandySplitContainer e1hsqii10"
>
<div
class="css-1woty6b-Container"
>
<p>
Hello
</p>
</div>
<div
class="css-724x97-View-FlexBox-FlexRow"
id="detailsSidebar"
/>
</div>
</div>
</div>
</body>
`);
expect(lastOS).toBe('iOS');
});
test('triggering a deeplink without applicable client can wait for a device', async () => {
const definition = TestUtils.createTestPlugin(
{
Component() {
return <p>Hello</p>;
},
plugin() {
return {};
},
},
{
id: 'pluggy',
},
);
const {renderer, store, createClient, device, logger} =
await renderMockFlipperWithPlugin(definition);
store.dispatch(
selectPlugin({
selectedPlugin: 'nonexisting',
deepLinkPayload: null,
selectedDevice: device,
}),
);
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
No plugin selected
</div>
</body>
`);
const handlePromise = handleDeeplink(
store,
logger,
`flipper://open-plugin?plugin-id=${definition.id}&client=clienty`,
);
jest.runAllTimers();
// No device yet available (dialogs are not renderable atm)
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
No plugin selected
</div>
</body>
`);
// create a new client
createClient(device, 'clienty');
// wizard should continue automatically
await handlePromise;
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
<div
class="css-1x2cmzz-SandySplitContainer e1hsqii10"
>
<div />
<div
class="css-1knrt0j-SandySplitContainer e1hsqii10"
>
<div
class="css-1woty6b-Container"
>
<p>
Hello
</p>
</div>
<div
class="css-724x97-View-FlexBox-FlexRow"
id="detailsSidebar"
/>
</div>
</div>
</div>
<div />
</body>
`);
});
test('triggering a deeplink with incompatible device will cause bail', async () => {
const definition = TestUtils.createTestDevicePlugin(
{
Component() {
return <p>Hello</p>;
},
devicePlugin() {
return {};
},
},
{
id: 'DevicePlugin',
supportedDevices: [{os: 'iOS'}],
},
);
const {store, logger, createDevice} = await renderMockFlipperWithPlugin(
definition,
);
logger.track = jest.fn();
// Skipping user interactions.
Dialog.alert = (async () => {}) as any;
Dialog.confirm = (async () => {}) as any;
store.dispatch(
selectPlugin({selectedPlugin: 'nonexisting', deepLinkPayload: null}),
);
const handlePromise = handleDeeplink(
store,
logger,
`flipper://open-plugin?plugin-id=${definition.id}&devices=iOS`,
);
jest.runAllTimers();
// create a new device that doesn't match spec
createDevice({serial: 'device2', os: 'Android'});
// wait for dialogues
await handlePromise;
expect(logger.track).toHaveBeenCalledTimes(2);
expect(logger.track).toHaveBeenCalledWith(
'usage',
'deeplink',
{
plugin: {
client: undefined,
devices: ['iOS'],
payload: undefined,
pluginId: 'DevicePlugin',
},
query: 'flipper://open-plugin?plugin-id=DevicePlugin&devices=iOS',
state: 'PLUGIN_DEVICE_BAIL',
},
'DevicePlugin',
);
});

View File

@@ -0,0 +1,268 @@
/**
* 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
*/
jest.mock('../plugins');
jest.mock('../../utils/electronModuleCache');
import {
loadPlugin,
switchPlugin,
uninstallPlugin,
} from '../../reducers/pluginManager';
import {requirePlugin} from '../plugins';
import {mocked} from 'ts-jest/utils';
import {TestUtils} from 'flipper-plugin';
import * as TestPlugin from '../../test-utils/TestPlugin';
import {_SandyPluginDefinition as SandyPluginDefinition} from 'flipper-plugin';
import MockFlipper from '../../test-utils/MockFlipper';
import Client from '../../Client';
import React from 'react';
import BaseDevice from '../../devices/BaseDevice';
const pluginDetails1 = TestUtils.createMockPluginDetails({
id: 'plugin1',
name: 'flipper-plugin1',
version: '0.0.1',
});
const pluginDefinition1 = new SandyPluginDefinition(pluginDetails1, TestPlugin);
const pluginDetails1V2 = TestUtils.createMockPluginDetails({
id: 'plugin1',
name: 'flipper-plugin1',
version: '0.0.2',
});
const pluginDefinition1V2 = new SandyPluginDefinition(
pluginDetails1V2,
TestPlugin,
);
const pluginDetails2 = TestUtils.createMockPluginDetails({
id: 'plugin2',
name: 'flipper-plugin2',
});
const pluginDefinition2 = new SandyPluginDefinition(pluginDetails2, TestPlugin);
const devicePluginDetails = TestUtils.createMockPluginDetails({
id: 'device',
name: 'flipper-device',
});
const devicePluginDefinition = new SandyPluginDefinition(devicePluginDetails, {
supportsDevice() {
return true;
},
devicePlugin() {
return {};
},
Component() {
return <h1>Plugin3</h1>;
},
});
const mockedRequirePlugin = mocked(requirePlugin);
let mockFlipper: MockFlipper;
let mockClient: Client;
let mockDevice: BaseDevice;
beforeEach(async () => {
mockedRequirePlugin.mockImplementation(
(details) =>
(details === pluginDetails1
? pluginDefinition1
: details === pluginDetails2
? pluginDefinition2
: details === pluginDetails1V2
? pluginDefinition1V2
: details === devicePluginDetails
? devicePluginDefinition
: undefined)!,
);
mockFlipper = new MockFlipper();
const initResult = await mockFlipper.initWithDeviceAndClient({
clientOptions: {supportedPlugins: ['plugin1', 'plugin2']},
});
mockClient = initResult.client;
mockDevice = initResult.device;
});
afterEach(async () => {
mockedRequirePlugin.mockReset();
await mockFlipper.destroy();
});
test('load plugin when no other version loaded', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1,
);
expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
pluginDetails1,
);
expect(mockClient.sandyPluginStates.has('plugin1')).toBeFalsy();
});
test('load plugin when other version loaded', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
);
mockFlipper.dispatch(
loadPlugin({
plugin: pluginDetails1V2,
enable: false,
notifyIfFailed: false,
}),
);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1V2,
);
expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
pluginDetails1V2,
);
expect(mockClient.sandyPluginStates.has('plugin1')).toBeFalsy();
});
test('load and enable Sandy plugin', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: true, notifyIfFailed: false}),
);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1,
);
expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
pluginDetails1,
);
expect(mockClient.sandyPluginStates.has('plugin1')).toBeTruthy();
});
test('uninstall plugin', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: true, notifyIfFailed: false}),
);
mockFlipper.dispatch(uninstallPlugin({plugin: pluginDefinition1}));
expect(
mockFlipper.getState().plugins.clientPlugins.has('plugin1'),
).toBeFalsy();
expect(
mockFlipper.getState().plugins.loadedPlugins.has('plugin1'),
).toBeFalsy();
expect(
mockFlipper
.getState()
.plugins.uninstalledPluginNames.has('flipper-plugin1'),
).toBeTruthy();
expect(mockClient.sandyPluginStates.has('plugin1')).toBeFalsy();
});
test('uninstall bundled plugin', async () => {
const pluginDetails = TestUtils.createMockBundledPluginDetails({
id: 'bundled-plugin',
name: 'flipper-bundled-plugin',
version: '0.43.0',
});
const pluginDefinition = new SandyPluginDefinition(pluginDetails, TestPlugin);
mockedRequirePlugin.mockReturnValue(pluginDefinition);
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails, enable: true, notifyIfFailed: false}),
);
mockFlipper.dispatch(uninstallPlugin({plugin: pluginDefinition}));
expect(
mockFlipper.getState().plugins.clientPlugins.has('bundled-plugin'),
).toBeFalsy();
expect(
mockFlipper.getState().plugins.loadedPlugins.has('bundled-plugin'),
).toBeFalsy();
expect(
mockFlipper
.getState()
.plugins.uninstalledPluginNames.has('flipper-bundled-plugin'),
).toBeTruthy();
expect(mockClient.sandyPluginStates.has('bundled-plugin')).toBeFalsy();
});
test('star plugin', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: pluginDefinition1,
selectedApp: mockClient.query.app,
}),
);
expect(
mockFlipper.getState().connections.enabledPlugins[mockClient.query.app],
).toContain('plugin1');
expect(mockClient.sandyPluginStates.has('plugin1')).toBeTruthy();
});
test('disable plugin', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: pluginDefinition1,
selectedApp: mockClient.query.app,
}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: pluginDefinition1,
selectedApp: mockClient.query.app,
}),
);
expect(
mockFlipper.getState().connections.enabledPlugins[mockClient.query.app],
).not.toContain('plugin1');
expect(mockClient.sandyPluginStates.has('plugin1')).toBeFalsy();
});
test('star device plugin', async () => {
mockFlipper.dispatch(
loadPlugin({
plugin: devicePluginDetails,
enable: false,
notifyIfFailed: false,
}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: devicePluginDefinition,
}),
);
expect(
mockFlipper.getState().connections.enabledDevicePlugins.has('device'),
).toBeTruthy();
expect(mockDevice.sandyPluginStates.has('device')).toBeTruthy();
});
test('disable device plugin', async () => {
mockFlipper.dispatch(
loadPlugin({
plugin: devicePluginDetails,
enable: false,
notifyIfFailed: false,
}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: devicePluginDefinition,
}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: devicePluginDefinition,
}),
);
expect(
mockFlipper.getState().connections.enabledDevicePlugins.has('device'),
).toBeFalsy();
expect(mockDevice.sandyPluginStates.has('device')).toBeFalsy();
});

View File

@@ -0,0 +1,325 @@
/**
* 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
*/
jest.mock('../../../../app/src/defaultPlugins');
jest.mock('../../utils/loadDynamicPlugins');
import dispatcher, {
getDynamicPlugins,
checkDisabled,
checkGK,
createRequirePluginFunction,
getLatestCompatibleVersionOfEachPlugin,
} from '../plugins';
import {BundledPluginDetails, InstalledPluginDetails} from 'flipper-plugin-lib';
import path from 'path';
import {createRootReducer, State} from '../../reducers/index';
import {getLogger} from 'flipper-common';
import configureStore from 'redux-mock-store';
import {TEST_PASSING_GK, TEST_FAILING_GK} from '../../fb-stubs/GK';
import TestPlugin from './TestPlugin';
import {resetConfigForTesting} from '../../utils/processConfig';
import {_SandyPluginDefinition} from 'flipper-plugin';
import {mocked} from 'ts-jest/utils';
import loadDynamicPlugins from '../../utils/loadDynamicPlugins';
const loadDynamicPluginsMock = mocked(loadDynamicPlugins);
const mockStore = configureStore<State, {}>([])(
createRootReducer()(undefined, {type: 'INIT'}),
);
const logger = getLogger();
const sampleInstalledPluginDetails: InstalledPluginDetails = {
name: 'other Name',
version: '1.0.0',
specVersion: 2,
pluginType: 'client',
main: 'dist/bundle.js',
source: 'src/index.js',
id: 'Sample',
title: 'Sample',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample',
entry: 'this/path/does not/exist',
isBundled: false,
isActivatable: true,
};
const sampleBundledPluginDetails: BundledPluginDetails = {
...sampleInstalledPluginDetails,
id: 'SampleBundled',
isBundled: true,
};
beforeEach(() => {
resetConfigForTesting();
loadDynamicPluginsMock.mockResolvedValue([]);
});
afterEach(() => {
loadDynamicPluginsMock.mockClear();
});
test('dispatcher dispatches REGISTER_PLUGINS', async () => {
await dispatcher(mockStore, logger);
const actions = mockStore.getActions();
expect(actions.map((a) => a.type)).toContain('REGISTER_PLUGINS');
});
test('getDynamicPlugins returns empty array on errors', async () => {
const loadDynamicPluginsMock = mocked(loadDynamicPlugins);
loadDynamicPluginsMock.mockRejectedValue(new Error('ooops'));
const res = await getDynamicPlugins();
expect(res).toEqual([]);
});
test('checkDisabled', () => {
const disabledPlugin = 'pluginName';
const config = {disabledPlugins: [disabledPlugin]};
const orig = process.env.CONFIG;
try {
process.env.CONFIG = JSON.stringify(config);
const disabled = checkDisabled([]);
expect(
disabled({
...sampleBundledPluginDetails,
name: 'other Name',
version: '1.0.0',
}),
).toBeTruthy();
expect(
disabled({
...sampleBundledPluginDetails,
name: disabledPlugin,
version: '1.0.0',
}),
).toBeFalsy();
} finally {
process.env.CONFIG = orig;
}
});
test('checkGK for plugin without GK', () => {
expect(
checkGK([])({
...sampleBundledPluginDetails,
name: 'pluginID',
version: '1.0.0',
}),
).toBeTruthy();
});
test('checkGK for passing plugin', () => {
expect(
checkGK([])({
...sampleBundledPluginDetails,
name: 'pluginID',
gatekeeper: TEST_PASSING_GK,
version: '1.0.0',
}),
).toBeTruthy();
});
test('checkGK for failing plugin', () => {
const gatekeepedPlugins: InstalledPluginDetails[] = [];
const name = 'pluginID';
const plugins = checkGK(gatekeepedPlugins)({
...sampleBundledPluginDetails,
name,
gatekeeper: TEST_FAILING_GK,
version: '1.0.0',
});
expect(plugins).toBeFalsy();
expect(gatekeepedPlugins[0].name).toEqual(name);
});
test('requirePlugin returns null for invalid requires', () => {
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...sampleInstalledPluginDetails,
name: 'pluginID',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample',
entry: 'this/path/does not/exist',
version: '1.0.0',
});
expect(plugin).toBeNull();
});
test('requirePlugin loads plugin', () => {
const name = 'pluginID';
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...sampleInstalledPluginDetails,
name,
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample',
entry: path.join(__dirname, 'TestPlugin'),
version: '1.0.0',
});
expect(plugin).not.toBeNull();
expect(Object.keys(plugin as any)).toEqual([
'id',
'details',
'isDevicePlugin',
'module',
]);
expect(Object.keys((plugin as any).module)).toEqual(['plugin', 'Component']);
expect(plugin!.id).toBe(TestPlugin.id);
});
test('newest version of each plugin is used', () => {
const bundledPlugins: BundledPluginDetails[] = [
{
...sampleBundledPluginDetails,
id: 'TestPlugin1',
name: 'flipper-plugin-test1',
version: '0.1.0',
},
{
...sampleBundledPluginDetails,
id: 'TestPlugin2',
name: 'flipper-plugin-test2',
version: '0.1.0-alpha.201',
},
];
const installedPlugins: InstalledPluginDetails[] = [
{
...sampleInstalledPluginDetails,
id: 'TestPlugin2',
name: 'flipper-plugin-test2',
version: '0.1.0-alpha.21',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-test2',
entry: './test/index.js',
},
{
...sampleInstalledPluginDetails,
id: 'TestPlugin1',
name: 'flipper-plugin-test1',
version: '0.10.0',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-test1',
entry: './test/index.js',
},
];
const filteredPlugins = getLatestCompatibleVersionOfEachPlugin([
...bundledPlugins,
...installedPlugins,
]);
expect(filteredPlugins).toHaveLength(2);
expect(filteredPlugins).toContainEqual({
...sampleInstalledPluginDetails,
id: 'TestPlugin1',
name: 'flipper-plugin-test1',
version: '0.10.0',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-test1',
entry: './test/index.js',
});
expect(filteredPlugins).toContainEqual({
...sampleBundledPluginDetails,
id: 'TestPlugin2',
name: 'flipper-plugin-test2',
version: '0.1.0-alpha.201',
});
});
test('requirePlugin loads valid Sandy plugin', () => {
const name = 'pluginID';
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...sampleInstalledPluginDetails,
name,
dir: path.join(
__dirname,
'../../../../flipper-plugin/src/__tests__/TestPlugin',
),
entry: path.join(
__dirname,
'../../../../flipper-plugin/src/__tests__/TestPlugin',
),
version: '1.0.0',
flipperSDKVersion: '0.0.0',
}) as _SandyPluginDefinition;
expect(plugin).not.toBeNull();
expect(plugin).toBeInstanceOf(_SandyPluginDefinition);
expect(plugin.id).toBe('Sample');
expect(plugin.details).toMatchObject({
flipperSDKVersion: '0.0.0',
id: 'Sample',
isBundled: false,
main: 'dist/bundle.js',
name: 'pluginID',
source: 'src/index.js',
specVersion: 2,
title: 'Sample',
version: '1.0.0',
});
expect(plugin.isDevicePlugin).toBe(false);
expect(typeof plugin.module.Component).toBe('function');
expect(plugin.module.Component.displayName).toBe('FlipperPlugin(Sample)');
expect(typeof plugin.asPluginModule().plugin).toBe('function');
});
test('requirePlugin errors on invalid Sandy plugin', () => {
const name = 'pluginID';
const failedPlugins: any[] = [];
const requireFn = createRequirePluginFunction(failedPlugins, require);
requireFn({
...sampleInstalledPluginDetails,
name,
// Intentionally the wrong file:
dir: __dirname,
entry: path.join(__dirname, 'TestPlugin'),
version: '1.0.0',
flipperSDKVersion: '0.0.0',
});
expect(failedPlugins[0][1]).toMatchInlineSnapshot(
`"Flipper plugin 'Sample' should export named function called 'plugin'"`,
);
});
test('requirePlugin loads valid Sandy Device plugin', () => {
const name = 'pluginID';
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...sampleInstalledPluginDetails,
pluginType: 'device',
name,
dir: path.join(
__dirname,
'../../../../flipper-plugin/src/__tests__/DeviceTestPlugin',
),
entry: path.join(
__dirname,
'../../../../flipper-plugin/src/__tests__/DeviceTestPlugin',
),
version: '1.0.0',
flipperSDKVersion: '0.0.0',
}) as _SandyPluginDefinition;
expect(plugin).not.toBeNull();
expect(plugin).toBeInstanceOf(_SandyPluginDefinition);
expect(plugin.id).toBe('Sample');
expect(plugin.details).toMatchObject({
flipperSDKVersion: '0.0.0',
id: 'Sample',
isBundled: false,
main: 'dist/bundle.js',
name: 'pluginID',
source: 'src/index.js',
specVersion: 2,
title: 'Sample',
version: '1.0.0',
});
expect(plugin.isDevicePlugin).toBe(true);
expect(typeof plugin.module.Component).toBe('function');
expect(plugin.module.Component.displayName).toBe('FlipperPlugin(Sample)');
expect(typeof plugin.asDevicePluginModule().devicePlugin).toBe('function');
expect(typeof plugin.asDevicePluginModule().supportsDevice).toBe('function');
});

View File

@@ -0,0 +1,231 @@
/**
* 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 {computeUsageSummary} from '../tracking';
import type {State} from '../../reducers/usageTracking';
import type {SelectionInfo} from '../../utils/info';
const layoutSelection: SelectionInfo = {
plugin: 'Layout',
pluginName: 'flipper-plugin-layout',
pluginVersion: '0.0.0',
pluginEnabled: true,
app: 'Facebook',
device: 'test device',
deviceName: 'test device',
deviceSerial: 'serial',
deviceType: 'emulator',
os: 'iOS',
archived: false,
};
const networkSelection = {...layoutSelection, plugin: 'Network'};
const databasesSelection = {...layoutSelection, plugin: 'Databases'};
const layoutPluginKey = JSON.stringify(layoutSelection);
const networkPluginKey = JSON.stringify(networkSelection);
const databasesPluginKey = JSON.stringify(databasesSelection);
test('Never focused', () => {
const state: State = {
timeline: [{type: 'TIMELINE_START', time: 100, isFocused: false}],
};
const result = computeUsageSummary(state, 200);
expect(result.total).toReportTimeSpent('total', 0, 100);
});
test('Always focused', () => {
const state: State = {
timeline: [{type: 'TIMELINE_START', time: 100, isFocused: true}],
};
const result = computeUsageSummary(state, 200);
expect(result.total).toReportTimeSpent('total', 100, 0);
});
test('Focused then unfocused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: false},
],
};
const result = computeUsageSummary(state, 350);
expect(result.total).toReportTimeSpent('total', 50, 200);
});
test('Unfocused then focused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: false},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: true},
],
};
const result = computeUsageSummary(state, 350);
expect(result.total).toReportTimeSpent('total', 200, 50);
});
test('Unfocused then focused then unfocused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: false},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: true},
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false},
],
};
const result = computeUsageSummary(state, 650);
expect(result.total).toReportTimeSpent('total', 200, 350);
});
test('Focused then unfocused then focused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: false},
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: true},
],
};
const result = computeUsageSummary(state, 650);
expect(result.total).toReportTimeSpent('total', 350, 200);
});
test('Always focused plugin change', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{
type: 'SELECTION_CHANGED',
time: 150,
selectionKey: layoutPluginKey,
selection: layoutSelection,
},
],
};
const result = computeUsageSummary(state, 200);
expect(result.total).toReportTimeSpent('total', 100, 0);
expect(result.plugin[layoutPluginKey]).toReportTimeSpent('Layout', 50, 0);
});
test('Focused then plugin change then unfocusd', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{
type: 'SELECTION_CHANGED',
time: 150,
selectionKey: layoutPluginKey,
selection: layoutSelection,
},
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false},
],
};
const result = computeUsageSummary(state, 650);
expect(result.total).toReportTimeSpent('total', 250, 300);
expect(result.plugin[layoutPluginKey]).toReportTimeSpent('Layout', 200, 300);
});
test('Multiple plugin changes', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{
type: 'SELECTION_CHANGED',
time: 150,
selectionKey: layoutPluginKey,
selection: layoutSelection,
},
{
type: 'SELECTION_CHANGED',
time: 350,
selectionKey: networkPluginKey,
selection: networkSelection,
},
{
type: 'SELECTION_CHANGED',
time: 650,
selectionKey: layoutPluginKey,
selection: layoutSelection,
},
{
type: 'SELECTION_CHANGED',
time: 1050,
selectionKey: databasesPluginKey,
selection: databasesSelection,
},
],
};
const result = computeUsageSummary(state, 1550);
expect(result.total).toReportTimeSpent('total', 1450, 0);
expect(result.plugin[layoutPluginKey]).toReportTimeSpent('Layout', 600, 0);
expect(result.plugin[networkPluginKey]).toReportTimeSpent('Network', 300, 0);
expect(result.plugin[databasesPluginKey]).toReportTimeSpent(
'Databases',
500,
0,
);
});
declare global {
namespace jest {
interface Matchers<R> {
toReportTimeSpent(
plugin: string,
focusedTimeSpent: number,
unfocusedTimeSpent: number,
): R;
}
}
}
expect.extend({
toReportTimeSpent(
received: {focusedTime: number; unfocusedTime: number} | undefined,
plugin: string,
focusedTimeSpent: number,
unfocusedTimeSpent: number,
) {
if (!received) {
return {
message: () =>
`expected to have tracking element for plugin ${plugin}, but was not found`,
pass: false,
};
}
const focusedPass = received.focusedTime === focusedTimeSpent;
const unfocusedPass = received.unfocusedTime === unfocusedTimeSpent;
if (!focusedPass) {
return {
message: () =>
`expected ${JSON.stringify(
received,
)} to have focused time spent: ${focusedTimeSpent} for plugin ${plugin}, but was ${
received.focusedTime
}`,
pass: false,
};
}
if (!unfocusedPass) {
return {
message: () =>
`expected ${JSON.stringify(
received,
)} to have unfocused time spent: ${unfocusedTimeSpent} for plugin ${plugin}, but was ${
received.unfocusedTime
}`,
pass: false,
};
}
return {
message: () =>
`expected ${JSON.stringify(
received,
)} not to have focused time spent: ${focusedTimeSpent} and unfocused: ${unfocusedTimeSpent}`,
pass: true,
};
},
});

View File

@@ -0,0 +1,75 @@
/**
* 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 {Store} from '../reducers/index';
import {Logger} from 'flipper-common';
import {
importFileToStore,
IMPORT_FLIPPER_TRACE_EVENT,
} from '../utils/exportData';
import {tryCatchReportPlatformFailures} from 'flipper-common';
import {handleDeeplink} from '../deeplink';
import {Dialog} from 'flipper-plugin';
import {getRenderHostInstance} from '../RenderHost';
export default (store: Store, logger: Logger) => {
const renderHost = getRenderHostInstance();
const onFocus = () => {
setImmediate(() => {
store.dispatch({
type: 'windowIsFocused',
payload: {isFocused: true, time: Date.now()},
});
});
};
const onBlur = () => {
setImmediate(() => {
store.dispatch({
type: 'windowIsFocused',
payload: {isFocused: false, time: Date.now()},
});
});
};
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
window.addEventListener('beforeunload', () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
});
// windowIsFocussed is initialized in the store before the app is fully ready.
// So wait until everything is up and running and then check and set the isFocussed state.
window.addEventListener('flipper-store-ready', () => {
const isFocused = renderHost.hasFocus();
store.dispatch({
type: 'windowIsFocused',
payload: {isFocused: isFocused, time: Date.now()},
});
});
renderHost.onIpcEvent('flipper-protocol-handler', (query: string) => {
handleDeeplink(store, logger, query).catch((e) => {
console.warn('Failed to handle deeplink', query, e);
Dialog.alert({
title: 'Failed to open deeplink',
type: 'error',
message: `Failed to handle deeplink '${query}': ${
e.message ?? e.toString()
}`,
});
});
});
renderHost.onIpcEvent('open-flipper-file', (url: string) => {
tryCatchReportPlatformFailures(() => {
return importFileToStore(url, store);
}, `${IMPORT_FLIPPER_TRACE_EVENT}:Deeplink`);
});
};

View File

@@ -0,0 +1,16 @@
/**
* 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 async function loadPluginsFromMarketplace() {
// Marketplace is not implemented in public version of Flipper
}
export default () => {
// Marketplace is not implemented in public version of Flipper
};

View File

@@ -0,0 +1,12 @@
/**
* 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 () => {
// no public implementation
};

View File

@@ -0,0 +1,308 @@
/**
* 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 React from 'react';
import {State, Store} from '../reducers/index';
import {FlipperServer, Logger} from 'flipper-common';
import {FlipperServerImpl} from 'flipper-server-core';
import {selectClient} from '../reducers/connections';
import Client from '../Client';
import {notification} from 'antd';
import BaseDevice from '../devices/BaseDevice';
import {ClientDescription, timeout} from 'flipper-common';
import {reportPlatformFailures} from 'flipper-common';
import {sideEffect} from '../utils/sideEffect';
import {getStaticPath} from '../utils/pathUtils';
import constants from '../fb-stubs/constants';
import {getRenderHostInstance} from '../RenderHost';
export default async (store: Store, logger: Logger) => {
const {enableAndroid, androidHome, idbPath, enableIOS, enablePhysicalIOS} =
store.getState().settingsState;
const server = new FlipperServerImpl(
{
enableAndroid,
androidHome,
idbPath,
enableIOS,
enablePhysicalIOS,
staticPath: getStaticPath(),
tmpPath: getRenderHostInstance().paths.tempPath,
validWebSocketOrigins: constants.VALID_WEB_SOCKET_REQUEST_ORIGIN_PREFIXES,
},
logger,
);
store.dispatch({
type: 'SET_FLIPPER_SERVER',
payload: server,
});
server.on('notification', ({type, title, description}) => {
console.warn(`[$type] ${title}: ${description}`);
notification.open({
message: title,
description: description,
type: type,
duration: 0,
});
});
server.on('server-error', (err) => {
notification.error({
message: 'Failed to start connection server',
description:
err.code === 'EADDRINUSE' ? (
<>
Couldn't start connection server. Looks like you have multiple
copies of Flipper running or another process is using the same
port(s). As a result devices will not be able to connect to Flipper.
<br />
<br />
Please try to kill the offending process by running{' '}
<code>kill $(lsof -ti:PORTNUMBER)</code> and restart flipper.
<br />
<br />
{'' + err}
</>
) : (
<>Failed to start Flipper server: ${err.message}</>
),
duration: null,
});
});
server.on('device-connected', (deviceInfo) => {
logger.track('usage', 'register-device', {
os: deviceInfo.os,
name: deviceInfo.title,
serial: deviceInfo.serial,
});
const existing = store
.getState()
.connections.devices.find(
(device) => device.serial === deviceInfo.serial,
);
// handled outside reducer, as it might emit new redux actions...
if (existing) {
if (existing.connected.get()) {
console.warn(
`Tried to replace still connected device '${existing.serial}' with a new instance.`,
);
}
existing.destroy();
}
const device = new BaseDevice(server, deviceInfo);
device.loadDevicePlugins(
store.getState().plugins.devicePlugins,
store.getState().connections.enabledDevicePlugins,
);
store.dispatch({
type: 'REGISTER_DEVICE',
payload: device,
});
});
server.on('device-disconnected', (device) => {
logger.track('usage', 'unregister-device', {
os: device.os,
serial: device.serial,
});
// N.B.: note that we don't remove the device, we keep it in offline
});
server.on('client-setup', (client) => {
store.dispatch({
type: 'START_CLIENT_SETUP',
payload: client,
});
});
server.on('client-connected', (payload: ClientDescription) =>
handleClientConnected(server, store, logger, payload),
);
server.on('client-disconnected', ({id}) => {
const existingClient = store.getState().connections.clients.get(id);
existingClient?.disconnect();
});
server.on('client-message', ({id, message}) => {
const existingClient = store.getState().connections.clients.get(id);
existingClient?.onMessage(message);
});
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
server.close();
});
}
server
.start()
.then(() => {
console.log(
'Flipper server started and accepting device / client connections',
);
})
.catch((e) => {
console.error('Failed to start Flipper server', e);
notification.error({
message: 'Failed to start Flipper server',
description: 'error: ' + e,
});
});
return () => {
server.close();
};
};
export async function handleClientConnected(
server: Pick<FlipperServer, 'exec'>,
store: Store,
logger: Logger,
{id, query}: ClientDescription,
) {
const {connections} = store.getState();
const existingClient = connections.clients.get(id);
if (existingClient) {
existingClient.destroy();
store.dispatch({
type: 'CLEAR_CLIENT_PLUGINS_STATE',
payload: {
clientId: id,
devicePlugins: new Set(),
},
});
store.dispatch({
type: 'CLIENT_REMOVED',
payload: id,
});
}
console.log(
`[conn] Searching matching device ${query.device_id} for client ${query.app}...`,
);
const device =
getDeviceBySerial(store.getState(), query.device_id) ??
(await findDeviceForConnection(store, query.app, query.device_id).catch(
(e) => {
console.error(
`[conn] Failed to find device '${query.device_id}' while connection app '${query.app}'`,
e,
);
notification.error({
message: 'Connection failed',
description: `Failed to find device '${query.device_id}' while trying to connect app '${query.app}'`,
duration: 0,
});
},
));
if (!device) {
return;
}
const client = new Client(
id,
query,
{
send(data: any) {
server.exec('client-request', id, data);
},
async sendExpectResponse(data: any) {
return await server.exec('client-request-response', id, data);
},
},
logger,
store,
undefined,
device,
);
console.debug(
`Device client initialized: ${client.id}. Supported plugins: ${Array.from(
client.plugins,
).join(', ')}`,
'server',
);
store.dispatch({
type: 'NEW_CLIENT',
payload: client,
});
store.dispatch(selectClient(client.id));
await timeout(
30 * 1000,
client.init(),
`[conn] Failed to initialize client ${query.app} on ${query.device_id} in a timely manner`,
);
console.log(`[conn] ${query.app} on ${query.device_id} connected and ready.`);
}
function getDeviceBySerial(
state: State,
serial: string,
): BaseDevice | undefined {
return state.connections.devices.find((device) => device.serial === serial);
}
async function findDeviceForConnection(
store: Store,
clientId: string,
serial: string,
): Promise<BaseDevice> {
let lastSeenDeviceList: BaseDevice[] = [];
/* All clients should have a corresponding Device in the store.
However, clients can connect before a device is registered, so wait a
while for the device to be registered if it isn't already. */
return reportPlatformFailures(
new Promise<BaseDevice>((resolve, reject) => {
let unsubscribe: () => void = () => {};
const timeout = setTimeout(() => {
unsubscribe();
reject(
new Error(
`Timed out waiting for device ${serial} for client ${clientId}`,
),
);
}, 15000);
unsubscribe = sideEffect(
store,
{name: 'waitForDevice', throttleMs: 100},
(state) => state.connections.devices,
(newDeviceList) => {
if (newDeviceList === lastSeenDeviceList) {
return;
}
lastSeenDeviceList = newDeviceList;
const matchingDevice = newDeviceList.find(
(device) => device.serial === serial,
);
if (matchingDevice) {
console.log(`[conn] Found device for: ${clientId} on ${serial}.`);
clearTimeout(timeout);
resolve(matchingDevice);
unsubscribe();
}
},
);
}),
'client-setMatchingDevice',
);
}

View File

@@ -0,0 +1,646 @@
/**
* 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 React from 'react';
import {Dialog, getFlipperLib} from 'flipper-plugin';
import {isTest} from 'flipper-common';
import {getUser} from '../fb-stubs/user';
import {State, Store} from '../reducers/index';
import {checkForUpdate} from '../fb-stubs/checkForUpdate';
import {getAppVersion} from '../utils/info';
import {UserNotSignedInError} from 'flipper-common';
import {
canBeDefaultDevice,
selectPlugin,
setPluginEnabled,
} from '../reducers/connections';
import {getUpdateAvailableMessage} from '../chrome/UpdateIndicator';
import {Typography} from 'antd';
import {getPluginStatus, PluginStatus} from '../utils/pluginUtils';
import {loadPluginsFromMarketplace} from './fb-stubs/pluginMarketplace';
import {loadPlugin, switchPlugin} from '../reducers/pluginManager';
import {startPluginDownload} from '../reducers/pluginDownloads';
import isProduction from '../utils/isProduction';
import BaseDevice from '../devices/BaseDevice';
import Client from '../Client';
import {RocketOutlined} from '@ant-design/icons';
import {showEmulatorLauncher} from '../sandy-chrome/appinspect/LaunchEmulator';
import {getAllClients} from '../reducers/connections';
import {showLoginDialog} from '../chrome/fb-stubs/SignInSheet';
import {
DeeplinkInteraction,
DeeplinkInteractionState,
OpenPluginParams,
} from '../deeplinkTracking';
import {getRenderHostInstance} from '../RenderHost';
export function parseOpenPluginParams(query: string): OpenPluginParams {
// 'flipper://open-plugin?plugin-id=graphql&client=facebook&devices=android,ios&chrome=1&payload='
const url = new URL(query);
const params = new Map<string, string>(url.searchParams as any);
if (!params.has('plugin-id')) {
throw new Error('Missing plugin-id param');
}
return {
pluginId: params.get('plugin-id')!,
client: params.get('client'),
devices: params.get('devices')?.split(',') ?? [],
payload: params.get('payload')
? decodeURIComponent(params.get('payload')!)
: undefined,
};
}
export async function handleOpenPluginDeeplink(
store: Store,
query: string,
trackInteraction: (interaction: DeeplinkInteraction) => void,
) {
const params = parseOpenPluginParams(query);
const title = `Opening plugin ${params.pluginId}`;
console.debug(`[deeplink] ${title} for with params`, params);
if (!(await verifyLighthouseAndUserLoggedIn(store, title))) {
trackInteraction({
state: 'PLUGIN_LIGHTHOUSE_BAIL',
plugin: params,
});
return;
}
console.debug('[deeplink] Cleared Lighthouse and log-in check.');
await verifyFlipperIsUpToDate(title);
console.debug('[deeplink] Cleared up-to-date check.');
const [pluginStatusResult, pluginStatus] = await verifyPluginStatus(
store,
params.pluginId,
title,
);
if (!pluginStatusResult) {
trackInteraction({
state: 'PLUGIN_STATUS_BAIL',
plugin: params,
extra: {pluginStatus},
});
return;
}
console.debug('[deeplink] Cleared plugin status check:', pluginStatusResult);
const isDevicePlugin = store
.getState()
.plugins.devicePlugins.has(params.pluginId);
const pluginDefinition = isDevicePlugin
? store.getState().plugins.devicePlugins.get(params.pluginId)!
: store.getState().plugins.clientPlugins.get(params.pluginId)!;
const deviceOrClient = await selectDevicesAndClient(
store,
params,
title,
isDevicePlugin,
);
console.debug('[deeplink] Selected device and client:', deviceOrClient);
if ('errorState' in deviceOrClient) {
trackInteraction({
state: deviceOrClient.errorState,
plugin: params,
});
return;
}
const client: Client | undefined = isDevicePlugin
? undefined
: (deviceOrClient as Client);
const device: BaseDevice = isDevicePlugin
? (deviceOrClient as BaseDevice)
: (deviceOrClient as Client).device;
console.debug('[deeplink] Client: ', client);
console.debug('[deeplink] Device: ', device);
// verify plugin supported by selected device / client
if (isDevicePlugin && !device.supportsPlugin(pluginDefinition)) {
await Dialog.alert({
title,
type: 'error',
message: `This plugin is not supported by device ${device.displayTitle()}`,
});
trackInteraction({
state: 'PLUGIN_DEVICE_UNSUPPORTED',
plugin: params,
extra: {device: device.displayTitle()},
});
return;
}
console.debug('[deeplink] Cleared device plugin support check.');
if (!isDevicePlugin && !client!.plugins.has(params.pluginId)) {
await Dialog.alert({
title,
type: 'error',
message: `This plugin is not supported by client ${client!.query.app}`,
});
trackInteraction({
state: 'PLUGIN_CLIENT_UNSUPPORTED',
plugin: params,
extra: {client: client!.query.app},
});
return;
}
console.debug('[deeplink] Cleared client plugin support check.');
// verify plugin enabled
if (isDevicePlugin) {
// for the device plugins enabling is a bit more complication and should go through the pluginManager
if (
!store.getState().connections.enabledDevicePlugins.has(params.pluginId)
) {
store.dispatch(switchPlugin({plugin: pluginDefinition}));
}
} else {
store.dispatch(setPluginEnabled(params.pluginId, client!.query.app));
}
console.debug('[deeplink] Cleared plugin enabling.');
// open the plugin
if (isDevicePlugin) {
store.dispatch(
selectPlugin({
selectedPlugin: params.pluginId,
selectedAppId: null,
selectedDevice: device,
deepLinkPayload: params.payload,
}),
);
} else {
store.dispatch(
selectPlugin({
selectedPlugin: params.pluginId,
selectedAppId: client!.id,
selectedDevice: device,
deepLinkPayload: params.payload,
}),
);
}
trackInteraction({
state: 'PLUGIN_OPEN_SUCCESS',
plugin: params,
});
}
// check if user is connected to VPN and logged in. Returns true if OK, or false if aborted
async function verifyLighthouseAndUserLoggedIn(
store: Store,
title: string,
): Promise<boolean> {
if (!getFlipperLib().isFB || process.env.NODE_ENV === 'test') {
return true; // ok, continue
}
// repeat until connection succeeded
while (true) {
const spinnerDialog = Dialog.loading({
title,
message: 'Checking connection to Facebook Intern',
});
try {
const user = await getUser();
spinnerDialog.close();
// User is logged in
if (user) {
return true;
} else {
// Connected, but not logged in or no valid profile object returned
return await showPleaseLoginDialog(store, title);
}
} catch (e) {
spinnerDialog.close();
if (e instanceof UserNotSignedInError) {
// connection, but user is not logged in
return await showPleaseLoginDialog(store, title);
}
// General connection error.
// Not connected (to presumably) intern at all
if (
!(await Dialog.confirm({
title,
message:
'It looks you are currently not connected to Lighthouse / VPN. Please connect and retry.',
okText: 'Retry',
}))
) {
return false;
}
}
}
}
async function showPleaseLoginDialog(
store: Store,
title: string,
): Promise<boolean> {
if (
!(await Dialog.confirm({
title,
message: 'You are currently not logged in, please login.',
okText: 'Login',
}))
) {
// cancelled login
return false;
}
await showLoginDialog();
// wait until login succeeded
await waitForLogin(store);
return true;
}
async function waitForLogin(store: Store) {
return waitFor(store, (state) => !!state.user?.id);
}
// make this more reusable?
function waitFor(
store: Store,
predicate: (state: State) => boolean,
): Promise<void> {
return new Promise<void>((resolve) => {
const unsub = store.subscribe(() => {
if (predicate(store.getState())) {
unsub();
resolve();
}
});
});
}
async function verifyFlipperIsUpToDate(title: string) {
if (!isProduction() || isTest()) {
return;
}
const currentVersion = getAppVersion();
const handle = Dialog.loading({
title,
message: 'Checking if Flipper is up-to-date',
});
try {
const result = await checkForUpdate(currentVersion);
handle.close();
switch (result.kind) {
case 'error':
// if we can't tell if we're up to date, we don't want to halt the process on that.
console.warn('Failed to verify Flipper version', result);
return;
case 'up-to-date':
return;
case 'update-available':
await Dialog.confirm({
title,
message: (
<Typography.Text>
{getUpdateAvailableMessage(result)}
</Typography.Text>
),
okText: 'Skip',
});
return;
}
} catch (e) {
// if we can't tell if we're up to date, we don't want to halt the process on that.
console.warn('Failed to verify Flipper version', e);
handle.close();
}
}
async function verifyPluginStatus(
store: Store,
pluginId: string,
title: string,
): Promise<[boolean, PluginStatus]> {
// make sure we have marketplace plugin data present
if (!isTest() && !store.getState().plugins.marketplacePlugins.length) {
// plugins not yet fetched
// updates plugins from marketplace (if logged in), and stores them
await loadPluginsFromMarketplace();
}
// while true loop; after pressing install or add GK, we want to check again if plugin is available
while (true) {
const [status, reason] = getPluginStatus(store, pluginId);
switch (status) {
case 'ready':
return [true, status];
case 'unknown':
await Dialog.alert({
type: 'warning',
title,
message: `No plugin with id '${pluginId}' is known to Flipper. Please correct the deeplink, or install the plugin from NPM using the plugin manager.`,
});
return [false, status];
case 'failed':
await Dialog.alert({
type: 'error',
title,
message: `We found plugin '${pluginId}', but failed to load it: ${reason}. Please check the logs for more details`,
});
return [false, status];
case 'gatekeeped':
if (
!(await Dialog.confirm({
title,
message: (
<p>
{`To use plugin '${pluginId}', it is necessary to be a member of the GK '${reason}'. Click `}
<Typography.Link
href={`https://www.internalfb.com/intern/gatekeeper/projects/${reason}`}>
here
</Typography.Link>{' '}
to enroll, restart Flipper, and click the link again.
</p>
),
okText: 'Restart',
onConfirm: async () => {
getRenderHostInstance().restartFlipper();
// intentionally forever pending, we're restarting...
return new Promise(() => {});
},
}))
) {
return [false, status];
}
break;
case 'bundle_installable': {
// For convenience, don't ask user to install bundled plugins, handle it directly
await installBundledPlugin(store, pluginId, title);
break;
}
case 'marketplace_installable': {
if (!(await installMarketPlacePlugin(store, pluginId, title))) {
return [false, status];
}
break;
}
default:
throw new Error('Unhandled state: ' + status);
}
}
}
async function installBundledPlugin(
store: Store,
pluginId: string,
title: string,
) {
const plugin = store.getState().plugins.bundledPlugins.get(pluginId);
if (!plugin || !plugin.isBundled) {
throw new Error(`Failed to find bundled plugin '${pluginId}'`);
}
const loadingDialog = Dialog.loading({
title,
message: `Loading plugin '${pluginId}'...`,
});
store.dispatch(loadPlugin({plugin, enable: true, notifyIfFailed: true}));
try {
await waitFor(
store,
() => getPluginStatus(store, pluginId)[0] !== 'bundle_installable',
);
} finally {
loadingDialog.close();
}
}
async function installMarketPlacePlugin(
store: Store,
pluginId: string,
title: string,
): Promise<boolean> {
if (
!(await Dialog.confirm({
title,
message: `The requested plugin '${pluginId}' is currently not installed, but can be downloaded from the Flipper plugin Marketplace. If you trust the source of the current link, press 'Install' to continue`,
okText: 'Install',
}))
) {
return false;
}
const plugin = store
.getState()
.plugins.marketplacePlugins.find((p) => p.id === pluginId);
if (!plugin) {
throw new Error(`Failed to find marketplace plugin '${pluginId}'`);
}
const loadingDialog = Dialog.loading({
title,
message: `Installing plugin '${pluginId}'...`,
});
try {
store.dispatch(startPluginDownload({plugin, startedByUser: true}));
await waitFor(
store,
() => getPluginStatus(store, pluginId)[0] !== 'marketplace_installable',
);
} finally {
loadingDialog.close();
}
return true;
}
type DeeplinkError = {
errorState: DeeplinkInteractionState;
};
async function selectDevicesAndClient(
store: Store,
params: OpenPluginParams,
title: string,
isDevicePlugin: boolean,
): Promise<DeeplinkError | BaseDevice | Client> {
function findValidDevices() {
// find connected devices with the right OS.
return (
store
.getState()
.connections.devices.filter((d) => d.connected.get())
.filter(
(d) => params.devices.length === 0 || params.devices.includes(d.os),
)
// This filters out OS-level devices which are causing more confusion than good
// when used with deeplinks.
.filter(canBeDefaultDevice)
);
}
// loop until we have devices (or abort)
while (!findValidDevices().length) {
if (!(await launchDeviceDialog(store, params, title))) {
return {errorState: 'PLUGIN_DEVICE_BAIL'};
}
}
// at this point we have 1 or more valid devices
const availableDevices = findValidDevices();
console.debug(
'[deeplink] selectDevicesAndClient found at least one more valid device:',
availableDevices,
);
// device plugin
if (isDevicePlugin) {
if (availableDevices.length === 1) {
return availableDevices[0];
}
const selectedDevice = await selectDeviceDialog(availableDevices, title);
if (!selectedDevice) {
return {errorState: 'PLUGIN_DEVICE_SELECTION_BAIL'};
}
return selectedDevice;
}
console.debug('[deeplink] Not a device plugin. Waiting for valid client.');
// wait for valid client
while (true) {
const origClients = store.getState().connections.clients;
const validClients = getAllClients(store.getState().connections)
.filter(
// correct app name, or, if not set, an app that at least supports this plugin
(c) =>
params.client
? c.query.app === params.client
: c.plugins.has(params.pluginId),
)
.filter((c) => c.connected.get())
.filter((c) => availableDevices.includes(c.device));
if (validClients.length === 1) {
return validClients[0];
}
if (validClients.length > 1) {
const selectedClient = await selectClientDialog(validClients, title);
if (!selectedClient) {
return {errorState: 'PLUGIN_CLIENT_SELECTION_BAIL'};
}
return selectedClient;
}
// no valid client yet
const result = await new Promise<boolean>((resolve) => {
const dialog = Dialog.alert({
title,
type: 'warning',
message: params.client
? `Application '${params.client}' doesn't seem to be connected yet. Please start a debug version of the app to continue.`
: `No application that supports plugin '${params.pluginId}' seems to be running. Please start a debug application that supports the plugin to continue.`,
okText: 'Cancel',
});
// eslint-disable-next-line promise/catch-or-return
dialog.then(() => resolve(false));
// eslint-disable-next-line promise/catch-or-return
waitFor(store, (state) => state.connections.clients !== origClients).then(
() => {
dialog.close();
resolve(true);
},
);
// We also want to react to changes in the available plugins and refresh.
origClients.forEach((c) =>
c.on('plugins-change', () => {
dialog.close();
resolve(true);
}),
);
});
if (!result) {
return {errorState: 'PLUGIN_CLIENT_BAIL'}; // User cancelled
}
}
}
/**
* Shows a warning that no device was found, with button to launch emulator.
* Resolves false if cancelled, or true if new devices were detected.
*/
async function launchDeviceDialog(
store: Store,
params: OpenPluginParams,
title: string,
) {
return new Promise<boolean>((resolve) => {
const currentDevices = store.getState().connections.devices;
const waitForNewDevice = async () =>
await waitFor(
store,
(state) => state.connections.devices !== currentDevices,
);
const dialog = Dialog.confirm({
title,
message: (
<p>
To open the current deeplink for plugin {params.pluginId} a device{' '}
{params.devices.length ? ' of type ' + params.devices.join(', ') : ''}{' '}
should be up and running. No device was found. Please connect a device
or launch an emulator / simulator.
</p>
),
cancelText: 'Cancel',
okText: 'Launch Device',
onConfirm: async () => {
showEmulatorLauncher(store);
await waitForNewDevice();
return true;
},
okButtonProps: {
icon: <RocketOutlined />,
},
});
// eslint-disable-next-line promise/catch-or-return
dialog.then(() => {
// dialog was cancelled
resolve(false);
});
// new devices were found
// eslint-disable-next-line promise/catch-or-return
waitForNewDevice().then(() => {
dialog.close();
resolve(true);
});
});
}
async function selectDeviceDialog(
devices: BaseDevice[],
title: string,
): Promise<undefined | BaseDevice> {
const selectedId = await Dialog.options({
title,
message: 'Select the device to open:',
options: devices.map((d) => ({
value: d.serial,
label: d.displayTitle(),
})),
});
// might find nothing if id === false
return devices.find((d) => d.serial === selectedId);
}
async function selectClientDialog(
clients: Client[],
title: string,
): Promise<undefined | Client> {
const selectedId = await Dialog.options({
title,
message:
'Multiple applications running this plugin were found, please select one:',
options: clients.map((c) => ({
value: c.id,
label: `${c.query.app} on ${c.device.displayTitle()}`,
})),
});
// might find nothing if id === false
return clients.find((c) => c.id === selectedId);
}

View File

@@ -0,0 +1,52 @@
/**
* 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
*/
// Used responsibly.
import flipperServer from './flipperServer';
import application from './application';
import tracking from './tracking';
import notifications from './notifications';
import plugins from './plugins';
import user from './fb-stubs/user';
import pluginManager from './pluginManager';
import reactNative from './reactNative';
import pluginMarketplace from './fb-stubs/pluginMarketplace';
import pluginDownloads from './pluginDownloads';
import info from '../utils/info';
import pluginChangeListener from './pluginsChangeListener';
import {Logger} from 'flipper-common';
import {Store} from '../reducers/index';
import {Dispatcher} from './types';
import {notNull} from '../utils/typeUtils';
export default function (store: Store, logger: Logger): () => Promise<void> {
// This only runs in development as when the reload
// kicks in it doesn't unregister the shortcuts
const dispatchers: Array<Dispatcher> = [
application,
tracking,
flipperServer,
notifications,
plugins,
user,
pluginManager,
reactNative,
pluginMarketplace,
pluginDownloads,
info,
pluginChangeListener,
].filter(notNull);
const globalCleanup = dispatchers
.map((dispatcher) => dispatcher(store, logger))
.filter(Boolean);
return () => {
return Promise.all(globalCleanup).then(() => {});
};
}

View File

@@ -0,0 +1,162 @@
/**
* 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 {Store} from '../reducers/index';
import {Logger} from 'flipper-common';
import {PluginNotification} from '../reducers/notifications';
import reactElementToJSXString from 'react-element-to-jsx-string';
import {
updatePluginBlocklist,
updateCategoryBlocklist,
} from '../reducers/notifications';
import {textContent} from 'flipper-plugin';
import {getPluginTitle} from '../utils/pluginUtils';
import {sideEffect} from '../utils/sideEffect';
import {openNotification} from '../sandy-chrome/notification/Notification';
import {getRenderHostInstance} from '../RenderHost';
export type NotificationEvents =
| 'show'
| 'click'
| 'close'
| 'reply'
| 'action';
const NOTIFICATION_THROTTLE = 5 * 1000; // in milliseconds
export default (store: Store, logger: Logger) => {
const knownNotifications: Set<string> = new Set();
const lastNotificationTime: Map<string, number> = new Map();
getRenderHostInstance().onIpcEvent(
'notificationEvent',
(
eventName: NotificationEvents,
pluginNotification: PluginNotification,
arg: null | string | number,
) => {
if (eventName === 'click' || (eventName === 'action' && arg === 0)) {
openNotification(store, pluginNotification);
} else if (eventName === 'action') {
if (arg === 1 && pluginNotification.notification.category) {
// Hide similar (category)
logger.track(
'usage',
'notification-hide-category',
pluginNotification,
);
const {category} = pluginNotification.notification;
const {blocklistedCategories} = store.getState().notifications;
if (category && blocklistedCategories.indexOf(category) === -1) {
store.dispatch(
updateCategoryBlocklist([...blocklistedCategories, category]),
);
}
} else if (arg === 2) {
// Hide plugin
logger.track('usage', 'notification-hide-plugin', pluginNotification);
const {blocklistedPlugins} = store.getState().notifications;
if (blocklistedPlugins.indexOf(pluginNotification.pluginId) === -1) {
store.dispatch(
updatePluginBlocklist([
...blocklistedPlugins,
pluginNotification.pluginId,
]),
);
}
}
}
},
);
sideEffect(
store,
{name: 'notifications', throttleMs: 500},
({notifications, plugins}) => ({
notifications,
devicePlugins: plugins.devicePlugins,
clientPlugins: plugins.clientPlugins,
}),
({notifications, devicePlugins, clientPlugins}, store) => {
function getPlugin(name: string) {
return devicePlugins.get(name) ?? clientPlugins.get(name);
}
const {activeNotifications, blocklistedPlugins, blocklistedCategories} =
notifications;
activeNotifications
.map((n) => ({
...n,
notification: {
...n.notification,
message: textContent(n.notification.message),
},
}))
.forEach((n: PluginNotification) => {
if (
store.getState().connections.selectedPlugin !== 'notifications' &&
!knownNotifications.has(n.notification.id) &&
blocklistedPlugins.indexOf(n.pluginId) === -1 &&
(!n.notification.category ||
blocklistedCategories.indexOf(n.notification.category) === -1)
) {
const prevNotificationTime: number =
lastNotificationTime.get(n.pluginId) || 0;
lastNotificationTime.set(n.pluginId, new Date().getTime());
knownNotifications.add(n.notification.id);
if (
new Date().getTime() - prevNotificationTime <
NOTIFICATION_THROTTLE
) {
// Don't send a notification if the plugin has sent a notification
// within the NOTIFICATION_THROTTLE.
return;
}
const plugin = getPlugin(n.pluginId);
getRenderHostInstance().sendIpcEvent('sendNotification', {
payload: {
title: n.notification.title,
body: reactElementToJSXString(n.notification.message),
actions: [
{
type: 'button',
text: 'Show',
},
{
type: 'button',
text: 'Hide similar',
},
{
type: 'button',
text: `Hide all ${
plugin != null ? getPluginTitle(plugin) : ''
}`,
},
],
closeButtonText: 'Hide',
},
closeAfter: 10000,
pluginNotification: n,
});
logger.track('usage', 'native-notification', {
...n.notification,
message:
typeof n.notification.message === 'string'
? n.notification.message
: '<ReactNode>',
});
}
});
},
);
};

View File

@@ -0,0 +1,175 @@
/**
* 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 {
DownloadablePluginDetails,
getInstalledPluginDetails,
getPluginVersionInstallationDir,
InstalledPluginDetails,
installPluginFromFile,
} from 'flipper-plugin-lib';
import {State, Store} from '../reducers/index';
import {
PluginDownloadStatus,
pluginDownloadStarted,
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>;
export default (store: Store) => {
sideEffect(
store,
{name: 'handlePluginDownloads', throttleMs: 1000, fireImmediately: true},
(state) => state.pluginDownloads,
(state, store) => {
for (const download of Object.values(state)) {
if (download.status === PluginDownloadStatus.QUEUED) {
reportUsage(
'plugin-auto-update:download',
{
version: download.plugin.version,
startedByUser: download.startedByUser ? '1' : '0',
},
download.plugin.id,
);
reportPlatformFailures(
handlePluginDownload(
download.plugin,
download.startedByUser,
store,
),
'plugin-auto-update:download',
).catch(() => {});
}
}
},
);
return async () => {};
};
async function handlePluginDownload(
plugin: DownloadablePluginDetails,
startedByUser: boolean,
store: Store,
) {
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}".`,
);
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));
}
if (pluginIsDisabledForAllConnectedClients(store.getState(), plugin)) {
dispatch(
loadPlugin({
plugin: installedPlugin,
enable: startedByUser,
notifyIfFailed: startedByUser,
}),
);
}
console.log(
`Successfully downloaded and installed plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
);
} catch (error) {
console.error(
`Failed to download plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
error,
);
if (startedByUser) {
showErrorNotification(
`Failed to download plugin "${title}" v${version}.`,
'Please check that you are on VPN/Lighthouse.',
);
}
throw error;
} finally {
dispatch(pluginDownloadFinished({plugin}));
await fs.remove(tmpDir);
}
}
function pluginIsDisabledForAllConnectedClients(
state: State,
plugin: DownloadablePluginDetails,
) {
return (
!state.plugins.clientPlugins.has(plugin.id) ||
!getAllClients(state.connections).some((c) =>
state.connections.enabledPlugins[c.query.app]?.includes(plugin.id),
)
);
}

View File

@@ -0,0 +1,343 @@
/**
* 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 type {Store} from '../reducers/index';
import type {Logger} from 'flipper-common';
import {
LoadPluginActionPayload,
UninstallPluginActionPayload,
UpdatePluginActionPayload,
pluginCommandsProcessed,
SwitchPluginActionPayload,
PluginCommand,
} from '../reducers/pluginManager';
import {
getInstalledPlugins,
cleanupOldInstalledPluginVersions,
removePlugins,
ActivatablePluginDetails,
} from 'flipper-plugin-lib';
import {sideEffect} from '../utils/sideEffect';
import {requirePlugin} from './plugins';
import {showErrorNotification} from '../utils/notifications';
import {PluginDefinition} from '../plugin';
import type Client from '../Client';
import {unloadModule} from '../utils/electronModuleCache';
import {
pluginLoaded,
pluginUninstalled,
registerInstalledPlugins,
} from '../reducers/plugins';
import {_SandyPluginDefinition} from 'flipper-plugin';
import {
setDevicePluginEnabled,
setDevicePluginDisabled,
setPluginEnabled,
setPluginDisabled,
getClientsByAppName,
getAllClients,
} from '../reducers/connections';
import {deconstructClientId} from 'flipper-common';
import {clearMessageQueue} from '../reducers/pluginMessageQueue';
import {
isDevicePluginDefinition,
defaultEnabledBackgroundPlugins,
} from '../utils/pluginUtils';
import {getPluginKey} from '../utils/pluginKey';
const maxInstalledPluginVersionsToKeep = 2;
async function refreshInstalledPlugins(store: Store) {
await removePlugins(store.getState().plugins.uninstalledPluginNames.values());
await cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep);
const plugins = await getInstalledPlugins();
return store.dispatch(registerInstalledPlugins(plugins));
}
export default (
store: Store,
_logger: Logger,
{runSideEffectsSynchronously}: {runSideEffectsSynchronously: boolean} = {
runSideEffectsSynchronously: false,
},
) => {
// This needn't happen immediately and is (light) I/O work.
if (window.requestIdleCallback) {
window.requestIdleCallback(() => {
refreshInstalledPlugins(store).catch((err) =>
console.error('Failed to refresh installed plugins:', err),
);
});
}
const unsubscribeHandlePluginCommands = sideEffect(
store,
{
name: 'handlePluginCommands',
throttleMs: 0,
fireImmediately: true,
runSynchronously: runSideEffectsSynchronously, // Used to simplify writing tests, if "true" passed, the all side effects will be called synchronously and immediately after changes
noTimeBudgetWarns: true, // These side effects are critical, so we're doing them with zero throttling and want to avoid unnecessary warns
},
(state) => state.pluginManager.pluginCommandsQueue,
processPluginCommandsQueue,
);
return async () => {
unsubscribeHandlePluginCommands();
};
};
export function processPluginCommandsQueue(
queue: PluginCommand[],
store: Store,
) {
for (const command of queue) {
try {
switch (command.type) {
case 'LOAD_PLUGIN':
loadPlugin(store, command.payload);
break;
case 'UNINSTALL_PLUGIN':
uninstallPlugin(store, command.payload);
break;
case 'UPDATE_PLUGIN':
updatePlugin(store, command.payload);
break;
case 'SWITCH_PLUGIN':
switchPlugin(store, command.payload);
break;
default:
console.error('Unexpected plugin command', command);
break;
}
} catch (e) {
// make sure that upon failure the command is still marked processed to avoid
// unending loops!
console.error('Failed to process command', command);
}
}
store.dispatch(pluginCommandsProcessed(queue.length));
}
function loadPlugin(store: Store, payload: LoadPluginActionPayload) {
try {
const plugin = requirePlugin(payload.plugin);
const enablePlugin = payload.enable;
updatePlugin(store, {plugin, enablePlugin});
} catch (err) {
console.error(
`Failed to load plugin ${payload.plugin.title} v${payload.plugin.version}`,
err,
);
if (payload.notifyIfFailed) {
showErrorNotification(
`Failed to load plugin "${payload.plugin.title}" v${payload.plugin.version}`,
);
}
}
}
function uninstallPlugin(store: Store, {plugin}: UninstallPluginActionPayload) {
try {
const state = store.getState();
const clients = state.connections.clients;
clients.forEach((client) => {
stopPlugin(client, plugin.id);
});
if (!plugin.details.isBundled) {
unloadPluginModule(plugin.details);
}
store.dispatch(pluginUninstalled(plugin.details));
} catch (err) {
console.error(
`Failed to uninstall plugin ${plugin.title} v${plugin.version}`,
err,
);
showErrorNotification(
`Failed to uninstall plugin "${plugin.title}" v${plugin.version}`,
);
}
}
function updatePlugin(store: Store, payload: UpdatePluginActionPayload) {
const {plugin, enablePlugin} = payload;
if (isDevicePluginDefinition(plugin)) {
return updateDevicePlugin(store, plugin, enablePlugin);
} else {
return updateClientPlugin(store, plugin, enablePlugin);
}
}
function getSelectedAppName(store: Store) {
const {connections} = store.getState();
const selectedAppId = connections.selectedAppId
? deconstructClientId(connections.selectedAppId).app
: undefined;
return selectedAppId;
}
function switchPlugin(
store: Store,
{plugin, selectedApp}: SwitchPluginActionPayload,
) {
if (isDevicePluginDefinition(plugin)) {
switchDevicePlugin(store, plugin);
} else {
switchClientPlugin(store, plugin, selectedApp);
}
}
function switchClientPlugin(
store: Store,
plugin: PluginDefinition,
selectedApp: string | undefined,
) {
selectedApp = selectedApp ?? getSelectedAppName(store);
if (!selectedApp) {
return;
}
const {connections} = store.getState();
const clients = getClientsByAppName(connections.clients, selectedApp);
if (connections.enabledPlugins[selectedApp]?.includes(plugin.id)) {
clients.forEach((client) => {
stopPlugin(client, plugin.id);
const pluginKey = getPluginKey(
client.id,
{serial: client.query.device_id},
plugin.id,
);
store.dispatch(clearMessageQueue(pluginKey));
});
store.dispatch(setPluginDisabled(plugin.id, selectedApp));
} else {
clients.forEach((client) => {
startPlugin(client, plugin);
});
store.dispatch(setPluginEnabled(plugin.id, selectedApp));
}
}
function switchDevicePlugin(store: Store, plugin: PluginDefinition) {
const {connections} = store.getState();
const devicesWithPlugin = connections.devices.filter((d) =>
d.supportsPlugin(plugin.details),
);
if (connections.enabledDevicePlugins.has(plugin.id)) {
devicesWithPlugin.forEach((d) => {
d.unloadDevicePlugin(plugin.id);
});
store.dispatch(setDevicePluginDisabled(plugin.id));
} else {
devicesWithPlugin.forEach((d) => {
d.loadDevicePlugin(plugin);
});
store.dispatch(setDevicePluginEnabled(plugin.id));
}
}
function updateClientPlugin(
store: Store,
plugin: PluginDefinition,
enable: boolean,
) {
const clients = getAllClients(store.getState().connections);
if (enable) {
const selectedApp = getSelectedAppName(store);
if (selectedApp) {
store.dispatch(setPluginEnabled(plugin.id, selectedApp));
}
}
const clientsWithEnabledPlugin = clients.filter((c) => {
return (
c.supportsPlugin(plugin.id) &&
store
.getState()
.connections.enabledPlugins[c.query.app]?.includes(plugin.id)
);
});
const previousVersion = store.getState().plugins.clientPlugins.get(plugin.id);
clientsWithEnabledPlugin.forEach((client) => {
stopPlugin(client, plugin.id);
});
clientsWithEnabledPlugin.forEach((client) => {
startPlugin(client, plugin, true);
});
store.dispatch(pluginLoaded(plugin));
if (previousVersion) {
// unload previous version from Electron cache
unloadPluginModule(previousVersion.details);
}
}
function updateDevicePlugin(
store: Store,
plugin: PluginDefinition,
enable: boolean,
) {
if (enable) {
store.dispatch(setDevicePluginEnabled(plugin.id));
}
const connections = store.getState().connections;
const devicesWithEnabledPlugin = connections.devices.filter((d) =>
d.supportsPlugin(plugin),
);
devicesWithEnabledPlugin.forEach((d) => {
d.unloadDevicePlugin(plugin.id);
});
const previousVersion = store.getState().plugins.devicePlugins.get(plugin.id);
if (previousVersion) {
// unload previous version from Electron cache
unloadPluginModule(previousVersion.details);
}
store.dispatch(pluginLoaded(plugin));
devicesWithEnabledPlugin.forEach((d) => {
d.loadDevicePlugin(plugin);
});
}
function startPlugin(
client: Client,
plugin: PluginDefinition,
forceInitBackgroundPlugin: boolean = false,
) {
client.startPluginIfNeeded(plugin, true);
// background plugin? connect it needed
if (
(forceInitBackgroundPlugin ||
!defaultEnabledBackgroundPlugins.includes(plugin.id)) &&
client?.isBackgroundPlugin(plugin.id)
) {
client.initPlugin(plugin.id);
}
}
function stopPlugin(
client: Client,
pluginId: string,
forceInitBackgroundPlugin: boolean = false,
): boolean {
if (
(forceInitBackgroundPlugin ||
!defaultEnabledBackgroundPlugins.includes(pluginId)) &&
client?.isBackgroundPlugin(pluginId)
) {
client.deinitPlugin(pluginId);
}
// stop sandy plugins
client.stopPluginIfNeeded(pluginId);
return true;
}
function unloadPluginModule(plugin: ActivatablePluginDetails) {
if (plugin.isBundled) {
// We cannot unload bundled plugin.
return;
}
unloadModule(plugin.entry);
}

View File

@@ -0,0 +1,360 @@
/**
* 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 type {Store} from '../reducers/index';
import type {Logger} from 'flipper-common';
import {PluginDefinition} from '../plugin';
import React from 'react';
import ReactDOM from 'react-dom';
import adbkit from 'adbkit';
import {
registerPlugins,
addGatekeepedPlugins,
addDisabledPlugins,
addFailedPlugins,
registerLoadedPlugins,
registerBundledPlugins,
registerMarketplacePlugins,
MarketplacePluginDetails,
pluginsInitialized,
} from '../reducers/plugins';
import GK from '../fb-stubs/GK';
import {FlipperBasePlugin} from '../plugin';
import fs from 'fs-extra';
import path from 'path';
import {default as config} from '../utils/processConfig';
import {notNull} from '../utils/typeUtils';
import {
ActivatablePluginDetails,
BundledPluginDetails,
ConcretePluginDetails,
} from 'flipper-plugin-lib';
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';
import * as antdesign_icons from '@ant-design/icons';
// @ts-ignore
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;
export default async (store: Store, _logger: Logger) => {
// expose Flipper and exact globally for dynamically loaded plugins
const globalObject: any = typeof window === 'undefined' ? global : window;
// this list should match `replace-flipper-requires.tsx` and the `builtInModules` in `desktop/.eslintrc`
globalObject.React = React;
globalObject.ReactDOM = ReactDOM;
globalObject.Flipper = require('../deprecated-exports');
globalObject.adbkit = adbkit;
globalObject.FlipperPlugin = FlipperPluginSDK;
globalObject.Immer = Immer;
globalObject.antd = antd;
globalObject.emotion_styled = emotion_styled;
globalObject.antdesign_icons = antdesign_icons;
globalObject.crc32_hack_fix_me = crc32;
const gatekeepedPlugins: Array<ActivatablePluginDetails> = [];
const disabledPlugins: Array<ActivatablePluginDetails> = [];
const failedPlugins: Array<[ActivatablePluginDetails, string]> = [];
defaultPluginsIndex = getRenderHostInstance().loadDefaultPlugins();
const marketplacePlugins = selectCompatibleMarketplaceVersions(
store.getState().plugins.marketplacePlugins,
);
store.dispatch(registerMarketplacePlugins(marketplacePlugins));
const uninstalledPluginNames =
store.getState().plugins.uninstalledPluginNames;
const bundledPlugins = await getBundledPlugins();
const allLocalVersions = [
...bundledPlugins,
...(await getDynamicPlugins()),
].filter((p) => !uninstalledPluginNames.has(p.name));
const loadedPlugins =
getLatestCompatibleVersionOfEachPlugin(allLocalVersions);
const initialPlugins: PluginDefinition[] = loadedPlugins
.map(reportVersion)
.filter(checkDisabled(disabledPlugins))
.filter(checkGK(gatekeepedPlugins))
.map(createRequirePluginFunction(failedPlugins))
.filter(notNull);
const classicPlugins = initialPlugins.filter(
(p) => !isSandyPlugin(p.details),
);
if (process.env.NODE_ENV !== 'test' && classicPlugins.length) {
console.warn(
`${
classicPlugins.length
} plugin(s) were loaded in legacy mode. Please visit https://fbflipper.com/docs/extending/sandy-migration to learn how to migrate these plugins to the new Sandy architecture: \n${classicPlugins
.map((p) => `${p.title} (id: ${p.id})`)
.sort()
.join('\n')}`,
);
}
store.dispatch(registerBundledPlugins(bundledPlugins));
store.dispatch(registerLoadedPlugins(loadedPlugins));
store.dispatch(addGatekeepedPlugins(gatekeepedPlugins));
store.dispatch(addDisabledPlugins(disabledPlugins));
store.dispatch(addFailedPlugins(failedPlugins));
store.dispatch(registerPlugins(initialPlugins));
store.dispatch(pluginsInitialized());
};
function reportVersion(pluginDetails: ActivatablePluginDetails) {
reportUsage(
'plugin:version',
{
version: pluginDetails.version,
},
pluginDetails.id,
);
return pluginDetails;
}
export function getLatestCompatibleVersionOfEachPlugin<
T extends ConcretePluginDetails,
>(plugins: T[]): T[] {
const latestCompatibleVersions: Map<string, T> = new Map();
for (const plugin of plugins) {
if (isPluginCompatible(plugin)) {
const loadedVersion = latestCompatibleVersions.get(plugin.id);
if (!loadedVersion || isPluginVersionMoreRecent(plugin, loadedVersion)) {
latestCompatibleVersions.set(plugin.id, plugin);
}
}
}
return Array.from(latestCompatibleVersions.values());
}
async function getBundledPlugins(): Promise<Array<BundledPluginDetails>> {
// 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);
} catch (e) {
console.error('Failed to load list of bundled plugins', e);
}
return bundledPlugins;
}
export async function getDynamicPlugins() {
try {
return await loadDynamicPlugins();
} catch (e) {
console.error('Failed to load dynamic plugins', e);
return [];
}
}
export const checkGK =
(gatekeepedPlugins: Array<ActivatablePluginDetails>) =>
(plugin: ActivatablePluginDetails): boolean => {
try {
if (!plugin.gatekeeper) {
return true;
}
const result = GK.get(plugin.gatekeeper);
if (!result) {
gatekeepedPlugins.push(plugin);
}
return result;
} catch (err) {
console.error(`Failed to check GK for plugin ${plugin.id}`, err);
return false;
}
};
export const checkDisabled = (
disabledPlugins: Array<ActivatablePluginDetails>,
) => {
let enabledList: Set<string> | null = null;
let disabledList: Set<string> = new Set();
try {
if (process.env.FLIPPER_ENABLED_PLUGINS) {
enabledList = new Set<string>(
process.env.FLIPPER_ENABLED_PLUGINS.split(','),
);
}
disabledList = config().disabledPlugins;
} catch (e) {
console.error('Failed to compute enabled/disabled plugins', e);
}
return (plugin: ActivatablePluginDetails): boolean => {
try {
if (disabledList.has(plugin.name)) {
disabledPlugins.push(plugin);
return false;
}
if (
enabledList &&
!(
enabledList.has(plugin.name) ||
enabledList.has(plugin.id) ||
enabledList.has(plugin.name.replace('flipper-plugin-', ''))
)
) {
disabledPlugins.push(plugin);
return false;
}
return true;
} catch (e) {
console.error(
`Failed to check whether plugin ${plugin.id} is disabled`,
e,
);
return false;
}
};
};
export const createRequirePluginFunction = (
failedPlugins: Array<[ActivatablePluginDetails, string]>,
reqFn: Function = global.electronRequire,
) => {
return (pluginDetails: ActivatablePluginDetails): PluginDefinition | null => {
try {
const pluginDefinition = requirePlugin(pluginDetails, reqFn);
if (
pluginDefinition &&
isDevicePluginDefinition(pluginDefinition) &&
pluginDefinition.details.pluginType !== 'device'
) {
console.warn(
`Package ${pluginDefinition.details.name} contains the device plugin "${pluginDefinition.title}" defined in a wrong format. Specify "pluginType" and "supportedDevices" properties and remove exported function "supportsDevice". See details at https://fbflipper.com/docs/extending/desktop-plugin-structure#creating-a-device-plugin.`,
);
}
return pluginDefinition;
} catch (e) {
failedPlugins.push([pluginDetails, e.message]);
console.error(`Plugin ${pluginDetails.id} failed to load`, e);
return null;
}
};
};
export const requirePlugin = (
pluginDetails: ActivatablePluginDetails,
reqFn: Function = global.electronRequire,
): PluginDefinition => {
reportUsage(
'plugin:load',
{
version: pluginDetails.version,
},
pluginDetails.id,
);
return tryCatchReportPluginFailures(
() => requirePluginInternal(pluginDetails, reqFn),
'plugin:load',
pluginDetails.id,
);
};
const isSandyPlugin = (pluginDetails: ActivatablePluginDetails) => {
return !!pluginDetails.flipperSDKVersion;
};
const requirePluginInternal = (
pluginDetails: ActivatablePluginDetails,
reqFn: Function = global.electronRequire,
): PluginDefinition => {
let plugin = pluginDetails.isBundled
? defaultPluginsIndex[pluginDetails.name]
: reqFn(pluginDetails.entry);
if (isSandyPlugin(pluginDetails)) {
// Sandy plugin
return new _SandyPluginDefinition(pluginDetails, plugin);
} else {
// classic plugin
if (plugin.default) {
plugin = plugin.default;
}
if (plugin.prototype === undefined) {
throw new Error(
`Plugin ${pluginDetails.name} is neither a class-based plugin nor a Sandy-based one.
Ensure that it exports either a FlipperPlugin class or has flipper-plugin declared as a peer-dependency and exports a plugin and Component.
See https://fbflipper.com/docs/extending/sandy-migration/ for more information.`,
);
} else if (!(plugin.prototype instanceof FlipperBasePlugin)) {
throw new Error(
`Plugin ${pluginDetails.name} is not a FlipperBasePlugin`,
);
}
if (plugin.id && pluginDetails.id !== plugin.id) {
console.error(
`Plugin name mismatch: Package '${pluginDetails.id}' exposed a plugin with id '${plugin.id}'. Please update the 'package.json' to match the exposed plugin id`,
);
}
plugin.id = plugin.id || pluginDetails.id;
plugin.packageName = pluginDetails.name;
plugin.details = pluginDetails;
return createSandyPluginFromClassicPlugin(pluginDetails, plugin);
}
};
export function createSandyPluginFromClassicPlugin(
pluginDetails: ActivatablePluginDetails,
plugin: any,
) {
pluginDetails.id = plugin.id; // for backward compatibility, see above check!
return new _SandyPluginDefinition(
pluginDetails,
createSandyPluginWrapper(plugin),
);
}
export function selectCompatibleMarketplaceVersions(
availablePlugins: MarketplacePluginDetails[],
): MarketplacePluginDetails[] {
const plugins: MarketplacePluginDetails[] = [];
for (const plugin of availablePlugins) {
if (!isPluginCompatible(plugin)) {
const compatibleVersion =
plugin.availableVersions?.find(isPluginCompatible) ??
plugin.availableVersions?.slice(-1).pop();
if (compatibleVersion) {
plugins.push({
...compatibleVersion,
availableVersions: plugin?.availableVersions,
});
} else {
plugins.push(plugin);
}
} else {
plugins.push(plugin);
}
}
return plugins;
}

View File

@@ -0,0 +1,41 @@
/**
* 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 Client from '../Client';
import {Logger} from 'flipper-common';
import {Store} from '../reducers';
import {appPluginListChanged} from '../reducers/connections';
import {getActiveClient} from '../selectors/connections';
import {sideEffect} from '../utils/sideEffect';
export default (store: Store, _logger: Logger) => {
let prevClient: null | Client = null;
const onActiveAppPluginListChanged = () => {
store.dispatch(appPluginListChanged());
};
sideEffect(
store,
{name: 'pluginsChangeListener', throttleMs: 10, fireImmediately: true},
getActiveClient,
(activeClient, _store) => {
if (activeClient !== prevClient) {
if (prevClient) {
prevClient.off('plugins-change', onActiveAppPluginListChanged);
}
prevClient = activeClient;
if (prevClient) {
prevClient.on('plugins-change', onActiveAppPluginListChanged);
store.dispatch(appPluginListChanged()); // force refresh
}
}
},
);
};

View File

@@ -0,0 +1,59 @@
/**
* 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 {Store} from '../reducers';
import {getRenderHostInstance} from '../RenderHost';
type ShortcutEventCommand =
| {
shortcut: string;
command: string;
}
| '';
export default (store: Store) => {
const settings = store.getState().settingsState.reactNative;
const renderHost = getRenderHostInstance();
if (!settings.shortcuts.enabled) {
return;
}
const shortcuts: ShortcutEventCommand[] = [
settings.shortcuts.reload && {
shortcut: settings.shortcuts.reload,
command: 'reload',
},
settings.shortcuts.openDevMenu && {
shortcut: settings.shortcuts.openDevMenu,
command: 'devMenu',
},
];
shortcuts.forEach(
(shortcut: ShortcutEventCommand) =>
shortcut &&
shortcut.shortcut &&
renderHost.registerShortcut(shortcut.shortcut, () => {
const devices = store
.getState()
.connections.devices.filter(
(device) => device.os === 'Metro' && !device.isArchived,
);
devices.forEach((device) =>
device.flipperServer.exec(
'metro-command',
device.serial,
shortcut.command,
),
);
}),
);
};

View File

@@ -0,0 +1,380 @@
/**
* 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 {performance} from 'perf_hooks';
import {EventEmitter} from 'events';
import {State, Store} from '../reducers/index';
import {Logger} from 'flipper-common';
import {
getPluginBackgroundStats,
resetPluginBackgroundStatsDelta,
} from '../utils/pluginStats';
import {
clearTimeline,
TrackingEvent,
State as UsageTrackingState,
selectionChanged,
} from '../reducers/usageTracking';
import produce from 'immer';
import BaseDevice from '../devices/BaseDevice';
import {deconstructClientId} from 'flipper-common';
import {getCPUUsage} from 'process';
import {sideEffect} from '../utils/sideEffect';
import {getSelectionInfo} from '../utils/info';
import type {SelectionInfo} from '../utils/info';
import {getRenderHostInstance} from '../RenderHost';
const TIME_SPENT_EVENT = 'time-spent';
type UsageInterval = {
selectionKey: string | null;
selection: SelectionInfo | null;
length: number;
focused: boolean;
};
export type UsageSummary = {
total: {focusedTime: number; unfocusedTime: number};
plugin: {
[pluginKey: string]: {
focusedTime: number;
unfocusedTime: number;
} & SelectionInfo;
};
};
export const fpsEmitter = new EventEmitter();
// var is fine, let doesn't have the correct hoisting semantics
// eslint-disable-next-line no-var
var bytesReceivedEmitter: EventEmitter;
export function onBytesReceived(
callback: (plugin: string, bytes: number) => void,
): () => void {
if (!bytesReceivedEmitter) {
bytesReceivedEmitter = new EventEmitter();
}
bytesReceivedEmitter.on('bytesReceived', callback);
return () => {
bytesReceivedEmitter.off('bytesReceived', callback);
};
}
export function emitBytesReceived(plugin: string, bytes: number) {
if (bytesReceivedEmitter) {
bytesReceivedEmitter.emit('bytesReceived', plugin, bytes);
}
}
export default (store: Store, logger: Logger) => {
const renderHost = getRenderHostInstance();
sideEffect(
store,
{
name: 'pluginUsageTracking',
throttleMs: 0,
noTimeBudgetWarns: true,
runSynchronously: true,
},
getSelectionInfo,
(selection, store) => {
const time = Date.now();
store.dispatch(selectionChanged({selection, time}));
},
);
let droppedFrames: number = 0;
let largeFrameDrops: number = 0;
const oldExitData = loadExitData();
if (oldExitData) {
const isReload = renderHost.processId === oldExitData.pid;
const timeSinceLastStartup =
Date.now() - parseInt(oldExitData.lastSeen, 10);
// console.log(isReload ? 'reload' : 'restart', oldExitData);
logger.track('usage', isReload ? 'reload' : 'restart', {
...oldExitData,
pid: undefined,
timeSinceLastStartup,
});
// create fresh exit data
const {selectedDevice, selectedAppId, selectedPlugin} =
store.getState().connections;
persistExitData(
{
selectedDevice,
selectedAppId,
selectedPlugin,
},
false,
);
}
function droppedFrameDetection(
past: DOMHighResTimeStamp,
isWindowFocused: () => boolean,
) {
const now = performance.now();
requestAnimationFrame(() => droppedFrameDetection(now, isWindowFocused));
const delta = now - past;
const dropped = Math.round(delta / (1000 / 60) - 1);
fpsEmitter.emit('fps', delta > 1000 ? 0 : Math.round(1000 / (now - past)));
if (!isWindowFocused() || dropped < 1) {
return;
}
droppedFrames += dropped;
if (dropped > 3) {
largeFrameDrops++;
}
}
if (typeof window !== 'undefined') {
droppedFrameDetection(
performance.now(),
() => store.getState().application.windowIsFocused,
);
}
renderHost.onIpcEvent('trackUsage', (...args: any[]) => {
let state: State;
try {
state = store.getState();
} catch (e) {
// if trackUsage is called (indirectly) through a reducer, this will utterly die Flipper. Let's prevent that and log an error instead
console.error(
'trackUsage triggered indirectly as side effect of a reducer',
e,
);
return;
}
const {selectedDevice, selectedPlugin, selectedAppId, clients} =
state.connections;
persistExitData(
{selectedDevice, selectedPlugin, selectedAppId},
args[0] === 'exit',
);
const currentTime = Date.now();
const usageSummary = computeUsageSummary(state.usageTracking, currentTime);
store.dispatch(clearTimeline(currentTime));
logger.track('usage', TIME_SPENT_EVENT, usageSummary.total);
for (const key of Object.keys(usageSummary.plugin)) {
logger.track(
'usage',
TIME_SPENT_EVENT,
usageSummary.plugin[key],
usageSummary.plugin[key]?.plugin ?? 'none',
);
}
Object.entries(state.connections.enabledPlugins).forEach(
([app, plugins]) => {
// TODO: remove "starred-plugns" event in favor of "enabled-plugins" after some transition period
logger.track('usage', 'starred-plugins', {
app,
starredPlugins: plugins,
});
logger.track('usage', 'enabled-plugins', {
app,
enabledPugins: plugins,
});
},
);
const bgStats = getPluginBackgroundStats();
logger.track('usage', 'plugin-stats', {
cpuTime: bgStats.cpuTime,
bytesReceived: bgStats.bytesReceived,
});
for (const key of Object.keys(bgStats.byPlugin)) {
const {
cpuTimeTotal: _a,
messageCountTotal: _b,
bytesReceivedTotal: _c,
...dataWithoutTotal
} = bgStats.byPlugin[key];
if (Object.values(dataWithoutTotal).some((v) => v > 0)) {
logger.track('usage', 'plugin-stats-plugin', dataWithoutTotal, key);
}
}
resetPluginBackgroundStatsDelta();
if (
!state.application.windowIsFocused ||
!selectedDevice ||
!selectedPlugin
) {
return;
}
let app: string | null = null;
let sdkVersion: number | null = null;
if (selectedAppId) {
const client = clients.get(selectedAppId);
if (client) {
app = client.query.app;
sdkVersion = client.query.sdk_version || 0;
}
}
const info = {
droppedFrames,
largeFrameDrops,
os: selectedDevice.os,
device: selectedDevice.title,
plugin: selectedPlugin,
app,
sdkVersion,
isForeground: state.application.windowIsFocused,
usedJSHeapSize: (window.performance as any).memory.usedJSHeapSize,
cpuLoad: getCPUUsage().percentCPUUsage,
};
// reset dropped frames counter
droppedFrames = 0;
largeFrameDrops = 0;
logger.track('usage', 'ping', info);
});
};
export function computeUsageSummary(
state: UsageTrackingState,
currentTime: number,
) {
const intervals: UsageInterval[] = [];
let intervalStart = 0;
let isFocused = false;
let selection: SelectionInfo | null = null;
let selectionKey: string | null;
function startInterval(event: TrackingEvent) {
intervalStart = event.time;
if (
event.type === 'TIMELINE_START' ||
event.type === 'WINDOW_FOCUS_CHANGE'
) {
isFocused = event.isFocused;
}
if (event.type === 'SELECTION_CHANGED') {
selectionKey = event.selectionKey;
selection = event.selection;
}
}
function endInterval(time: number) {
const length = time - intervalStart;
intervals.push({
length,
focused: isFocused,
selectionKey,
selection,
});
}
for (const event of state.timeline) {
if (
event.type === 'TIMELINE_START' ||
event.type === 'WINDOW_FOCUS_CHANGE' ||
event.type === 'SELECTION_CHANGED'
) {
if (event.type !== 'TIMELINE_START') {
endInterval(event.time);
}
startInterval(event);
}
}
endInterval(currentTime);
return intervals.reduce<UsageSummary>(
(acc: UsageSummary, x: UsageInterval) =>
produce(acc, (draft) => {
draft.total.focusedTime += x.focused ? x.length : 0;
draft.total.unfocusedTime += x.focused ? 0 : x.length;
const selectionKey = x.selectionKey ?? 'none';
draft.plugin[selectionKey] = draft.plugin[selectionKey] ?? {
focusedTime: 0,
unfocusedTime: 0,
...x.selection,
};
draft.plugin[selectionKey].focusedTime += x.focused ? x.length : 0;
draft.plugin[selectionKey].unfocusedTime += x.focused ? 0 : x.length;
}),
{
total: {focusedTime: 0, unfocusedTime: 0},
plugin: {},
},
);
}
const flipperExitDataKey = 'FlipperExitData';
interface ExitData {
lastSeen: string;
deviceOs: string;
deviceType: string;
deviceTitle: string;
plugin: string;
app: string;
cleanExit: boolean;
pid: number;
}
function loadExitData(): ExitData | undefined {
if (!window.localStorage) {
return undefined;
}
const data = window.localStorage.getItem(flipperExitDataKey);
if (data) {
try {
const res = JSON.parse(data);
if (res.cleanExit === undefined) {
res.cleanExit = true; // avoid skewing results for historical data where this info isn't present
}
return res;
} catch (e) {
console.warn('Failed to parse flipperExitData', e);
}
}
return undefined;
}
export function persistExitData(
state: {
selectedDevice: BaseDevice | null;
selectedPlugin: string | null;
selectedAppId: string | null;
},
cleanExit: boolean,
) {
if (!window.localStorage) {
return;
}
const exitData: ExitData = {
lastSeen: '' + Date.now(),
deviceOs: state.selectedDevice ? state.selectedDevice.os : '',
deviceType: state.selectedDevice ? state.selectedDevice.deviceType : '',
deviceTitle: state.selectedDevice ? state.selectedDevice.title : '',
plugin: state.selectedPlugin || '',
app: state.selectedAppId
? deconstructClientId(state.selectedAppId).app
: '',
cleanExit,
pid: getRenderHostInstance().processId,
};
window.localStorage.setItem(
flipperExitDataKey,
JSON.stringify(exitData, null, 2),
);
}

View File

@@ -0,0 +1,16 @@
/**
* 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 {Store} from '../reducers/index';
import {Logger} from 'flipper-common';
export type Dispatcher = (
store: Store,
logger: Logger,
) => (() => Promise<void>) | null | void;