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:
committed by
Facebook GitHub Bot
parent
54b7ce9308
commit
7e50c0466a
@@ -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();
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
75
desktop/flipper-ui-core/src/dispatcher/application.tsx
Normal file
75
desktop/flipper-ui-core/src/dispatcher/application.tsx
Normal 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`);
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
12
desktop/flipper-ui-core/src/dispatcher/fb-stubs/user.tsx
Normal file
12
desktop/flipper-ui-core/src/dispatcher/fb-stubs/user.tsx
Normal 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
|
||||
};
|
||||
308
desktop/flipper-ui-core/src/dispatcher/flipperServer.tsx
Normal file
308
desktop/flipper-ui-core/src/dispatcher/flipperServer.tsx
Normal 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',
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
52
desktop/flipper-ui-core/src/dispatcher/index.tsx
Normal file
52
desktop/flipper-ui-core/src/dispatcher/index.tsx
Normal 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(() => {});
|
||||
};
|
||||
}
|
||||
162
desktop/flipper-ui-core/src/dispatcher/notifications.tsx
Normal file
162
desktop/flipper-ui-core/src/dispatcher/notifications.tsx
Normal 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>',
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
175
desktop/flipper-ui-core/src/dispatcher/pluginDownloads.tsx
Normal file
175
desktop/flipper-ui-core/src/dispatcher/pluginDownloads.tsx
Normal 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),
|
||||
)
|
||||
);
|
||||
}
|
||||
343
desktop/flipper-ui-core/src/dispatcher/pluginManager.tsx
Normal file
343
desktop/flipper-ui-core/src/dispatcher/pluginManager.tsx
Normal 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);
|
||||
}
|
||||
360
desktop/flipper-ui-core/src/dispatcher/plugins.tsx
Normal file
360
desktop/flipper-ui-core/src/dispatcher/plugins.tsx
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
59
desktop/flipper-ui-core/src/dispatcher/reactNative.tsx
Normal file
59
desktop/flipper-ui-core/src/dispatcher/reactNative.tsx
Normal 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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
380
desktop/flipper-ui-core/src/dispatcher/tracking.tsx
Normal file
380
desktop/flipper-ui-core/src/dispatcher/tracking.tsx
Normal 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),
|
||||
);
|
||||
}
|
||||
16
desktop/flipper-ui-core/src/dispatcher/types.tsx
Normal file
16
desktop/flipper-ui-core/src/dispatcher/types.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user