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

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

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

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

Reviewed By: aigoncharov

Differential Revision: D32427722

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

View File

@@ -0,0 +1,335 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`acknowledgeProblems 1`] = `
Object {
"acknowledgedProblems": Array [
"ios.sdk",
"common.openssl",
],
"healthcheckReport": Object {
"categories": Object {
"android": Object {
"checks": Object {
"android.sdk": Object {
"key": "android.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": true,
"status": "SUCCESS",
},
},
},
"key": "android",
"label": "Android",
"result": Object {
"isAcknowledged": true,
"status": "SUCCESS",
},
},
"common": Object {
"checks": Object {
"common.openssl": Object {
"key": "common.openssl",
"label": "OpenSSL Istalled",
"result": Object {
"isAcknowledged": true,
"status": "FAILED",
},
},
},
"key": "common",
"label": "Common",
"result": Object {
"isAcknowledged": true,
"status": "FAILED",
},
},
"ios": Object {
"checks": Object {
"ios.sdk": Object {
"key": "ios.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": true,
"status": "FAILED",
},
},
},
"key": "ios",
"label": "iOS",
"result": Object {
"isAcknowledged": true,
"status": "FAILED",
},
},
},
"result": Object {
"isAcknowledged": true,
"status": "FAILED",
},
},
}
`;
exports[`finish 1`] = `
Object {
"acknowledgedProblems": Array [],
"healthcheckReport": Object {
"categories": Object {
"android": Object {
"checks": Object {
"android.sdk": Object {
"key": "android.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "android",
"label": "Android",
"result": Object {
"isAcknowledged": false,
"status": "SUCCESS",
},
},
"common": Object {
"checks": Object {
"common.openssl": Object {
"key": "common.openssl",
"label": "OpenSSL Istalled",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "common",
"label": "Common",
"result": Object {
"isAcknowledged": false,
"status": "SUCCESS",
},
},
"ios": Object {
"checks": Object {
"ios.sdk": Object {
"key": "ios.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "ios",
"label": "iOS",
"result": Object {
"isAcknowledged": false,
"status": "SUCCESS",
},
},
},
"result": Object {
"status": "SUCCESS",
},
},
}
`;
exports[`startHealthCheck 1`] = `
Object {
"acknowledgedProblems": Array [],
"healthcheckReport": Object {
"categories": Object {
"android": Object {
"checks": Object {
"android.sdk": Object {
"key": "android.sdk",
"label": "SDK Installed",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"key": "android",
"label": "Android",
"result": Object {
"status": "IN_PROGRESS",
},
},
"common": Object {
"checks": Object {
"common.openssl": Object {
"key": "common.openssl",
"label": "OpenSSL Istalled",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"key": "common",
"label": "Common",
"result": Object {
"status": "IN_PROGRESS",
},
},
"ios": Object {
"checks": Object {
"ios.sdk": Object {
"key": "ios.sdk",
"label": "SDK Installed",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"key": "ios",
"label": "iOS",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"result": Object {
"status": "IN_PROGRESS",
},
},
}
`;
exports[`statuses updated after healthchecks finished 1`] = `
Object {
"acknowledgedProblems": Array [],
"healthcheckReport": Object {
"categories": Object {
"android": Object {
"checks": Object {
"android.sdk": Object {
"key": "android.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "FAILED",
},
},
},
"key": "android",
"label": "Android",
"result": Object {
"isAcknowledged": false,
"status": "FAILED",
},
},
"common": Object {
"checks": Object {
"common.openssl": Object {
"key": "common.openssl",
"label": "OpenSSL Istalled",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "common",
"label": "Common",
"result": Object {
"status": "SUCCESS",
},
},
"ios": Object {
"checks": Object {
"ios.sdk": Object {
"key": "ios.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "ios",
"label": "iOS",
"result": Object {
"status": "SUCCESS",
},
},
},
"result": Object {
"isAcknowledged": false,
"status": "FAILED",
},
},
}
`;
exports[`updateHealthcheckResult 1`] = `
Object {
"acknowledgedProblems": Array [],
"healthcheckReport": Object {
"categories": Object {
"android": Object {
"checks": Object {
"android.sdk": Object {
"key": "android.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "android",
"label": "Android",
"result": Object {
"status": "IN_PROGRESS",
},
},
"common": Object {
"checks": Object {
"common.openssl": Object {
"key": "common.openssl",
"label": "OpenSSL Istalled",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"key": "common",
"label": "Common",
"result": Object {
"status": "IN_PROGRESS",
},
},
"ios": Object {
"checks": Object {
"ios.sdk": Object {
"key": "ios.sdk",
"label": "SDK Installed",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"key": "ios",
"label": "iOS",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"result": Object {
"status": "IN_PROGRESS",
},
},
}
`;

View File

@@ -0,0 +1,57 @@
/**
* 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 reducer from '../application';
import {
initialState,
addStatusMessage,
removeStatusMessage,
} from '../application';
test('ADD_STATUS_MSG, to check if the status messages get pushed to the state', () => {
const state = reducer(
initialState(),
addStatusMessage({msg: 'Status Msg', sender: 'Test'}),
);
expect(state.statusMessages).toEqual(['Test: Status Msg']);
const updatedstate = reducer(
state,
addStatusMessage({msg: 'Status Msg 2', sender: 'Test'}),
);
expect(updatedstate.statusMessages).toEqual([
'Test: Status Msg',
'Test: Status Msg 2',
]);
});
test('REMOVE_STATUS_MSG, to check if the status messages gets removed from the state', () => {
const initState = initialState();
const state = reducer(
initState,
removeStatusMessage({msg: 'Status Msg', sender: 'Test'}),
);
expect(state).toEqual(initState);
const stateWithMessages = reducer(
reducer(
initialState(),
addStatusMessage({msg: 'Status Msg', sender: 'Test'}),
),
addStatusMessage({msg: 'Status Msg 2', sender: 'Test'}),
);
const updatedState = reducer(
stateWithMessages,
removeStatusMessage({msg: 'Status Msg', sender: 'Test'}),
);
expect(updatedState.statusMessages).toEqual(['Test: Status Msg 2']);
const updatedStateWithNoMessages = reducer(
updatedState,
removeStatusMessage({msg: 'Status Msg 2', sender: 'Test'}),
);
expect(updatedStateWithNoMessages.statusMessages).toEqual([]);
});

View File

@@ -0,0 +1,413 @@
/**
* 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 reducer, {selectClient, selectDevice} from '../connections';
import {State, selectPlugin} from '../connections';
import {
_SandyPluginDefinition,
_setFlipperLibImplementation,
TestUtils,
MockedConsole,
} from 'flipper-plugin';
import {TestDevice} from '../../test-utils/TestDevice';
import {
createMockFlipperWithPlugin,
MockFlipperResult,
} from '../../test-utils/createMockFlipperWithPlugin';
import {Store} from '..';
import {getActiveClient, getActiveDevice} from '../../selectors/connections';
import BaseDevice from '../../devices/BaseDevice';
import Client from '../../Client';
let mockedConsole: MockedConsole;
beforeEach(() => {
mockedConsole = TestUtils.mockConsole();
_setFlipperLibImplementation(TestUtils.createMockFlipperLib());
});
afterEach(() => {
mockedConsole.unmock();
_setFlipperLibImplementation(undefined);
});
test('doing a double REGISTER_DEVICE fails', () => {
const device1 = new TestDevice('serial', 'physical', 'title', 'Android');
const device2 = new TestDevice('serial', 'physical', 'title2', 'Android');
const initialState: State = reducer(undefined, {
type: 'REGISTER_DEVICE',
payload: device1,
});
expect(initialState.devices.length).toBe(1);
expect(initialState.devices[0]).toBe(device1);
expect(() => {
reducer(initialState, {
type: 'REGISTER_DEVICE',
payload: device2,
});
}).toThrow('still connected');
});
test('register, remove, re-register a metro device works correctly', () => {
const device1 = new TestDevice(
'http://localhost:8081',
'emulator',
'React Native',
'Metro',
);
let state: State = reducer(undefined, {
type: 'REGISTER_DEVICE',
payload: device1,
});
expect(state.devices.length).toBe(1);
expect(state.devices[0].displayTitle()).toBe('React Native');
device1.disconnect();
expect(state.devices.length).toBe(1);
expect(state.devices[0].displayTitle()).toBe('React Native (Offline)');
state = reducer(state, {
type: 'REGISTER_DEVICE',
payload: new TestDevice(
'http://localhost:8081',
'emulator',
'React Native',
'Metro',
),
});
expect(state.devices.length).toBe(1);
expect(state.devices[0].displayTitle()).toBe('React Native');
expect(state.devices[0]).not.toBe(device1);
});
test('selectPlugin sets deepLinkPayload correctly', () => {
const device1 = new TestDevice(
'http://localhost:8081',
'emulator',
'React Native',
'Metro',
);
let state = reducer(undefined, {
type: 'REGISTER_DEVICE',
payload: device1,
});
state = reducer(
undefined,
selectPlugin({
selectedPlugin: 'myPlugin',
deepLinkPayload: 'myPayload',
selectedDevice: device1,
}),
);
expect(state.deepLinkPayload).toBe('myPayload');
});
test('can handle plugins that throw at start', async () => {
const TestPlugin = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
Component() {
return null;
},
plugin() {
throw new Error('Broken plugin');
},
},
);
const {client, store, createClient, createDevice} =
await createMockFlipperWithPlugin(TestPlugin);
// not initialized
expect(client.sandyPluginStates.get(TestPlugin.id)).toBe(undefined);
expect(store.getState().connections.clients.size).toBe(1);
expect(client.connected.get()).toBe(true);
expect((console.error as any).mock.calls[0]).toMatchInlineSnapshot(`
Array [
"Failed to start plugin 'TestPlugin': ",
[Error: Broken plugin],
]
`);
const device2 = await createDevice({});
const client2 = await createClient(device2, client.query.app);
expect((console.error as any).mock.calls[1]).toMatchInlineSnapshot(`
Array [
"Failed to start plugin 'TestPlugin': ",
[Error: Broken plugin],
]
`);
expect(store.getState().connections.clients.size).toBe(2);
expect(client2.connected.get()).toBe(true);
expect(client2.sandyPluginStates.size).toBe(0);
});
test('can handle device plugins that throw at start', async () => {
const TestPlugin = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
Component() {
return null;
},
devicePlugin() {
throw new Error('Broken device plugin');
},
},
);
const {device, store, createDevice} = await createMockFlipperWithPlugin(
TestPlugin,
);
expect(mockedConsole.errorCalls[0]).toMatchInlineSnapshot(`
Array [
"Failed to start device plugin 'TestPlugin': ",
[Error: Broken device plugin],
]
`);
// not initialized
expect(device.sandyPluginStates.get(TestPlugin.id)).toBe(undefined);
expect(store.getState().connections.devices.length).toBe(1);
expect(device.connected.get()).toBe(true);
const device2 = await createDevice({});
expect(store.getState().connections.devices.length).toBe(2);
expect(device2.connected.get()).toBe(true);
expect(mockedConsole.errorCalls[1]).toMatchInlineSnapshot(`
Array [
"Failed to start device plugin 'TestPlugin': ",
[Error: Broken device plugin],
]
`);
expect(device2.sandyPluginStates.size).toBe(0);
});
describe('selection changes', () => {
const TestPlugin1 = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
Component() {
return null;
},
plugin() {
return {};
},
},
);
const TestPlugin2 = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
Component() {
return null;
},
plugin() {
return {};
},
},
);
const DevicePlugin1 = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails({pluginType: 'device'}),
{
Component() {
return null;
},
devicePlugin() {
return {};
},
},
);
let device1: BaseDevice;
let device2: BaseDevice;
let metroDevice: BaseDevice;
let d1app1: Client;
let d1app2: Client;
let d2app1: Client;
let d2app2: Client;
let store: Store;
let mockFlipper: MockFlipperResult;
beforeEach(async () => {
mockFlipper = await createMockFlipperWithPlugin(TestPlugin1, {
additionalPlugins: [TestPlugin2, DevicePlugin1],
supportedPlugins: [TestPlugin1.id, TestPlugin2.id, DevicePlugin1.id],
});
device1 = mockFlipper.device;
device2 = mockFlipper.createDevice({});
metroDevice = mockFlipper.createDevice({
os: 'Metro',
serial: 'http://localhost:8081',
});
d1app1 = mockFlipper.client;
d1app2 = await mockFlipper.createClient(device1, 'd1app2');
d2app1 = await mockFlipper.createClient(device2, 'd2app1');
d2app2 = await mockFlipper.createClient(device2, 'd2app2');
store = mockFlipper.store;
});
test('basic/ device selection change', async () => {
// after registering d1app2, this will have become the selection
expect(store.getState().connections).toMatchObject({
selectedDevice: device1,
selectedPlugin: TestPlugin1.id,
selectedAppId: d1app2.id,
// no preferences changes, no explicit selection was made
userPreferredDevice: device1.title,
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d1app1.query.app,
});
expect(getActiveClient(store.getState())).toBe(d1app2);
expect(getActiveDevice(store.getState())).toBe(device1);
// select plugin 2 on d2app2
store.dispatch(
selectPlugin({
selectedPlugin: TestPlugin2.id,
selectedAppId: d2app2.id,
}),
);
expect(store.getState().connections).toMatchObject({
selectedDevice: device2,
selectedPlugin: TestPlugin2.id,
selectedAppId: d2app2.id,
userPreferredDevice: device2.title,
userPreferredPlugin: TestPlugin2.id,
userPreferredApp: d2app2.query.app,
});
// disconnect device1, and then register a new device should select it
device1.disconnect();
const device3 = await mockFlipper.createDevice({});
expect(store.getState().connections).toMatchObject({
selectedDevice: device3,
selectedPlugin: TestPlugin2.id,
selectedAppId: null,
// prefs not updated
userPreferredDevice: device2.title,
userPreferredPlugin: TestPlugin2.id,
userPreferredApp: d2app2.query.app,
});
store.dispatch(selectDevice(device1));
expect(store.getState().connections).toMatchObject({
selectedDevice: device1,
selectedPlugin: TestPlugin2.id,
selectedAppId: null,
userPreferredDevice: device1.title,
// other prefs not updated
userPreferredPlugin: TestPlugin2.id,
userPreferredApp: d2app2.query.app,
});
// used by plugin list, to keep main device / app selection correct
expect(getActiveClient(store.getState())).toBe(null);
expect(getActiveDevice(store.getState())).toBe(device1);
});
test('select a metro device', async () => {
store.dispatch(
selectPlugin({
selectedPlugin: DevicePlugin1.id,
selectedDevice: metroDevice,
selectedAppId: d2app1.id, // this app will determine the active device
}),
);
const state = store.getState();
expect(state.connections).toMatchObject({
selectedDevice: metroDevice,
selectedPlugin: DevicePlugin1.id,
selectedAppId: d2app1.id,
userPreferredDevice: metroDevice.title,
// other prefs not updated
userPreferredPlugin: DevicePlugin1.id,
userPreferredApp: d2app1.query.app,
});
// used by plugin list, to keep main device / app selection correct
expect(getActiveClient(state)).toBe(d2app1);
expect(getActiveDevice(state)).toBe(device2);
});
test('introducing new client does not select it', async () => {
await mockFlipper.createClient(device2, 'd2app3');
expect(store.getState().connections).toMatchObject({
selectedDevice: device1,
selectedPlugin: TestPlugin1.id,
selectedAppId: d1app2.id,
// other prefs not updated
userPreferredDevice: device1.title,
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d1app1.query.app,
});
});
test('introducing new client does select it if preferred', async () => {
// pure testing evil
const client3 = await mockFlipper.createClient(
device2,
store.getState().connections.userPreferredApp!,
);
expect(store.getState().connections).toMatchObject({
selectedDevice: device2,
selectedPlugin: TestPlugin1.id,
selectedAppId: client3.id,
// other prefs not updated
userPreferredDevice: device1.title,
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d1app1.query.app,
});
});
test('introducing new client does select it if old is offline', async () => {
d1app2.disconnect();
const client3 = await mockFlipper.createClient(device2, 'd2app3');
expect(store.getState().connections).toMatchObject({
selectedDevice: device2,
selectedPlugin: TestPlugin1.id,
selectedAppId: client3.id,
// other prefs not updated
userPreferredDevice: device1.title,
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d1app1.query.app,
});
});
test('select client', () => {
store.dispatch(selectClient(d2app2.id));
expect(store.getState().connections).toMatchObject({
selectedDevice: device2,
selectedPlugin: TestPlugin1.id,
selectedAppId: d2app2.id,
userPreferredDevice: device2.title,
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d2app2.query.app,
});
});
test('select device', () => {
store.dispatch(selectDevice(metroDevice));
expect(store.getState().connections).toMatchObject({
selectedDevice: metroDevice,
selectedPlugin: TestPlugin1.id,
selectedAppId: null,
userPreferredDevice: metroDevice.title,
// other prefs not updated
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d1app1.query.app,
});
});
});

View File

@@ -0,0 +1,168 @@
/**
* 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 {
default as reducer,
startHealthchecks,
finishHealthchecks,
updateHealthcheckResult,
acknowledgeProblems,
} from '../healthchecks';
import {Healthchecks, EnvironmentInfo} from 'flipper-doctor';
const HEALTHCHECKS: Healthchecks = {
ios: {
label: 'iOS',
isSkipped: false,
isRequired: true,
healthchecks: [
{
key: 'ios.sdk',
label: 'SDK Installed',
run: async (_env: EnvironmentInfo) => {
return {hasProblem: false, message: ''};
},
},
],
},
android: {
label: 'Android',
isSkipped: false,
isRequired: true,
healthchecks: [
{
key: 'android.sdk',
label: 'SDK Installed',
run: async (_env: EnvironmentInfo) => {
return {hasProblem: true, message: 'Error'};
},
},
],
},
common: {
label: 'Common',
isSkipped: false,
isRequired: false,
healthchecks: [
{
key: 'common.openssl',
label: 'OpenSSL Istalled',
run: async (_env: EnvironmentInfo) => {
return {hasProblem: false, message: ''};
},
},
],
},
};
test('startHealthCheck', () => {
const res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
expect(res).toMatchSnapshot();
});
test('updateHealthcheckResult', () => {
let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
res = reducer(
res,
updateHealthcheckResult('android', 'android.sdk', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
expect(res).toMatchSnapshot();
});
test('finish', () => {
let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
res = reducer(
res,
updateHealthcheckResult('ios', 'ios.sdk', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(
res,
updateHealthcheckResult('android', 'android.sdk', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(
res,
updateHealthcheckResult('common', 'common.openssl', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(res, finishHealthchecks());
expect(res).toMatchSnapshot();
});
test('statuses updated after healthchecks finished', () => {
let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
res = reducer(
res,
updateHealthcheckResult('android', 'android.sdk', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'FAILED',
}),
);
res = reducer(
res,
updateHealthcheckResult('ios', 'ios.sdk', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(
res,
updateHealthcheckResult('common', 'common.openssl', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(res, finishHealthchecks());
expect(res).toMatchSnapshot();
});
test('acknowledgeProblems', () => {
let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
res = reducer(
res,
updateHealthcheckResult('ios', 'ios.sdk', {
isAcknowledged: false,
status: 'FAILED',
}),
);
res = reducer(
res,
updateHealthcheckResult('android', 'android.sdk', {
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(
res,
updateHealthcheckResult('common', 'common.openssl', {
isAcknowledged: false,
status: 'FAILED',
}),
);
res = reducer(res, finishHealthchecks());
res = reducer(res, acknowledgeProblems());
expect(res).toMatchSnapshot();
});

View File

@@ -0,0 +1,397 @@
/**
* 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 {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
import {
_SandyPluginDefinition,
TestUtils,
PluginClient,
Notification,
DevicePluginClient,
} from 'flipper-plugin';
import {State, addNotification, removeNotification} from '../notifications';
import {
default as reducer,
setActiveNotifications,
clearAllNotifications,
updatePluginBlocklist,
updateCategoryBlocklist,
} from '../notifications';
const notification: Notification = {
id: 'id',
title: 'title',
message: 'message',
severity: 'warning',
};
function getInitialState(): State {
return {
activeNotifications: [],
invalidatedNotifications: [],
blocklistedPlugins: [],
blocklistedCategories: [],
clearedNotifications: new Set(),
};
}
test('reduce updateCategoryBlocklist', () => {
const blocklistedCategories = ['blocklistedCategory'];
const res = reducer(
getInitialState(),
updateCategoryBlocklist(blocklistedCategories),
);
expect(res).toEqual({
...getInitialState(),
blocklistedCategories,
});
});
test('reduce updatePluginBlocklist', () => {
const blocklistedPlugins = ['blocklistedPlugin'];
const res = reducer(
getInitialState(),
updatePluginBlocklist(blocklistedPlugins),
);
expect(res).toEqual({
...getInitialState(),
blocklistedPlugins,
});
});
test('reduce clearAllNotifications', () => {
const pluginId = 'pluginId';
const client = 'client';
const res = reducer(
{
...getInitialState(),
activeNotifications: [
{
client,
pluginId,
notification,
},
],
},
clearAllNotifications(),
);
expect(res).toEqual({
...getInitialState(),
clearedNotifications: new Set([`${pluginId}#${notification.id}`]),
});
});
test('reduce setActiveNotifications', () => {
const pluginId = 'pluginId';
const client = 'client';
const res = reducer(
getInitialState(),
setActiveNotifications({
notifications: [notification],
client,
pluginId,
}),
);
expect(res).toEqual({
...getInitialState(),
activeNotifications: [
{
client,
pluginId,
notification,
},
],
});
});
test('addNotification removes duplicates', () => {
let res = reducer(
getInitialState(),
addNotification({
pluginId: 'test',
client: null,
notification,
}),
);
res = reducer(
res,
addNotification({
pluginId: 'test',
client: null,
notification: {
...notification,
id: 'otherId',
},
}),
);
res = reducer(
res,
removeNotification({
pluginId: 'test',
client: null,
notificationId: 'id',
}),
);
expect(res).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [
Object {
"client": null,
"notification": Object {
"id": "otherId",
"message": "message",
"severity": "warning",
"title": "title",
},
"pluginId": "test",
},
],
"blocklistedCategories": Array [],
"blocklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});
test('reduce removeNotification', () => {
let res = reducer(
getInitialState(),
addNotification({
pluginId: 'test',
client: null,
notification,
}),
);
res = reducer(
res,
addNotification({
pluginId: 'test',
client: null,
notification: {
...notification,
id: 'otherId',
},
}),
);
res = reducer(
res,
addNotification({
pluginId: 'test',
client: null,
notification: {
...notification,
message: 'slightly different message',
},
}),
);
expect(res).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [
Object {
"client": null,
"notification": Object {
"id": "otherId",
"message": "message",
"severity": "warning",
"title": "title",
},
"pluginId": "test",
},
Object {
"client": null,
"notification": Object {
"id": "id",
"message": "slightly different message",
"severity": "warning",
"title": "title",
},
"pluginId": "test",
},
],
"blocklistedCategories": Array [],
"blocklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});
test('notifications from plugins arrive in the notifications reducer', async () => {
const TestPlugin = TestUtils.createTestPlugin({
plugin(client: PluginClient) {
client.onUnhandledMessage(() => {
client.showNotification({
id: 'test',
message: 'test message',
severity: 'error',
title: 'hi',
action: 'dosomething',
});
});
return {};
},
});
const {store, client, sendMessage} = await createMockFlipperWithPlugin(
TestPlugin,
);
sendMessage('testMessage', {});
client.flushMessageBuffer();
expect(store.getState().notifications).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [
Object {
"client": "TestApp#Android#MockAndroidDevice#serial",
"notification": Object {
"action": "dosomething",
"id": "test",
"message": "test message",
"severity": "error",
"title": "hi",
},
"pluginId": "TestPlugin",
},
],
"blocklistedCategories": Array [],
"blocklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});
test('notifications from a device plugin arrive in the notifications reducer', async () => {
let trigger: any;
const TestPlugin = TestUtils.createTestDevicePlugin({
devicePlugin(client: DevicePluginClient) {
trigger = () => {
client.showNotification({
id: 'test',
message: 'test message',
severity: 'error',
title: 'hi',
action: 'dosomething',
});
};
return {};
},
});
const {store} = await createMockFlipperWithPlugin(TestPlugin);
trigger();
expect(store.getState().notifications).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [
Object {
"client": "serial",
"notification": Object {
"action": "dosomething",
"id": "test",
"message": "test message",
"severity": "error",
"title": "hi",
},
"pluginId": "TestPlugin",
},
],
"blocklistedCategories": Array [],
"blocklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});
test('errors end up as notifications if crash reporter is active', async () => {
const TestPlugin = TestUtils.createTestPlugin({
plugin() {
return {};
},
});
// eslint-disable-next-line
const CrashReporterImpl = require('../../../../plugins/public/crash_reporter/index');
const CrashPlugin = TestUtils.createTestDevicePlugin(CrashReporterImpl, {
id: 'CrashReporter',
});
const {store, client, sendError} = await createMockFlipperWithPlugin(
TestPlugin,
{
additionalPlugins: [CrashPlugin],
},
);
sendError('gone wrong');
client.flushMessageBuffer();
expect(store.getState().notifications).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [
Object {
"client": "serial",
"notification": Object {
"action": "0",
"category": "\\"gone wrong\\"",
"id": "0",
"message": "Callstack: No callstack available",
"severity": "error",
"title": "CRASH: Plugin ErrorReason: \\"gone wrong\\"",
},
"pluginId": "CrashReporter",
},
],
"blocklistedCategories": Array [],
"blocklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});
test('errors end NOT up as notifications if crash reporter is active but suppressPluginErrors is disabled', async () => {
const TestPlugin = TestUtils.createTestPlugin({
plugin() {
return {};
},
});
// eslint-disable-next-line
const CrashReporterImpl = require('../../../../plugins/public/crash_reporter/index');
const CrashPlugin = TestUtils.createTestDevicePlugin(CrashReporterImpl, {
id: 'CrashReporter',
});
const {store, client, sendError} = await createMockFlipperWithPlugin(
TestPlugin,
{
additionalPlugins: [CrashPlugin],
},
);
store.dispatch({
type: 'UPDATE_SETTINGS',
payload: {
...store.getState().settingsState,
suppressPluginErrors: true,
},
});
sendError('gone wrong');
client.flushMessageBuffer();
expect(store.getState().notifications).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [],
"blocklistedCategories": Array [],
"blocklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});

View File

@@ -0,0 +1,161 @@
/**
* 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 {
default as reducer,
registerPlugins,
addGatekeepedPlugins,
registerInstalledPlugins,
} from '../plugins';
import {FlipperPlugin, FlipperDevicePlugin, BaseAction} from '../../plugin';
import {InstalledPluginDetails} from 'flipper-plugin-lib';
import {wrapSandy} from '../../test-utils/createMockFlipperWithPlugin';
const testPluginOrig = class extends FlipperPlugin<any, BaseAction, any> {
static id = 'TestPlugin';
};
const testPlugin = wrapSandy(testPluginOrig);
const testDevicePluginOrig = class extends FlipperDevicePlugin<
any,
BaseAction,
any
> {
static id = 'TestDevicePlugin';
};
const testDevicePlugin = wrapSandy(testDevicePluginOrig);
test('add clientPlugin', () => {
const res = reducer(
{
devicePlugins: new Map(),
clientPlugins: new Map(),
loadedPlugins: new Map(),
bundledPlugins: new Map(),
gatekeepedPlugins: [],
failedPlugins: [],
disabledPlugins: [],
selectedPlugins: [],
marketplacePlugins: [],
uninstalledPluginNames: new Set(),
installedPlugins: new Map(),
initialized: false,
},
registerPlugins([testPlugin]),
);
expect(res.clientPlugins.get(testPlugin.id)).toBe(testPlugin);
});
test('add devicePlugin', () => {
const res = reducer(
{
devicePlugins: new Map(),
clientPlugins: new Map(),
loadedPlugins: new Map(),
bundledPlugins: new Map(),
gatekeepedPlugins: [],
failedPlugins: [],
disabledPlugins: [],
selectedPlugins: [],
marketplacePlugins: [],
uninstalledPluginNames: new Set(),
installedPlugins: new Map(),
initialized: false,
},
registerPlugins([testDevicePlugin]),
);
expect(res.devicePlugins.get(testDevicePlugin.id)).toBe(testDevicePlugin);
});
test('do not add plugin twice', () => {
const res = reducer(
{
devicePlugins: new Map(),
clientPlugins: new Map(),
loadedPlugins: new Map(),
bundledPlugins: new Map(),
gatekeepedPlugins: [],
failedPlugins: [],
disabledPlugins: [],
selectedPlugins: [],
marketplacePlugins: [],
uninstalledPluginNames: new Set(),
installedPlugins: new Map(),
initialized: false,
},
registerPlugins([testPlugin, testPlugin]),
);
expect(res.clientPlugins.size).toEqual(1);
});
test('add gatekeeped plugin', () => {
const gatekeepedPlugins: InstalledPluginDetails[] = [
{
name: 'plugin',
version: '1.0.0',
dir: '/plugins/test',
specVersion: 2,
pluginType: 'client',
source: 'src/index.ts',
isBundled: false,
isActivatable: true,
main: 'lib/index.js',
title: 'test',
id: 'test',
entry: '/plugins/test/lib/index.js',
},
];
const res = reducer(
{
devicePlugins: new Map(),
clientPlugins: new Map(),
loadedPlugins: new Map(),
bundledPlugins: new Map(),
gatekeepedPlugins: [],
failedPlugins: [],
disabledPlugins: [],
selectedPlugins: [],
marketplacePlugins: [],
installedPlugins: new Map(),
uninstalledPluginNames: new Set(),
initialized: false,
},
addGatekeepedPlugins(gatekeepedPlugins),
);
expect(res.gatekeepedPlugins).toEqual(gatekeepedPlugins);
});
test('reduce empty registerInstalledPlugins', () => {
const result = reducer(undefined, registerInstalledPlugins([]));
expect(result.installedPlugins).toEqual(new Map());
});
const EXAMPLE_PLUGIN = {
name: 'test',
version: '0.1',
description: 'my test plugin',
dir: '/plugins/test',
specVersion: 2,
source: 'src/index.ts',
isBundled: false,
isActivatable: true,
main: 'lib/index.js',
title: 'test',
id: 'test',
entry: '/plugins/test/lib/index.js',
} as InstalledPluginDetails;
test('reduce registerInstalledPlugins, clear again', () => {
const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN]));
expect(result.installedPlugins).toEqual(
new Map([[EXAMPLE_PLUGIN.name, EXAMPLE_PLUGIN]]),
);
const result2 = reducer(result, registerInstalledPlugins([]));
expect(result2.installedPlugins).toEqual(new Map());
});

View File

@@ -0,0 +1,88 @@
/**
* 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 {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
import {Store} from '../../reducers/';
import {selectPlugin} from '../../reducers/connections';
import {
_SandyPluginDefinition,
_SandyDevicePluginInstance,
DevicePluginClient,
TestUtils,
} from 'flipper-plugin';
const pluginDetails = TestUtils.createMockPluginDetails();
let initialized = false;
beforeEach(() => {
initialized = false;
});
function devicePlugin(client: DevicePluginClient) {
const activateStub = jest.fn();
const deactivateStub = jest.fn();
const destroyStub = jest.fn();
client.onActivate(activateStub);
client.onDeactivate(deactivateStub);
client.onDestroy(destroyStub);
initialized = true;
return {
activateStub: activateStub,
deactivateStub: deactivateStub,
destroyStub,
};
}
const TestPlugin = new _SandyPluginDefinition(pluginDetails, {
supportsDevice: jest.fn().mockImplementation(() => true),
devicePlugin: jest
.fn()
.mockImplementation(devicePlugin) as typeof devicePlugin,
Component: jest.fn().mockImplementation(() => null),
});
type PluginApi = ReturnType<typeof devicePlugin>;
function selectTestPlugin(store: Store) {
store.dispatch(
selectPlugin({
selectedPlugin: TestPlugin.id,
selectedAppId: null,
deepLinkPayload: null,
selectedDevice: store.getState().connections.selectedDevice!,
}),
);
}
test('it should initialize device sandy plugins', async () => {
const {device, store} = await createMockFlipperWithPlugin(TestPlugin);
// already started, so initialized immediately
expect(initialized).toBe(true);
expect(device.sandyPluginStates.get(TestPlugin.id)).toBeInstanceOf(
_SandyDevicePluginInstance,
);
const instanceApi: PluginApi = device.sandyPluginStates.get(
TestPlugin.id,
)!.instanceApi;
expect(instanceApi.activateStub).toBeCalledTimes(0);
selectTestPlugin(store);
// without rendering, non-bg plugins won't connect automatically,
// so this isn't the best test, but PluginContainer tests do test that part of the lifecycle
device.sandyPluginStates.get(TestPlugin.id)!.activate();
expect(instanceApi.activateStub).toBeCalledTimes(1);
device.sandyPluginStates.get(TestPlugin.id)!.deactivate();
expect(instanceApi.deactivateStub).toBeCalledTimes(1);
expect(instanceApi.destroyStub).toBeCalledTimes(0);
});

View File

@@ -0,0 +1,299 @@
/**
* 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 {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
import Client from '../../Client';
import {Store} from '../../reducers';
import {registerPlugins} from '../../reducers/plugins';
import {
_SandyPluginDefinition,
_SandyPluginInstance,
PluginClient,
TestUtils,
} from 'flipper-plugin';
import {switchPlugin} from '../pluginManager';
const pluginDetails = TestUtils.createMockPluginDetails();
let initialized = false;
beforeEach(() => {
initialized = false;
});
function plugin(client: PluginClient<any, any>) {
const connectStub = jest.fn();
const disconnectStub = jest.fn();
const destroyStub = jest.fn();
const messages: any[] = [];
client.onConnect(connectStub);
client.onDisconnect(disconnectStub);
client.onDestroy(destroyStub);
client.onMessage('message', (msg) => {
messages.push(msg);
});
initialized = true;
return {
connectStub,
disconnectStub,
destroyStub,
send: client.send,
messages,
};
}
const TestPlugin = new _SandyPluginDefinition(pluginDetails, {
plugin: jest.fn().mockImplementation(plugin) as typeof plugin,
Component: jest.fn().mockImplementation(() => null),
});
type PluginApi = ReturnType<typeof plugin>;
function starTestPlugin(store: Store, client: Client) {
store.dispatch(
switchPlugin({
plugin: TestPlugin,
selectedApp: client.query.app,
}),
);
}
test('it should initialize starred sandy plugins', async () => {
const {client} = await createMockFlipperWithPlugin(TestPlugin);
// already started, so initialized immediately
expect(initialized).toBe(true);
expect(client.sandyPluginStates.get(TestPlugin.id)).toBeInstanceOf(
_SandyPluginInstance,
);
const instanceApi: PluginApi = client.sandyPluginStates.get(
TestPlugin.id,
)!.instanceApi;
expect(instanceApi.connectStub).toBeCalledTimes(1);
client.deinitPlugin(TestPlugin.id);
expect(instanceApi.disconnectStub).toBeCalledTimes(1);
expect(instanceApi.destroyStub).toBeCalledTimes(0);
});
test('it should cleanup a plugin if disabled', async () => {
const {client, store} = await createMockFlipperWithPlugin(TestPlugin);
expect(TestPlugin.asPluginModule().plugin).toBeCalledTimes(1);
const pluginInstance: PluginApi = client.sandyPluginStates.get(
TestPlugin.id,
)!.instanceApi;
expect(pluginInstance.destroyStub).toHaveBeenCalledTimes(0);
client.initPlugin(TestPlugin.id);
expect(pluginInstance.connectStub).toHaveBeenCalledTimes(1);
// disable
starTestPlugin(store, client);
expect(client.sandyPluginStates.has(TestPlugin.id)).toBeFalsy();
expect(pluginInstance.disconnectStub).toHaveBeenCalledTimes(1);
expect(pluginInstance.destroyStub).toHaveBeenCalledTimes(1);
});
test('it should NOT cleanup if client is removed', async () => {
const {client} = await createMockFlipperWithPlugin(TestPlugin);
const pluginInstance = client.sandyPluginStates.get(TestPlugin.id)!;
expect(pluginInstance.instanceApi.destroyStub).toHaveBeenCalledTimes(0);
pluginInstance.connect();
expect(client.connected.get()).toBe(true);
client.disconnect();
expect(client.connected.get()).toBe(false);
expect(client.sandyPluginStates.has(TestPlugin.id)).toBeTruthy();
expect(
(pluginInstance.instanceApi as PluginApi).disconnectStub,
).toHaveBeenCalledTimes(1);
expect(
(pluginInstance.instanceApi as PluginApi).destroyStub,
).toHaveBeenCalledTimes(0);
});
test('it should cleanup if client is destroyed', async () => {
const {client} = await createMockFlipperWithPlugin(TestPlugin);
const pluginInstance = client.sandyPluginStates.get(TestPlugin.id)!;
expect(pluginInstance.instanceApi.destroyStub).toHaveBeenCalledTimes(0);
client.destroy();
expect(client.sandyPluginStates.has(TestPlugin.id)).toBeFalsy();
expect(
(pluginInstance.instanceApi as PluginApi).destroyStub,
).toHaveBeenCalledTimes(1);
});
test('it should not initialize a sandy plugin if not enabled', async () => {
const {client, store} = await createMockFlipperWithPlugin(TestPlugin);
const Plugin2 = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails({
name: 'Plugin2',
id: 'Plugin2',
}),
{
plugin: jest.fn().mockImplementation(plugin),
Component() {
return null;
},
},
);
const pluginState1 = client.sandyPluginStates.get(TestPlugin.id);
expect(pluginState1).toBeInstanceOf(_SandyPluginInstance);
store.dispatch(registerPlugins([Plugin2]));
await client.refreshPlugins();
// not yet enabled, so not yet started
expect(client.sandyPluginStates.get(Plugin2.id)).toBeUndefined();
expect(Plugin2.asPluginModule().plugin).toBeCalledTimes(0);
store.dispatch(
switchPlugin({
plugin: Plugin2,
selectedApp: client.query.app,
}),
);
expect(client.sandyPluginStates.get(Plugin2.id)).toBeInstanceOf(
_SandyPluginInstance,
);
const instance = client.sandyPluginStates.get(Plugin2.id)!
.instanceApi as PluginApi;
expect(client.sandyPluginStates.get(TestPlugin.id)).toBe(pluginState1); // not reinitialized
expect(TestPlugin.asPluginModule().plugin).toBeCalledTimes(1);
expect(Plugin2.asPluginModule().plugin).toBeCalledTimes(1);
expect(instance.destroyStub).toHaveBeenCalledTimes(0);
// disable plugin again
store.dispatch(
switchPlugin({
plugin: Plugin2,
selectedApp: client.query.app,
}),
);
expect(client.sandyPluginStates.get(Plugin2.id)).toBeUndefined();
expect(instance.connectStub).toHaveBeenCalledTimes(0);
// disconnect wasn't called because connect was never called
expect(instance.disconnectStub).toHaveBeenCalledTimes(0);
expect(instance.destroyStub).toHaveBeenCalledTimes(1);
});
test('it trigger hooks for background plugins', async () => {
const {client} = await createMockFlipperWithPlugin(TestPlugin, {
asBackgroundPlugin: true,
});
const pluginInstance: PluginApi = client.sandyPluginStates.get(
TestPlugin.id,
)!.instanceApi;
expect(client.isBackgroundPlugin(TestPlugin.id)).toBeTruthy();
expect(pluginInstance.destroyStub).toHaveBeenCalledTimes(0);
expect(pluginInstance.connectStub).toHaveBeenCalledTimes(1);
expect(pluginInstance.disconnectStub).toHaveBeenCalledTimes(0);
client.destroy();
expect(client.sandyPluginStates.has(TestPlugin.id)).toBeFalsy();
expect(pluginInstance.destroyStub).toHaveBeenCalledTimes(1);
expect(pluginInstance.connectStub).toHaveBeenCalledTimes(1);
expect(pluginInstance.disconnectStub).toHaveBeenCalledTimes(1);
});
test('it can send messages from sandy clients', async () => {
let testMethodCalledWith: any = undefined;
const {client} = await createMockFlipperWithPlugin(TestPlugin, {
onSend(method, params) {
if (method === 'execute') {
testMethodCalledWith = params;
return {};
}
},
});
const pluginInstance: PluginApi = client.sandyPluginStates.get(
TestPlugin.id,
)!.instanceApi;
// without rendering, non-bg plugins won't connect automatically,
client.initPlugin(TestPlugin.id);
await pluginInstance.send('test', {test: 3});
expect(testMethodCalledWith).toMatchInlineSnapshot(`
Object {
"api": "TestPlugin",
"method": "test",
"params": Object {
"test": 3,
},
}
`);
});
test('it should initialize "Navigation" plugin if not enabled', async () => {
const {client, store} = await createMockFlipperWithPlugin(TestPlugin, {
supportedPlugins: ['Navigation'],
});
const Plugin2 = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails({
name: 'Plugin2',
id: 'Navigation',
}),
{
plugin: jest.fn().mockImplementation(plugin),
Component() {
return null;
},
},
);
const pluginState1 = client.sandyPluginStates.get(TestPlugin.id);
expect(pluginState1).toBeInstanceOf(_SandyPluginInstance);
store.dispatch(registerPlugins([Plugin2]));
await client.refreshPlugins();
// not enabled, but Navigation is an exception, so we still get an instance
const origInstance = client.sandyPluginStates.get(Plugin2.id);
expect(origInstance).toBeDefined();
expect(Plugin2.asPluginModule().plugin).toBeCalledTimes(1);
store.dispatch(
switchPlugin({
plugin: Plugin2,
selectedApp: client.query.app,
}),
);
expect(client.sandyPluginStates.get(Plugin2.id)).toBe(origInstance);
const instance = client.sandyPluginStates.get(Plugin2.id)!
.instanceApi as PluginApi;
expect(Plugin2.asPluginModule().plugin).toBeCalledTimes(1);
expect(instance.destroyStub).toHaveBeenCalledTimes(0);
// disable plugin again
store.dispatch(
switchPlugin({
plugin: Plugin2,
selectedApp: client.query.app,
}),
);
// stil enabled
expect(client.sandyPluginStates.get(Plugin2.id)).toBe(origInstance);
expect(instance.connectStub).toHaveBeenCalledTimes(0);
// disconnect wasn't called because connect was never called
expect(instance.disconnectStub).toHaveBeenCalledTimes(0);
expect(instance.destroyStub).toHaveBeenCalledTimes(0);
// closing does stop the plugin!
client.destroy();
expect(instance.destroyStub).toHaveBeenCalledTimes(1);
expect(client.sandyPluginStates.get(Plugin2.id)).toBeUndefined();
});

View File

@@ -0,0 +1,36 @@
/**
* 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 {default as reducer, updateSettings, Tristate} from '../settings';
test('init', () => {
const res = reducer(undefined, {type: 'INIT'});
expect(res.enableAndroid).toBeTruthy();
});
test('updateSettings', () => {
const initialSettings = reducer(undefined, {type: 'INIT'});
const updatedSettings = Object.assign(initialSettings, {
enableAndroid: false,
enablePrefetching: Tristate.True,
jsApps: {
webAppLauncher: {
height: 900,
},
},
});
const res = reducer(initialSettings, updateSettings(updatedSettings));
expect(res.enableAndroid).toBeFalsy();
expect(res.enablePrefetching).toEqual(Tristate.True);
expect(res.jsApps.webAppLauncher.height).toEqual(900);
expect(res.jsApps.webAppLauncher.width).toEqual(
initialSettings.jsApps.webAppLauncher.width,
);
});

View File

@@ -0,0 +1,26 @@
/**
* 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 {default as reducer, login, logout} from '../user';
test('login', () => {
const userData = {name: 'Jane Doe'};
const res = reducer({}, login(userData));
expect(res).toEqual(userData);
});
test('logout', () => {
const res = reducer(
{
name: 'Jane Doe',
},
logout(),
);
expect(res).toEqual({});
});

View File

@@ -0,0 +1,196 @@
/**
* 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 {v1 as uuidv1} from 'uuid';
import {getRenderHostInstance} from '../RenderHost';
import {Actions} from './';
export type LauncherMsg = {
message: string;
severity: 'warning' | 'error';
};
export type StatusMessageType = {
msg: string;
sender: string;
};
type SubShareType =
| {
type: 'file';
file: string;
}
| {
type: 'link';
url?: string;
};
export type ShareType = {
statusComponent?: React.ReactNode;
closeOnFinish: boolean;
} & SubShareType;
export type State = {
leftSidebarVisible: boolean;
rightSidebarVisible: boolean;
rightSidebarAvailable: boolean;
windowIsFocused: boolean;
share: ShareType | null;
sessionId: string | null;
launcherMsg: LauncherMsg;
statusMessages: Array<string>;
};
type BooleanActionType =
| 'leftSidebarVisible'
| 'rightSidebarVisible'
| 'rightSidebarAvailable';
export type Action =
| {
type: BooleanActionType;
payload?: boolean;
}
| {
type: 'windowIsFocused';
payload: {isFocused: boolean; time: number};
}
| {
type: 'LAUNCHER_MSG';
payload: {
severity: 'warning' | 'error';
message: string;
};
}
| {
type: 'ADD_STATUS_MSG';
payload: {msg: string; sender: string};
}
| {
type: 'REMOVE_STATUS_MSG';
payload: {msg: string; sender: string};
};
export const initialState: () => State = () => ({
leftSidebarVisible: true,
rightSidebarVisible: true,
rightSidebarAvailable: false,
windowIsFocused: getRenderHostInstance().hasFocus(),
activeSheet: null,
share: null,
sessionId: uuidv1(),
launcherMsg: {
severity: 'warning',
message: '',
},
statusMessages: [],
trackingTimeline: [],
});
function statusMessage(sender: string, msg: string): string {
const messageTrimmed = msg.trim();
const senderTrimmed = sender.trim();
let statusMessage = senderTrimmed.length > 0 ? senderTrimmed : '';
statusMessage =
statusMessage.length > 0 && messageTrimmed.length > 0
? `${statusMessage}: ${messageTrimmed}`
: '';
return statusMessage;
}
export default function reducer(
state: State | undefined,
action: Actions,
): State {
state = state || initialState();
if (
action.type === 'leftSidebarVisible' ||
action.type === 'rightSidebarVisible' ||
action.type === 'rightSidebarAvailable'
) {
const newValue =
typeof action.payload === 'undefined'
? !state[action.type]
: action.payload;
if (state[action.type] === newValue) {
// value hasn't changed
return state;
} else {
return {
...state,
[action.type]: newValue,
};
}
} else if (action.type === 'windowIsFocused') {
return {
...state,
windowIsFocused: action.payload.isFocused,
};
} else if (action.type === 'LAUNCHER_MSG') {
return {
...state,
launcherMsg: action.payload,
};
} else if (action.type === 'ADD_STATUS_MSG') {
const {sender, msg} = action.payload;
const statusMsg = statusMessage(sender, msg);
if (statusMsg.length > 0) {
return {
...state,
statusMessages: [...state.statusMessages, statusMsg],
};
}
return state;
} else if (action.type === 'REMOVE_STATUS_MSG') {
const {sender, msg} = action.payload;
const statusMsg = statusMessage(sender, msg);
if (statusMsg.length > 0) {
const statusMessages = [...state.statusMessages];
statusMessages.splice(statusMessages.indexOf(statusMsg), 1);
return {...state, statusMessages};
}
return state;
} else {
return state;
}
}
export const toggleAction = (
type: BooleanActionType,
payload?: boolean,
): Action => ({
type,
payload,
});
export const toggleLeftSidebarVisible = (payload?: boolean): Action => ({
type: 'leftSidebarVisible',
payload,
});
export const toggleRightSidebarVisible = (payload?: boolean): Action => ({
type: 'rightSidebarVisible',
payload,
});
export const toggleRightSidebarAvailable = (payload?: boolean): Action => ({
type: 'rightSidebarAvailable',
payload,
});
export const addStatusMessage = (payload: StatusMessageType): Action => ({
type: 'ADD_STATUS_MSG',
payload,
});
export const removeStatusMessage = (payload: StatusMessageType): Action => ({
type: 'REMOVE_STATUS_MSG',
payload,
});

View File

@@ -0,0 +1,598 @@
/**
* 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 {ComponentType} from 'react';
import {produce} from 'immer';
import type BaseDevice from '../devices/BaseDevice';
import type Client from '../Client';
import type {
UninitializedClient,
DeviceOS,
Logger,
FlipperServer,
} from 'flipper-common';
import {performance} from 'perf_hooks';
import type {Actions} from '.';
import {WelcomeScreenStaticView} from '../sandy-chrome/WelcomeScreen';
import {isDevicePluginDefinition} from '../utils/pluginUtils';
import {getPluginKey} from '../utils/pluginKey';
import {deconstructClientId} from 'flipper-common';
import type {RegisterPluginAction} from './plugins';
import {shallowEqual} from 'react-redux';
import {NormalizedMenuEntry} from 'flipper-plugin';
export type StaticViewProps = {logger: Logger};
export type StaticView =
| null
| ComponentType<StaticViewProps>
| React.FunctionComponent<any>;
export type State = StateV2;
export const persistVersion = 2;
export const persistMigrations = {
1: (state: any) => {
const stateV0 = state as StateV0;
const stateV1 = {
...stateV0,
enabledPlugins: stateV0.userStarredPlugins ?? {},
enabledDevicePlugins:
stateV0.userStarredDevicePlugins ??
new Set<string>(INITAL_STATE.enabledDevicePlugins),
};
return stateV1 as any;
},
2: (state: any) => {
const stateV1 = state as StateV1;
const stateV2 = {
...stateV1,
enabledPlugins: stateV1.enabledPlugins ?? {},
enabledDevicePlugins:
stateV1.enabledDevicePlugins ??
new Set<string>(INITAL_STATE.enabledDevicePlugins),
};
return stateV2 as any;
},
};
type StateV2 = {
devices: Array<BaseDevice>;
selectedDevice: null | BaseDevice;
selectedPlugin: null | string;
selectedAppId: null | string; // Full quantified identifier of the app
pluginMenuEntries: NormalizedMenuEntry[];
userPreferredDevice: null | string;
userPreferredPlugin: null | string;
userPreferredApp: null | string; // The name of the preferred app, e.g. Facebook
enabledPlugins: {[client: string]: string[]};
enabledDevicePlugins: Set<string>;
clients: Map<string, Client>;
uninitializedClients: UninitializedClient[];
deepLinkPayload: unknown;
staticView: StaticView;
selectedAppPluginListRevision: number;
flipperServer: FlipperServer | undefined;
};
type StateV1 = Omit<StateV2, 'enabledPlugins' | 'enabledDevicePlugins'> & {
enabledPlugins?: {[client: string]: string[]};
enabledDevicePlugins?: Set<string>;
};
type StateV0 = Omit<StateV1, 'enabledPlugins' | 'enabledDevicePlugins'> & {
userStarredPlugins?: {[client: string]: string[]};
userStarredDevicePlugins?: Set<string>;
};
export type Action =
| {
type: 'REGISTER_DEVICE';
payload: BaseDevice;
}
| {
type: 'SELECT_DEVICE';
payload: BaseDevice;
}
| {
type: 'SELECT_PLUGIN';
payload: {
selectedPlugin: string;
selectedAppId?: null | string; // not set for device plugins
deepLinkPayload?: unknown;
selectedDevice?: BaseDevice | null;
time: number;
};
}
| {
type: 'SET_MENU_ENTRIES';
payload: NormalizedMenuEntry[];
}
| {
type: 'NEW_CLIENT';
payload: Client;
}
| {
type: 'CLIENT_REMOVED';
payload: string;
}
| {
type: 'START_CLIENT_SETUP';
payload: UninitializedClient;
}
| {
type: 'SET_STATIC_VIEW';
payload: StaticView;
deepLinkPayload: unknown;
}
| {
type: 'SET_PLUGIN_ENABLED';
payload: {
pluginId: string;
selectedApp: string;
};
}
| {
type: 'SET_DEVICE_PLUGIN_ENABLED';
payload: {
pluginId: string;
};
}
| {
type: 'SET_PLUGIN_DISABLED';
payload: {
pluginId: string;
selectedApp: string;
};
}
| {
type: 'SET_DEVICE_PLUGIN_DISABLED';
payload: {
pluginId: string;
};
}
| {
type: 'SELECT_CLIENT';
payload: string; // App ID
}
| {
type: 'APP_PLUGIN_LIST_CHANGED';
}
| {
type: 'SET_FLIPPER_SERVER';
payload: FlipperServer;
}
| RegisterPluginAction;
const DEFAULT_PLUGIN = 'DeviceLogs';
const DEFAULT_DEVICE_BLACKLIST: DeviceOS[] = ['MacOS', 'Metro', 'Windows'];
const INITAL_STATE: State = {
devices: [],
selectedDevice: null,
selectedAppId: null,
selectedPlugin: DEFAULT_PLUGIN,
pluginMenuEntries: [],
userPreferredDevice: null,
userPreferredPlugin: null,
userPreferredApp: null,
enabledPlugins: {},
enabledDevicePlugins: new Set([
'DeviceLogs',
'CrashReporter',
'MobileBuilds',
'Hermesdebuggerrn',
'React',
]),
clients: new Map(),
uninitializedClients: [],
deepLinkPayload: null,
staticView: WelcomeScreenStaticView,
selectedAppPluginListRevision: 0,
flipperServer: undefined,
};
export default (state: State = INITAL_STATE, action: Actions): State => {
switch (action.type) {
case 'SET_FLIPPER_SERVER': {
return {
...state,
flipperServer: action.payload,
};
}
case 'SET_STATIC_VIEW': {
const {payload, deepLinkPayload} = action;
return {
...state,
staticView: payload,
deepLinkPayload: deepLinkPayload ?? null,
};
}
case 'RESET_SUPPORT_FORM_V2_STATE': {
return {
...state,
staticView: null,
};
}
case 'SELECT_DEVICE': {
const {payload} = action;
return {
...state,
staticView: null,
selectedDevice: payload,
selectedAppId: null,
userPreferredDevice: payload
? payload.title
: state.userPreferredDevice,
};
}
case 'SET_MENU_ENTRIES': {
return {...state, pluginMenuEntries: action.payload};
}
case 'REGISTER_DEVICE': {
const {payload} = action;
const newDevices = state.devices.slice();
const existing = state.devices.findIndex(
(device) => device.serial === payload.serial,
);
if (existing !== -1) {
const d = newDevices[existing];
if (d.connected.get()) {
throw new Error(`Cannot register, '${d.serial}' is still connected`);
}
newDevices[existing] = payload;
} else {
newDevices.push(payload);
}
const selectNewDevice =
!state.selectedDevice ||
!state.selectedDevice.isConnected ||
state.userPreferredDevice === payload.title;
let selectedAppId = state.selectedAppId;
if (selectNewDevice) {
// need to select a different app
selectedAppId =
getAllClients(state).find(
(c) =>
c.device === payload && c.query.app === state.userPreferredApp,
)?.id ?? null;
// nothing found, try first app if any
if (!selectedAppId) {
selectedAppId =
getAllClients(state).find((c) => c.device === payload)?.id ?? null;
}
}
return {
...state,
devices: newDevices,
selectedDevice: selectNewDevice ? payload : state.selectedDevice,
selectedAppId,
};
}
case 'SELECT_PLUGIN': {
const {selectedPlugin, selectedAppId, deepLinkPayload} = action.payload;
if (selectedPlugin) {
performance.mark(`activePlugin-${selectedPlugin}`);
}
const client = state.clients.get(selectedAppId!);
const device = action.payload.selectedDevice ?? client?.device;
if (!device) {
console.warn(
'No valid device / client provided when calling SELECT_PLUGIN',
);
return state;
}
return {
...state,
staticView: null,
selectedDevice: device,
userPreferredDevice: canBeDefaultDevice(device)
? device.title
: state.userPreferredDevice,
selectedAppId: selectedAppId ?? null,
userPreferredApp:
state.clients.get(selectedAppId!)?.query.app ??
state.userPreferredApp,
selectedPlugin,
userPreferredPlugin: selectedPlugin,
deepLinkPayload: deepLinkPayload,
};
}
case 'NEW_CLIENT': {
const {payload} = action;
return produce(state, (draft) => {
if (draft.clients.has(payload.id)) {
console.error(
`Received a new connection for client ${payload.id}, but the old connection was not cleaned up`,
);
}
draft.clients.set(payload.id, payload);
// select new client if nothing select, this one is preferred, or the old one is offline
const selectNewClient =
!draft.selectedAppId ||
draft.userPreferredApp === payload.query.app ||
draft.clients.get(draft.selectedAppId!)?.connected.get() === false;
if (selectNewClient) {
draft.selectedAppId = payload.id;
draft.selectedDevice = payload.device;
}
const unitialisedIndex = draft.uninitializedClients.findIndex(
(c) =>
c.deviceName === payload.query.device ||
c.appName === payload.query.app,
);
if (unitialisedIndex !== -1)
draft.uninitializedClients.splice(unitialisedIndex, 1);
});
}
case 'SELECT_CLIENT': {
const {payload} = action;
const client = state.clients.get(payload);
if (!client) {
return state;
}
return {
...state,
selectedAppId: payload,
selectedDevice: client.device,
userPreferredDevice: client.device.title,
userPreferredApp: client.query.app,
selectedPlugin:
state.selectedPlugin && client.supportsPlugin(state.selectedPlugin)
? state.selectedPlugin
: state.userPreferredPlugin &&
client.supportsPlugin(state.userPreferredPlugin)
? state.userPreferredPlugin
: null,
};
}
case 'CLIENT_REMOVED': {
const {payload} = action;
return produce(state, (draft) => {
draft.clients.delete(payload);
if (draft.selectedAppId === payload) {
draft.selectedAppId = null;
}
});
}
case 'START_CLIENT_SETUP': {
const {payload} = action;
return {
...state,
uninitializedClients: [
...state.uninitializedClients.filter(
(existing) => !shallowEqual(existing, payload),
),
payload,
],
};
}
case 'REGISTER_PLUGINS': {
// plugins are registered after creating the base devices, so update them
const plugins = action.payload;
plugins.forEach((plugin) => {
if (isDevicePluginDefinition(plugin)) {
// smell: devices are mutable
state.devices.forEach((device) => {
device.loadDevicePlugin(plugin);
});
}
});
return state;
}
case 'SET_PLUGIN_ENABLED': {
const {pluginId, selectedApp} = action.payload;
return produce(state, (draft) => {
if (!draft.enabledPlugins[selectedApp]) {
draft.enabledPlugins[selectedApp] = [];
}
const plugins = draft.enabledPlugins[selectedApp];
const idx = plugins.indexOf(pluginId);
if (idx === -1) {
plugins.push(pluginId);
}
});
}
case 'SET_DEVICE_PLUGIN_ENABLED': {
const {pluginId} = action.payload;
return produce(state, (draft) => {
draft.enabledDevicePlugins.add(pluginId);
});
}
case 'SET_PLUGIN_DISABLED': {
const {pluginId, selectedApp} = action.payload;
return produce(state, (draft) => {
if (!draft.enabledPlugins[selectedApp]) {
draft.enabledPlugins[selectedApp] = [];
}
const plugins = draft.enabledPlugins[selectedApp];
const idx = plugins.indexOf(pluginId);
if (idx !== -1) {
plugins.splice(idx, 1);
}
});
}
case 'SET_DEVICE_PLUGIN_DISABLED': {
const {pluginId} = action.payload;
return produce(state, (draft) => {
draft.enabledDevicePlugins.delete(pluginId);
});
}
case 'APP_PLUGIN_LIST_CHANGED': {
return produce(state, (draft) => {
draft.selectedAppPluginListRevision++;
});
}
default:
return state;
}
};
export const selectDevice = (payload: BaseDevice): Action => ({
type: 'SELECT_DEVICE',
payload,
});
export const setStaticView = (
payload: StaticView,
deepLinkPayload?: unknown,
): Action => {
if (!payload) {
throw new Error('Cannot set empty static view');
}
return {
type: 'SET_STATIC_VIEW',
payload,
deepLinkPayload,
};
};
export const selectPlugin = (payload: {
selectedPlugin: string;
selectedAppId?: null | string;
selectedDevice?: null | BaseDevice;
deepLinkPayload?: unknown;
time?: number;
}): Action => ({
type: 'SELECT_PLUGIN',
payload: {...payload, time: payload.time ?? Date.now()},
});
export const setMenuEntries = (menuEntries: NormalizedMenuEntry[]): Action => ({
type: 'SET_MENU_ENTRIES',
payload: menuEntries,
});
export const selectClient = (clientId: string): Action => ({
type: 'SELECT_CLIENT',
payload: clientId,
});
export const setPluginEnabled = (pluginId: string, appId: string): Action => ({
type: 'SET_PLUGIN_ENABLED',
payload: {
pluginId,
selectedApp: appId,
},
});
export const setDevicePluginEnabled = (pluginId: string): Action => ({
type: 'SET_DEVICE_PLUGIN_ENABLED',
payload: {
pluginId,
},
});
export const setDevicePluginDisabled = (pluginId: string): Action => ({
type: 'SET_DEVICE_PLUGIN_DISABLED',
payload: {
pluginId,
},
});
export const setPluginDisabled = (pluginId: string, appId: string): Action => ({
type: 'SET_PLUGIN_DISABLED',
payload: {
pluginId,
selectedApp: appId,
},
});
export const appPluginListChanged = (): Action => ({
type: 'APP_PLUGIN_LIST_CHANGED',
});
export function getClientsByDevice(
device: null | undefined | BaseDevice,
clients: Map<string, Client>,
): Client[] {
if (!device) {
return [];
}
return Array.from(clients.values())
.filter((client: Client) => client.query.device_id === device.serial)
.sort((a, b) => (a.query.app || '').localeCompare(b.query.app));
}
export function getClientsByAppName(
clients: Map<string, Client>,
appName: string | null | undefined,
): Client[] {
return Array.from(clients.values()).filter(
(client) => client.query.app === appName,
);
}
export function getClientById(
clients: Client[],
clientId: string | null | undefined,
): Client | undefined {
return clients.find((client) => client.id === clientId);
}
export function canBeDefaultDevice(device: BaseDevice) {
return !DEFAULT_DEVICE_BLACKLIST.includes(device.os);
}
export function getSelectedPluginKey(state: State): string | undefined {
return state.selectedPlugin
? getPluginKey(
state.selectedAppId,
state.selectedDevice,
state.selectedPlugin,
)
: undefined;
}
export function isPluginEnabled(
enabledPlugins: State['enabledPlugins'],
enabledDevicePlugins: State['enabledDevicePlugins'],
app: string | null,
pluginId: string,
): boolean {
if (enabledDevicePlugins?.has(pluginId)) {
return true;
}
if (!app || !enabledPlugins) {
return false;
}
const appInfo = deconstructClientId(app);
const enabledAppPlugins = enabledPlugins[appInfo.app];
return enabledAppPlugins && enabledAppPlugins.indexOf(pluginId) > -1;
}
export function getAllClients(state: State): readonly Client[] {
return Array.from(state.clients.values());
}

View File

@@ -0,0 +1,272 @@
/**
* 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 {Actions} from './';
import {produce} from 'immer';
import {Healthchecks} from 'flipper-doctor';
export type State = {
healthcheckReport: HealthcheckReport;
acknowledgedProblems: string[];
};
export type Action =
| {
type: 'START_HEALTHCHECKS';
payload: Healthchecks;
}
| {
type: 'FINISH_HEALTHCHECKS';
}
| {
type: 'UPDATE_HEALTHCHECK_RESULT';
payload: {
categoryKey: string;
itemKey: string;
result: HealthcheckResult;
};
}
| {
type: 'ACKNOWLEDGE_PROBLEMS';
}
| {
type: 'RESET_ACKNOWLEDGED_PROBLEMS';
};
const INITIAL_STATE: State = {
healthcheckReport: {
result: {status: 'IN_PROGRESS'},
categories: {},
},
acknowledgedProblems: [],
};
type Dictionary<T> = {[key: string]: T};
export type HealthcheckStatus =
| 'IN_PROGRESS'
| 'SUCCESS'
| 'FAILED'
| 'SKIPPED'
| 'WARNING';
export type HealthcheckResult = {
status: HealthcheckStatus;
isAcknowledged?: boolean;
message?: string;
};
export type HealthcheckReportItem = {
key: string;
label: string;
result: HealthcheckResult;
};
export type HealthcheckReportCategory = {
key: string;
label: string;
result: HealthcheckResult;
checks: Dictionary<HealthcheckReportItem>;
};
export type HealthcheckReport = {
result: HealthcheckResult;
categories: Dictionary<HealthcheckReportCategory>;
};
function recomputeHealthcheckStatus(draft: State): void {
draft.healthcheckReport.result = computeAggregatedResult(
Object.values(draft.healthcheckReport.categories).map((c) => c.result),
);
}
function computeAggregatedResult(
results: HealthcheckResult[],
): HealthcheckResult {
return results.some((r) => r.status === 'IN_PROGRESS')
? {status: 'IN_PROGRESS'}
: results.every((r) => r.status === 'SUCCESS')
? {status: 'SUCCESS'}
: results.some((r) => r.status === 'FAILED' && !r.isAcknowledged)
? {status: 'FAILED', isAcknowledged: false}
: results.some((r) => r.status === 'FAILED')
? {status: 'FAILED', isAcknowledged: true}
: results.some((r) => r.status === 'WARNING' && !r.isAcknowledged)
? {status: 'WARNING', isAcknowledged: false}
: results.some((r) => r.status === 'WARNING')
? {status: 'WARNING', isAcknowledged: true}
: {status: 'SKIPPED'};
}
const updateCheckResult = produce(
(
draft: State,
{
categoryKey,
itemKey,
result,
}: {
categoryKey: string;
itemKey: string;
result: HealthcheckResult;
},
) => {
const category = draft.healthcheckReport.categories[categoryKey];
const item = category.checks[itemKey];
Object.assign(item.result, result);
item.result.isAcknowledged = draft.acknowledgedProblems.includes(item.key);
},
);
function createDict<T>(pairs: [string, T][]): Dictionary<T> {
const obj: Dictionary<T> = {};
for (const pair of pairs) {
obj[pair[0]] = pair[1];
}
return obj;
}
const start = produce((draft: State, healthchecks: Healthchecks) => {
draft.healthcheckReport = {
result: {status: 'IN_PROGRESS'},
categories: createDict<HealthcheckReportCategory>(
Object.entries(healthchecks).map(([categoryKey, category]) => {
if (category.isSkipped) {
return [
categoryKey,
{
key: categoryKey,
result: {
status: 'SKIPPED',
message: category.skipReason,
},
label: category.label,
checks: createDict<HealthcheckReportItem>([]),
},
];
}
return [
categoryKey,
{
key: categoryKey,
result: {status: 'IN_PROGRESS'},
label: category.label,
checks: createDict<HealthcheckReportItem>(
category.healthchecks.map((check) => [
check.key,
{
key: check.key,
result: {status: 'IN_PROGRESS'},
label: check.label,
},
]),
),
},
];
}),
),
};
});
const finish = produce((draft: State) => {
Object.values(draft.healthcheckReport.categories)
.filter((cat) => cat.result.status !== 'SKIPPED')
.forEach((cat) => {
cat.result.message = undefined;
cat.result = computeAggregatedResult(
Object.values(cat.checks).map((c) => c.result),
);
});
recomputeHealthcheckStatus(draft);
if (draft.healthcheckReport.result.status === 'SUCCESS') {
setAcknowledgedProblemsToEmpty(draft);
}
});
const acknowledge = produce((draft: State) => {
draft.acknowledgedProblems = ([] as string[]).concat(
...Object.values(draft.healthcheckReport.categories).map((cat) =>
Object.values(cat.checks)
.filter(
(chk) =>
chk.result.status === 'FAILED' || chk.result.status === 'WARNING',
)
.map((chk) => chk.key),
),
);
Object.values(draft.healthcheckReport.categories).forEach((cat) => {
cat.result.isAcknowledged = true;
Object.values(cat.checks).forEach((chk) => {
chk.result.isAcknowledged = true;
});
});
recomputeHealthcheckStatus(draft);
});
function setAcknowledgedProblemsToEmpty(draft: State) {
draft.acknowledgedProblems = [];
Object.values(draft.healthcheckReport.categories).forEach((cat) => {
cat.result.isAcknowledged = false;
Object.values(cat.checks).forEach((chk) => {
chk.result.isAcknowledged = false;
});
});
}
const resetAcknowledged = produce((draft: State) => {
setAcknowledgedProblemsToEmpty(draft);
recomputeHealthcheckStatus(draft);
});
export default function reducer(
draft: State | undefined = INITIAL_STATE,
action: Actions,
): State {
return action.type === 'START_HEALTHCHECKS'
? start(draft, action.payload)
: action.type === 'FINISH_HEALTHCHECKS'
? finish(draft)
: action.type === 'UPDATE_HEALTHCHECK_RESULT'
? updateCheckResult(draft, action.payload)
: action.type === 'ACKNOWLEDGE_PROBLEMS'
? acknowledge(draft)
: action.type === 'RESET_ACKNOWLEDGED_PROBLEMS'
? resetAcknowledged(draft)
: draft;
}
export const updateHealthcheckResult = (
categoryKey: string,
itemKey: string,
result: HealthcheckResult,
): Action => ({
type: 'UPDATE_HEALTHCHECK_RESULT',
payload: {
categoryKey,
itemKey,
result,
},
});
export const startHealthchecks = (healthchecks: Healthchecks): Action => ({
type: 'START_HEALTHCHECKS',
payload: healthchecks,
});
export const finishHealthchecks = (): Action => ({
type: 'FINISH_HEALTHCHECKS',
});
export const acknowledgeProblems = (): Action => ({
type: 'ACKNOWLEDGE_PROBLEMS',
});
export const resetAcknowledgedProblems = (): Action => ({
type: 'RESET_ACKNOWLEDGED_PROBLEMS',
});

View File

@@ -0,0 +1,209 @@
/**
* 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 {combineReducers, Dispatch} from 'redux';
import application, {
State as ApplicationState,
Action as ApplicationAction,
} from './application';
import connections, {
State as DevicesState,
Action as DevicesAction,
persistMigrations as devicesPersistMigrations,
persistVersion as devicesPersistVersion,
} from './connections';
import pluginMessageQueue, {
State as PluginMessageQueueState,
Action as PluginMessageQueueAction,
} from './pluginMessageQueue';
import notifications, {
State as NotificationsState,
Action as NotificationsAction,
} from './notifications';
import plugins, {
State as PluginsState,
Action as PluginsAction,
persistMigrations as pluginsPersistMigrations,
persistVersion as pluginsPersistVersion,
} from './plugins';
import supportForm, {
State as SupportFormState,
Action as SupportFormAction,
} from './supportForm';
import settings, {
Settings as SettingsState,
Action as SettingsAction,
} from './settings';
import launcherSettings, {
LauncherSettings as LauncherSettingsState,
Action as LauncherSettingsAction,
} from './launcherSettings';
import pluginManager, {
State as PluginManagerState,
Action as PluginManagerAction,
} from './pluginManager';
import healthchecks, {
Action as HealthcheckAction,
State as HealthcheckState,
} from './healthchecks';
import pluginDownloads, {
State as PluginDownloadsState,
Action as PluginDownloadsAction,
} from './pluginDownloads';
import usageTracking, {
Action as TrackingAction,
State as TrackingState,
} from './usageTracking';
import user, {State as UserState, Action as UserAction} from './user';
import JsonFileStorage from '../utils/jsonFileReduxPersistStorage';
import LauncherSettingsStorage from '../utils/launcherSettingsStorage';
import {launcherConfigDir} from '../utils/launcher';
import os from 'os';
import {resolve} from 'path';
import xdg from 'xdg-basedir';
import {createMigrate, createTransform, persistReducer} from 'redux-persist';
import {PersistPartial} from 'redux-persist/es/persistReducer';
import {Store as ReduxStore, MiddlewareAPI as ReduxMiddlewareAPI} from 'redux';
import storage from 'redux-persist/lib/storage';
import {TransformConfig} from 'redux-persist/es/createTransform';
export type Actions =
| ApplicationAction
| DevicesAction
| PluginMessageQueueAction
| NotificationsAction
| PluginsAction
| UserAction
| SettingsAction
| LauncherSettingsAction
| SupportFormAction
| PluginManagerAction
| HealthcheckAction
| TrackingAction
| PluginDownloadsAction
| {type: 'INIT'};
export type State = {
application: ApplicationState;
connections: DevicesState & PersistPartial;
pluginMessageQueue: PluginMessageQueueState;
notifications: NotificationsState & PersistPartial;
plugins: PluginsState & PersistPartial;
user: UserState & PersistPartial;
settingsState: SettingsState & PersistPartial;
launcherSettingsState: LauncherSettingsState & PersistPartial;
supportForm: SupportFormState;
pluginManager: PluginManagerState;
healthchecks: HealthcheckState & PersistPartial;
usageTracking: TrackingState;
pluginDownloads: PluginDownloadsState;
};
export type Store = ReduxStore<State, Actions>;
export type MiddlewareAPI = ReduxMiddlewareAPI<Dispatch<Actions>, State>;
const settingsStorage = new JsonFileStorage(
resolve(
...(xdg.config ? [xdg.config] : [os.homedir(), '.config']),
'flipper',
'settings.json',
),
);
const setTransformer = (config: TransformConfig) =>
createTransform(
(set: Set<string>) => Array.from(set),
(arrayString: string[]) => new Set(arrayString),
config,
);
const launcherSettingsStorage = new LauncherSettingsStorage(
resolve(launcherConfigDir(), 'flipper-launcher.toml'),
);
export function createRootReducer() {
return combineReducers<State, Actions>({
application,
connections: persistReducer<DevicesState, Actions>(
{
key: 'connections',
storage,
whitelist: [
'userPreferredDevice',
'userPreferredPlugin',
'userPreferredApp',
'enabledPlugins',
'enabledDevicePlugins',
],
transforms: [
setTransformer({
whitelist: ['enabledDevicePlugins', 'userStarredDevicePlugins'],
}),
],
version: devicesPersistVersion,
migrate: createMigrate(devicesPersistMigrations),
},
connections,
),
pluginMessageQueue: pluginMessageQueue as any,
notifications: persistReducer(
{
key: 'notifications',
storage,
whitelist: ['blacklistedPlugins', 'blacklistedCategories'],
},
notifications,
),
plugins: persistReducer<PluginsState, Actions>(
{
key: 'plugins',
storage,
whitelist: ['marketplacePlugins', 'uninstalledPluginNames'],
transforms: [setTransformer({whitelist: ['uninstalledPluginNames']})],
version: pluginsPersistVersion,
migrate: createMigrate(pluginsPersistMigrations),
},
plugins,
),
supportForm,
pluginManager,
user: persistReducer(
{
key: 'user',
storage,
},
user,
),
settingsState: persistReducer(
{key: 'settings', storage: settingsStorage},
settings,
),
launcherSettingsState: persistReducer(
{
key: 'launcherSettings',
storage: launcherSettingsStorage,
serialize: false,
// @ts-ignore: property is erroneously missing in redux-persist type definitions
deserialize: false,
},
launcherSettings,
),
healthchecks: persistReducer<HealthcheckState, Actions>(
{
key: 'healthchecks',
storage,
whitelist: ['acknowledgedProblems'],
},
healthchecks,
),
usageTracking,
pluginDownloads,
});
}

View File

@@ -0,0 +1,43 @@
/**
* 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 {Actions} from './index';
import ReleaseChannel from '../ReleaseChannel';
export type LauncherSettings = {
releaseChannel: ReleaseChannel;
ignoreLocalPin: boolean;
};
export type Action = {
type: 'UPDATE_LAUNCHER_SETTINGS';
payload: LauncherSettings;
};
export const defaultLauncherSettings: LauncherSettings = {
releaseChannel: ReleaseChannel.DEFAULT,
ignoreLocalPin: false,
};
export default function reducer(
state: LauncherSettings = defaultLauncherSettings,
action: Actions,
): LauncherSettings {
if (action.type === 'UPDATE_LAUNCHER_SETTINGS') {
return action.payload;
}
return state;
}
export function updateLauncherSettings(settings: LauncherSettings): Action {
return {
type: 'UPDATE_LAUNCHER_SETTINGS',
payload: settings,
};
}

View File

@@ -0,0 +1,259 @@
/**
* 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 {Notification} from 'flipper-plugin';
import {Actions} from './';
import React from 'react';
import {getStringFromErrorLike} from 'flipper-common';
export const GLOBAL_NOTIFICATION_PLUGIN_ID = 'Flipper';
export type PluginNotification = {
notification: Notification;
pluginId: string;
client: null | string; // id
};
export type PluginNotificationReference = {
notificationId: string;
pluginId: string;
client: null | string; // id
};
export type State = {
activeNotifications: Array<PluginNotification>;
invalidatedNotifications: Array<PluginNotification>;
blocklistedPlugins: Array<string>;
blocklistedCategories: Array<string>;
clearedNotifications: Set<string>;
};
type ActiveNotificationsAction = {
type: 'SET_ACTIVE_NOTIFICATIONS';
payload: {
notifications: Array<Notification>;
client: null | string;
pluginId: string;
};
};
export type Action =
| {
type: 'CLEAR_ALL_NOTIFICATIONS';
}
| {
type: 'SET_ACTIVE_NOTIFICATIONS';
payload: {
notifications: Array<Notification>;
client: null | string;
pluginId: string;
};
}
| {
type: 'UPDATE_PLUGIN_BLOCKLIST';
payload: Array<string>;
}
| {
type: 'UPDATE_CATEGORY_BLOCKLIST';
payload: Array<string>;
}
| {
type: 'ADD_NOTIFICATION';
payload: PluginNotification;
}
| {
type: 'REMOVE_NOTIFICATION';
payload: PluginNotificationReference;
};
const INITIAL_STATE: State = {
activeNotifications: [],
invalidatedNotifications: [],
blocklistedPlugins: [],
blocklistedCategories: [],
clearedNotifications: new Set(),
};
export default function reducer(
state: State = INITIAL_STATE,
action: Actions,
): State {
switch (action.type) {
case 'SET_ACTIVE_NOTIFICATIONS': {
return activeNotificationsReducer(state, action);
}
case 'CLEAR_ALL_NOTIFICATIONS':
const markAsCleared = ({
pluginId,
notification: {id},
}: PluginNotification) =>
state.clearedNotifications.add(`${pluginId}#${id}`);
state.activeNotifications.forEach(markAsCleared);
state.invalidatedNotifications.forEach(markAsCleared);
// Q: Should this actually delete them, or just invalidate them?
return {
...state,
activeNotifications: [],
invalidatedNotifications: [],
};
case 'UPDATE_PLUGIN_BLOCKLIST':
return {
...state,
blocklistedPlugins: action.payload,
};
case 'UPDATE_CATEGORY_BLOCKLIST':
return {
...state,
blocklistedCategories: action.payload,
};
case 'ADD_NOTIFICATION':
return {
...state,
// while adding notifications, remove old duplicates
activeNotifications: [
...state.activeNotifications.filter(
(notif) =>
notif.client !== action.payload.client ||
notif.pluginId !== action.payload.pluginId ||
notif.notification.id !== action.payload.notification.id,
),
action.payload,
],
};
case 'REMOVE_NOTIFICATION':
return {
...state,
activeNotifications: [
...state.activeNotifications.filter(
(notif) =>
notif.client !== action.payload.client ||
notif.pluginId !== action.payload.pluginId ||
notif.notification.id !== action.payload.notificationId,
),
],
};
default:
return state;
}
}
function activeNotificationsReducer(
state: State,
action: ActiveNotificationsAction,
): State {
const {payload} = action;
const newActiveNotifications = [];
const newInactivatedNotifications = state.invalidatedNotifications.slice();
const newIDs = new Set(payload.notifications.map((n: Notification) => n.id));
for (const activeNotification of state.activeNotifications) {
if (activeNotification.pluginId !== payload.pluginId) {
newActiveNotifications.push(activeNotification);
continue;
}
if (!newIDs.has(activeNotification.notification.id)) {
newInactivatedNotifications.push(activeNotification);
}
}
payload.notifications
.filter(
({id}: Notification) =>
!state.clearedNotifications.has(`${payload.pluginId}#${id}`),
)
.forEach((notification: Notification) => {
newActiveNotifications.push({
pluginId: payload.pluginId,
client: payload.client,
notification,
});
});
return {
...state,
activeNotifications: newActiveNotifications,
invalidatedNotifications: newInactivatedNotifications,
};
}
export function addNotification(payload: PluginNotification): Action {
return {
type: 'ADD_NOTIFICATION',
payload,
};
}
export function removeNotification(
payload: PluginNotificationReference,
): Action {
return {
type: 'REMOVE_NOTIFICATION',
payload,
};
}
export function addErrorNotification(
title: string,
message: string | React.ReactNode,
error?: any,
): Action {
// TODO: use this method for https://github.com/facebook/flipper/pull/1478/files as well
console.warn(title, message, error);
return addNotification({
client: null,
pluginId: GLOBAL_NOTIFICATION_PLUGIN_ID,
notification: {
id: title,
title,
message: error ? (
<>
<p>{message}</p>
<p>{getStringFromErrorLike(error)}</p>
</>
) : (
message
),
severity: 'error',
},
});
}
export function setActiveNotifications(payload: {
notifications: Array<Notification>;
client: null | string;
pluginId: string;
}): Action {
return {
type: 'SET_ACTIVE_NOTIFICATIONS',
payload,
};
}
export function clearAllNotifications(): Action {
return {
type: 'CLEAR_ALL_NOTIFICATIONS',
};
}
export function updatePluginBlocklist(payload: Array<string>): Action {
return {
type: 'UPDATE_PLUGIN_BLOCKLIST',
payload,
};
}
export function updateCategoryBlocklist(payload: Array<string>): Action {
return {
type: 'UPDATE_CATEGORY_BLOCKLIST',
payload,
};
}

View File

@@ -0,0 +1,146 @@
/**
* 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,
getPluginVersionInstallationDir,
} from 'flipper-plugin-lib';
import {Actions} from '.';
import produce from 'immer';
import {Canceler} from 'axios';
export enum PluginDownloadStatus {
QUEUED = 'Queued',
STARTED = 'Started',
FAILED = 'Failed',
}
export type DownloadablePluginState = {
plugin: DownloadablePluginDetails;
startedByUser: boolean;
} & (
| {status: PluginDownloadStatus.QUEUED}
| {status: PluginDownloadStatus.STARTED; cancel: Canceler}
);
// We use plugin installation path as key as it is unique for each plugin version.
export type State = Record<string, DownloadablePluginState>;
export type PluginDownloadStart = {
type: 'PLUGIN_DOWNLOAD_START';
payload: {
plugin: DownloadablePluginDetails;
startedByUser: boolean;
};
};
export type PluginDownloadStarted = {
type: 'PLUGIN_DOWNLOAD_STARTED';
payload: {
plugin: DownloadablePluginDetails;
cancel: Canceler;
};
};
export type PluginDownloadFinished = {
type: 'PLUGIN_DOWNLOAD_FINISHED';
payload: {
plugin: DownloadablePluginDetails;
};
};
export type Action =
| PluginDownloadStart
| PluginDownloadStarted
| PluginDownloadFinished;
const INITIAL_STATE: State = {};
export default function reducer(
state: State = INITIAL_STATE,
action: Actions,
): State {
switch (action.type) {
case 'PLUGIN_DOWNLOAD_START': {
const {plugin, startedByUser} = action.payload;
const installationDir = getPluginVersionInstallationDir(
plugin.name,
plugin.version,
);
const downloadState = state[installationDir];
if (downloadState) {
// If download is already in progress - re-use the existing state.
return produce(state, (draft) => {
draft[installationDir] = {
...downloadState,
startedByUser: startedByUser || downloadState.startedByUser,
};
});
}
return produce(state, (draft) => {
draft[installationDir] = {
plugin,
startedByUser: startedByUser,
status: PluginDownloadStatus.QUEUED,
};
});
}
case 'PLUGIN_DOWNLOAD_STARTED': {
const {plugin, cancel} = action.payload;
const installationDir = getPluginVersionInstallationDir(
plugin.name,
plugin.version,
);
const downloadState = state[installationDir];
if (downloadState?.status !== PluginDownloadStatus.QUEUED) {
console.warn(
`Invalid state transition PLUGIN_DOWNLOAD_STARTED in status ${downloadState?.status} for download to directory ${installationDir}.`,
);
return state;
}
return produce(state, (draft) => {
draft[installationDir] = {
status: PluginDownloadStatus.STARTED,
plugin,
startedByUser: downloadState.startedByUser,
cancel,
};
});
}
case 'PLUGIN_DOWNLOAD_FINISHED': {
const {plugin} = action.payload;
const installationDir = getPluginVersionInstallationDir(
plugin.name,
plugin.version,
);
return produce(state, (draft) => {
delete draft[installationDir];
});
}
default:
return state;
}
}
export const startPluginDownload = (payload: {
plugin: DownloadablePluginDetails;
startedByUser: boolean;
}): Action => ({
type: 'PLUGIN_DOWNLOAD_START',
payload,
});
export const pluginDownloadStarted = (payload: {
plugin: DownloadablePluginDetails;
cancel: Canceler;
}): Action => ({type: 'PLUGIN_DOWNLOAD_STARTED', payload});
export const pluginDownloadFinished = (payload: {
plugin: DownloadablePluginDetails;
}): Action => ({type: 'PLUGIN_DOWNLOAD_FINISHED', payload});

View File

@@ -0,0 +1,124 @@
/**
* 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 {Actions} from './';
import type {ActivatablePluginDetails} from 'flipper-plugin-lib';
import type {PluginDefinition} from '../plugin';
import {produce} from 'immer';
export type State = {
pluginCommandsQueue: PluginCommand[];
};
export type PluginCommand =
| LoadPluginAction
| UninstallPluginAction
| UpdatePluginAction
| SwitchPluginAction;
export type LoadPluginActionPayload = {
plugin: ActivatablePluginDetails;
enable: boolean;
notifyIfFailed: boolean;
};
export type LoadPluginAction = {
type: 'LOAD_PLUGIN';
payload: LoadPluginActionPayload;
};
export type UninstallPluginActionPayload = {
plugin: PluginDefinition;
};
export type UninstallPluginAction = {
type: 'UNINSTALL_PLUGIN';
payload: UninstallPluginActionPayload;
};
export type UpdatePluginActionPayload = {
plugin: PluginDefinition;
enablePlugin: boolean;
};
export type UpdatePluginAction = {
type: 'UPDATE_PLUGIN';
payload: UpdatePluginActionPayload;
};
export type SwitchPluginActionPayload = {
plugin: PluginDefinition;
selectedApp?: string;
};
export type SwitchPluginAction = {
type: 'SWITCH_PLUGIN';
payload: SwitchPluginActionPayload;
};
export type Action =
| {
type: 'PLUGIN_COMMANDS_PROCESSED';
payload: number;
}
| PluginCommand;
const INITIAL_STATE: State = {
pluginCommandsQueue: [],
};
export default function reducer(
state: State = INITIAL_STATE,
action: Actions,
): State {
switch (action.type) {
case 'LOAD_PLUGIN':
case 'UNINSTALL_PLUGIN':
case 'UPDATE_PLUGIN':
case 'SWITCH_PLUGIN':
return produce(state, (draft) => {
draft.pluginCommandsQueue.push(action);
});
case 'PLUGIN_COMMANDS_PROCESSED':
return produce(state, (draft) => {
draft.pluginCommandsQueue.splice(0, action.payload);
});
default:
return state;
}
}
export const uninstallPlugin = (
payload: UninstallPluginActionPayload,
): Action => ({
type: 'UNINSTALL_PLUGIN',
payload,
});
export const loadPlugin = (payload: LoadPluginActionPayload): Action => ({
type: 'LOAD_PLUGIN',
payload,
});
export const pluginCommandsProcessed = (payload: number): Action => ({
type: 'PLUGIN_COMMANDS_PROCESSED',
payload,
});
export const registerPluginUpdate = (
payload: UpdatePluginActionPayload,
): Action => ({
type: 'UPDATE_PLUGIN',
payload,
});
export const switchPlugin = (payload: SwitchPluginActionPayload): Action => ({
type: 'SWITCH_PLUGIN',
payload,
});

View File

@@ -0,0 +1,141 @@
/**
* 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 produce from 'immer';
import {deconstructPluginKey} from 'flipper-common';
export const DEFAULT_MAX_QUEUE_SIZE = 10000;
export type Message = {
method: string;
params?: any;
};
export type State = {
[pluginKey: string]: Message[];
};
export type Action =
| {
type: 'QUEUE_MESSAGES';
payload: {
pluginKey: string; // client + plugin
maxQueueSize: number;
messages: Message[];
};
}
| {
type: 'CLEAR_MESSAGE_QUEUE';
payload: {
pluginKey: string; // client + plugin
amount?: number;
};
}
| {
type: 'CLEAR_CLIENT_PLUGINS_STATE';
payload: {clientId: string; devicePlugins: Set<string>};
}
| {
type: 'CLEAR_PLUGIN_STATE';
payload: {pluginId: string};
};
const INITIAL_STATE: State = {};
export default function reducer(
state: State | undefined = INITIAL_STATE,
action: Action,
): State {
switch (action.type) {
case 'QUEUE_MESSAGES': {
const {pluginKey, messages, maxQueueSize} = action.payload;
// this is hit very often, so try to do it a bit optimal
const currentMessages = state[pluginKey] || [];
let newMessages = currentMessages.concat(messages);
if (newMessages.length > maxQueueSize) {
// only keep last 90% of max queue size
newMessages = newMessages.slice(
newMessages.length - 1 - Math.ceil(maxQueueSize * 0.9),
);
}
return {
...state,
[pluginKey]: newMessages,
};
}
case 'CLEAR_MESSAGE_QUEUE': {
const {pluginKey, amount} = action.payload;
return produce(state, (draft) => {
const messages = draft[pluginKey];
if (messages) {
if (amount === undefined) {
delete draft[pluginKey];
} else {
messages.splice(0, amount);
}
}
});
}
case 'CLEAR_CLIENT_PLUGINS_STATE': {
const {payload} = action;
return Object.keys(state).reduce((newState: State, pluginKey) => {
// Only add the pluginState, if its from a plugin other than the one that
// was removed. pluginKeys are in the form of ${clientID}#${pluginID}.
const plugin = deconstructPluginKey(pluginKey);
const clientId = plugin.client;
const pluginId = plugin.pluginName;
if (
clientId !== payload.clientId ||
(pluginId && payload.devicePlugins.has(pluginId))
) {
newState[pluginKey] = state[pluginKey];
}
return newState;
}, {});
}
case 'CLEAR_PLUGIN_STATE': {
const {pluginId} = action.payload;
return produce(state, (draft) => {
Object.keys(draft).forEach((pluginKey) => {
const pluginKeyParts = deconstructPluginKey(pluginKey);
if (pluginKeyParts.pluginName === pluginId) {
delete draft[pluginKey];
}
});
});
}
default:
return state;
}
}
export const queueMessages = (
pluginKey: string,
messages: Message[],
maxQueueSize: number | undefined,
): Action => ({
type: 'QUEUE_MESSAGES',
payload: {
pluginKey,
messages,
maxQueueSize: maxQueueSize || DEFAULT_MAX_QUEUE_SIZE,
},
});
export const clearMessageQueue = (
pluginKey: string,
amount?: number,
): Action => ({
type: 'CLEAR_MESSAGE_QUEUE',
payload: {pluginKey, amount},
});

View File

@@ -0,0 +1,309 @@
/**
* 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 {
DevicePluginMap,
ClientPluginMap,
PluginDefinition,
} from '../plugin';
import type {
DownloadablePluginDetails,
ActivatablePluginDetails,
BundledPluginDetails,
InstalledPluginDetails,
} from 'flipper-plugin-lib';
import type {Actions} from '.';
import produce from 'immer';
import {isDevicePluginDefinition} from '../utils/pluginUtils';
import semver from 'semver';
export interface MarketplacePluginDetails extends DownloadablePluginDetails {
availableVersions?: DownloadablePluginDetails[];
}
export type State = StateV1;
type StateV1 = {
devicePlugins: DevicePluginMap;
clientPlugins: ClientPluginMap;
loadedPlugins: Map<string, ActivatablePluginDetails>;
bundledPlugins: Map<string, BundledPluginDetails>;
gatekeepedPlugins: Array<ActivatablePluginDetails>;
disabledPlugins: Array<ActivatablePluginDetails>;
failedPlugins: Array<[ActivatablePluginDetails, string]>;
selectedPlugins: Array<string>;
marketplacePlugins: Array<MarketplacePluginDetails>;
uninstalledPluginNames: Set<string>;
installedPlugins: Map<string, InstalledPluginDetails>;
initialized: boolean;
};
type StateV0 = Omit<StateV1, 'uninstalledPluginNames'> & {
uninstalledPlugins: Set<string>;
};
export const persistVersion = 1;
export const persistMigrations = {
1: (state: any) => {
const stateV0 = state as StateV0;
const stateV1: StateV1 = {
...stateV0,
uninstalledPluginNames: new Set(stateV0.uninstalledPlugins),
};
return stateV1 as any;
},
};
export type RegisterPluginAction = {
type: 'REGISTER_PLUGINS';
payload: PluginDefinition[];
};
export type Action =
| RegisterPluginAction
| {
type: 'GATEKEEPED_PLUGINS';
payload: Array<ActivatablePluginDetails>;
}
| {
type: 'DISABLED_PLUGINS';
payload: Array<ActivatablePluginDetails>;
}
| {
type: 'FAILED_PLUGINS';
payload: Array<[ActivatablePluginDetails, string]>;
}
| {
type: 'SELECTED_PLUGINS';
payload: Array<string>;
}
| {
type: 'MARKETPLACE_PLUGINS';
payload: Array<DownloadablePluginDetails>;
}
| {
type: 'REGISTER_LOADED_PLUGINS';
payload: Array<ActivatablePluginDetails>;
}
| {
type: 'REGISTER_BUNDLED_PLUGINS';
payload: Array<BundledPluginDetails>;
}
| {
type: 'REGISTER_INSTALLED_PLUGINS';
payload: InstalledPluginDetails[];
}
| {
type: 'PLUGIN_INSTALLED';
payload: InstalledPluginDetails;
}
| {
type: 'PLUGIN_UNINSTALLED';
payload: ActivatablePluginDetails;
}
| {
type: 'PLUGIN_LOADED';
payload: PluginDefinition;
}
| {
type: 'PLUGINS_INITIALIZED';
};
const INITIAL_STATE: State = {
devicePlugins: new Map(),
clientPlugins: new Map(),
loadedPlugins: new Map(),
bundledPlugins: new Map(),
gatekeepedPlugins: [],
disabledPlugins: [],
failedPlugins: [],
selectedPlugins: [],
marketplacePlugins: [],
uninstalledPluginNames: new Set(),
installedPlugins: new Map(),
initialized: false,
};
export default function reducer(
state: State | undefined = INITIAL_STATE,
action: Actions,
): State {
if (action.type === 'REGISTER_PLUGINS') {
return produce(state, (draft) => {
const {devicePlugins, clientPlugins} = draft;
action.payload.forEach((p) => {
if (devicePlugins.has(p.id) || clientPlugins.has(p.id)) {
return;
}
if (isDevicePluginDefinition(p)) {
devicePlugins.set(p.id, p);
} else {
clientPlugins.set(p.id, p);
}
});
});
} else if (action.type === 'GATEKEEPED_PLUGINS') {
return {
...state,
gatekeepedPlugins: state.gatekeepedPlugins.concat(action.payload),
};
} else if (action.type === 'DISABLED_PLUGINS') {
return {
...state,
disabledPlugins: state.disabledPlugins.concat(action.payload),
};
} else if (action.type === 'FAILED_PLUGINS') {
return {
...state,
failedPlugins: state.failedPlugins.concat(action.payload),
};
} else if (action.type === 'SELECTED_PLUGINS') {
return {
...state,
selectedPlugins: action.payload,
};
} else if (action.type === 'MARKETPLACE_PLUGINS') {
return {
...state,
marketplacePlugins: action.payload,
};
} else if (action.type === 'REGISTER_LOADED_PLUGINS') {
return {
...state,
loadedPlugins: new Map(action.payload.map((p) => [p.id, p])),
};
} else if (action.type === 'REGISTER_BUNDLED_PLUGINS') {
return {
...state,
bundledPlugins: new Map(action.payload.map((p) => [p.id, p])),
};
} else if (action.type === 'REGISTER_INSTALLED_PLUGINS') {
return produce(state, (draft) => {
draft.installedPlugins.clear();
action.payload.forEach((p) => {
if (!draft.uninstalledPluginNames.has(p.name)) {
draft.installedPlugins.set(p.id, p);
}
});
});
} else if (action.type === 'PLUGIN_INSTALLED') {
const plugin = action.payload;
return produce(state, (draft) => {
const existing = draft.installedPlugins.get(plugin.name);
if (!existing || semver.gt(plugin.version, existing.version)) {
draft.installedPlugins.set(plugin.name, plugin);
}
});
} else if (action.type === 'PLUGIN_UNINSTALLED') {
const plugin = action.payload;
return produce(state, (draft) => {
draft.clientPlugins.delete(plugin.id);
draft.devicePlugins.delete(plugin.id);
draft.loadedPlugins.delete(plugin.id);
draft.uninstalledPluginNames.add(plugin.name);
});
} else if (action.type === 'PLUGIN_LOADED') {
const plugin = action.payload;
return produce(state, (draft) => {
if (isDevicePluginDefinition(plugin)) {
draft.devicePlugins.set(plugin.id, plugin);
} else {
draft.clientPlugins.set(plugin.id, plugin);
}
draft.uninstalledPluginNames.delete(plugin.details.name);
draft.loadedPlugins.set(plugin.id, plugin.details);
});
} else if (action.type === 'PLUGINS_INITIALIZED') {
return produce(state, (draft) => {
draft.initialized = true;
});
} else {
return state;
}
}
export const selectedPlugins = (payload: Array<string>): Action => ({
type: 'SELECTED_PLUGINS',
payload,
});
export const registerPlugins = (payload: PluginDefinition[]): Action => ({
type: 'REGISTER_PLUGINS',
payload,
});
export const addGatekeepedPlugins = (
payload: Array<ActivatablePluginDetails>,
): Action => ({
type: 'GATEKEEPED_PLUGINS',
payload,
});
export const addDisabledPlugins = (
payload: Array<ActivatablePluginDetails>,
): Action => ({
type: 'DISABLED_PLUGINS',
payload,
});
export const addFailedPlugins = (
payload: Array<[ActivatablePluginDetails, string]>,
): Action => ({
type: 'FAILED_PLUGINS',
payload,
});
export const registerMarketplacePlugins = (
payload: Array<DownloadablePluginDetails>,
): Action => ({
type: 'MARKETPLACE_PLUGINS',
payload,
});
export const registerLoadedPlugins = (
payload: Array<ActivatablePluginDetails>,
): Action => ({
type: 'REGISTER_LOADED_PLUGINS',
payload,
});
export const registerBundledPlugins = (
payload: Array<BundledPluginDetails>,
): Action => ({
type: 'REGISTER_BUNDLED_PLUGINS',
payload,
});
export const registerInstalledPlugins = (
payload: InstalledPluginDetails[],
): Action => ({
type: 'REGISTER_INSTALLED_PLUGINS',
payload,
});
export const pluginInstalled = (payload: InstalledPluginDetails): Action => ({
type: 'PLUGIN_INSTALLED',
payload,
});
export const pluginUninstalled = (
payload: ActivatablePluginDetails,
): Action => ({
type: 'PLUGIN_UNINSTALLED',
payload,
});
export const pluginLoaded = (payload: PluginDefinition): Action => ({
type: 'PLUGIN_LOADED',
payload,
});
export const pluginsInitialized = (): Action => ({
type: 'PLUGINS_INITIALIZED',
});

View File

@@ -0,0 +1,111 @@
/**
* 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 {Actions} from './index';
import os from 'os';
import {getRenderHostInstance} from '../RenderHost';
export enum Tristate {
True,
False,
Unset,
}
export type Settings = {
androidHome: string;
enableAndroid: boolean;
enableIOS: boolean;
enablePhysicalIOS: boolean;
/**
* If unset, this will assume the value of the GK setting.
* Note that this setting has no effect in the open source version
* of Flipper.
*/
enablePrefetching: Tristate;
idbPath: string;
jsApps: {
webAppLauncher: {
url: string;
height: number;
width: number;
};
};
reactNative: {
shortcuts: {
enabled: boolean;
reload: string;
openDevMenu: string;
};
};
darkMode: 'dark' | 'light' | 'system';
showWelcomeAtStartup: boolean;
suppressPluginErrors: boolean;
};
export type Action =
| {type: 'INIT'}
| {
type: 'UPDATE_SETTINGS';
payload: Settings;
};
export const DEFAULT_ANDROID_SDK_PATH = getDefaultAndroidSdkPath();
const initialState: Settings = {
androidHome: getDefaultAndroidSdkPath(),
enableAndroid: true,
enableIOS: os.platform() === 'darwin',
enablePhysicalIOS: os.platform() === 'darwin',
enablePrefetching: Tristate.Unset,
idbPath: '/usr/local/bin/idb',
jsApps: {
webAppLauncher: {
url: 'http://localhost:8888',
height: 600,
width: 800,
},
},
reactNative: {
shortcuts: {
enabled: false,
reload: 'Alt+Shift+R',
openDevMenu: 'Alt+Shift+D',
},
},
darkMode: 'light',
showWelcomeAtStartup: true,
suppressPluginErrors: false,
};
export default function reducer(
state: Settings = initialState,
action: Actions,
): Settings {
if (action.type === 'UPDATE_SETTINGS') {
return action.payload;
}
return state;
}
export function updateSettings(settings: Settings): Action {
return {
type: 'UPDATE_SETTINGS',
payload: settings,
};
}
function getDefaultAndroidSdkPath() {
return os.platform() === 'win32' ? getWindowsSdkPath() : '/opt/android_sdk';
}
function getWindowsSdkPath() {
return `${
getRenderHostInstance().paths.homePath
}\\AppData\\Local\\android\\sdk`;
}

View File

@@ -0,0 +1,367 @@
/**
* 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 {Actions, Store} from './';
import {setStaticView} from './connections';
import {deconstructClientId} from 'flipper-common';
import {switchPlugin} from './pluginManager';
import {showStatusUpdatesForDuration} from '../utils/promiseTimeout';
import {selectedPlugins as setSelectedPlugins} from './plugins';
import {addStatusMessage, removeStatusMessage} from './application';
import constants from '../fb-stubs/constants';
import {getLogger} from 'flipper-common';
import {logPlatformSuccessRate} from 'flipper-common';
export const SUPPORT_FORM_PREFIX = 'support-form-v2';
import {getExportablePlugins} from '../selectors/connections';
import {DeviceOS} from 'flipper-plugin';
const {DEFAULT_SUPPORT_GROUP} = constants;
type SubmediaType =
| {uploadID: string; status: 'Uploaded'}
| {status: 'NotUploaded' | 'Uploading'};
type MediaObject = SubmediaType & {
description: string;
path: string;
};
export type GroupValidationErrors = {
plugins: string | null;
os: string | null;
};
export class Group {
constructor(
name: string,
workplaceGroupID: number,
requiredPlugins: Array<string>,
defaultPlugins: Array<string>,
supportedOS: Array<DeviceOS>,
deeplinkSuffix: string,
papercuts?: string,
) {
this.name = name;
this.requiredPlugins = requiredPlugins;
this.defaultPlugins = defaultPlugins;
this.workplaceGroupID = workplaceGroupID;
this.supportedOS = supportedOS;
this.deeplinkSuffix = deeplinkSuffix;
this.papercuts = papercuts;
}
readonly name: string;
requiredPlugins: Array<string>;
defaultPlugins: Array<string>;
workplaceGroupID: number;
supportedOS: Array<DeviceOS>;
deeplinkSuffix: string;
papercuts?: string;
getPluginsToSelect(): Array<string> {
return Array.from(
new Set([...this.defaultPlugins, ...this.requiredPlugins]),
);
}
getValidationMessage(
selectedPlugins: Array<string>,
selectedOS: DeviceOS | null,
): GroupValidationErrors {
const nonSelectedPlugin: Array<string> = [];
for (const plugin of this.requiredPlugins) {
if (!selectedPlugins.includes(plugin)) {
nonSelectedPlugin.push(plugin);
}
}
// Plugin validation
let str: string | null =
'should be exported if you want to submit to this group. Make sure, if your selected app supports those plugins, if so then enable it and select it from the plugin selection.';
if (nonSelectedPlugin.length == 1) {
str = `the ${nonSelectedPlugin.pop()} plugin ${str}`;
} else if (nonSelectedPlugin.length > 1) {
const lastPlugin = nonSelectedPlugin.pop();
str = `the ${nonSelectedPlugin.join(',')} and ${lastPlugin} ${str}`;
} else {
// nonSelectedPlugin is empty
str = null;
}
// OS validation
let osError: string | null = null;
if (this.name !== 'Flipper') {
if (!selectedOS) {
osError = 'Please select an app from the drop down.';
} else if (!this.supportedOS.includes(selectedOS)) {
osError = `The group ${
this.name
} supports exports from ${this.supportedOS.join(
', ',
)}. But your selected device's OS is ${selectedOS}, which is unsupported.`;
}
}
return {plugins: str, os: osError};
}
handleSupportFormDeeplinks(store: Store) {
getLogger().track('usage', 'support-form-source', {
source: 'deeplink',
group: this.name,
});
store.dispatch(
setStaticView(require('../fb-stubs/SupportRequestFormV2').default),
);
const selectedAppId = store.getState().connections.selectedAppId;
const selectedClient = store
.getState()
.connections.clients.get(selectedAppId!);
let errorMessage: string | undefined = undefined;
if (selectedAppId) {
const {app} = deconstructClientId(selectedAppId);
const enabledPlugins: Array<string> | null =
store.getState().connections.enabledPlugins[app];
const unsupportedPlugins = [];
for (const requiredPlugin of this.requiredPlugins) {
const requiredPluginEnabled =
enabledPlugins != null && enabledPlugins.includes(requiredPlugin);
if (
selectedClient &&
selectedClient.plugins.has(requiredPlugin) &&
!requiredPluginEnabled
) {
const plugin =
store.getState().plugins.clientPlugins.get(requiredPlugin) ||
store.getState().plugins.devicePlugins.get(requiredPlugin)!;
store.dispatch(
switchPlugin({
selectedApp: app,
plugin,
}),
);
} else if (
!selectedClient ||
!selectedClient.plugins.has(requiredPlugin)
) {
unsupportedPlugins.push(requiredPlugin);
}
}
if (unsupportedPlugins.length > 0) {
errorMessage = `The current client does not support ${unsupportedPlugins.join(
', ',
)}. Please change the app from the dropdown in the support form.`;
logPlatformSuccessRate(`${SUPPORT_FORM_PREFIX}-deeplink`, {
kind: 'failure',
supportedOperation: true,
error: errorMessage,
});
showStatusUpdatesForDuration(
errorMessage,
'Deeplink',
10000,
(payload) => {
store.dispatch(addStatusMessage(payload));
},
(payload) => {
store.dispatch(removeStatusMessage(payload));
},
);
}
} else {
errorMessage =
'Selected app is null, thus the deeplink failed to enable required plugin.';
showStatusUpdatesForDuration(
'Please select an app and the device from the dropdown.',
'Deeplink',
10000,
(payload) => {
store.dispatch(addStatusMessage(payload));
},
(payload) => {
store.dispatch(removeStatusMessage(payload));
},
);
}
store.dispatch(
setSupportFormV2State({
...store.getState().supportForm.supportFormV2,
selectedGroup: this,
}),
);
const pluginsList = getExportablePlugins(store.getState());
store.dispatch(
setSelectedPlugins(
this.getPluginsToSelect().filter((s) => {
return pluginsList.map((s) => s.id).includes(s);
}),
),
);
logPlatformSuccessRate(
`${SUPPORT_FORM_PREFIX}-deeplink`,
errorMessage
? {
kind: 'failure',
supportedOperation: true,
error: errorMessage,
}
: {kind: 'success'},
);
}
getWarningMessage(
state: Parameters<typeof getExportablePlugins>[0],
): string | null {
const activePersistentPlugins = getExportablePlugins(state);
const emptyPlugins: Array<string> = [];
for (const plugin of this.requiredPlugins) {
if (
!activePersistentPlugins.find((o) => {
return o.id === plugin;
})
) {
emptyPlugins.push(plugin);
}
}
const commonStr = 'Are you sure you want to submit?';
if (emptyPlugins.length == 1) {
return `There is no data in ${emptyPlugins.pop()} plugin. ${commonStr}`;
} else if (emptyPlugins.length > 1) {
return `The following plugins have no data: ${emptyPlugins.join(
',',
)}. ${commonStr}`;
}
return null;
}
}
export const DEFAULT_GROUP = new Group(
DEFAULT_SUPPORT_GROUP.name,
DEFAULT_SUPPORT_GROUP.workplaceGroupID,
DEFAULT_SUPPORT_GROUP.requiredPlugins,
DEFAULT_SUPPORT_GROUP.defaultPlugins,
DEFAULT_SUPPORT_GROUP.supportedOS,
DEFAULT_SUPPORT_GROUP.deeplinkSuffix,
DEFAULT_SUPPORT_GROUP.papercuts,
);
export const SUPPORTED_GROUPS: Array<Group> = [
DEFAULT_GROUP,
...constants.SUPPORT_GROUPS.map(
({
name,
workplaceGroupID,
requiredPlugins,
defaultPlugins,
supportedOS,
deeplinkSuffix,
papercuts,
}) => {
return new Group(
name,
workplaceGroupID,
requiredPlugins,
defaultPlugins,
supportedOS,
deeplinkSuffix,
papercuts,
);
},
),
];
export type MediaType = Array<MediaObject>;
export type SupportFormV2State = {
title: string;
description: string;
commitHash: string;
screenshots?: MediaType;
videos?: MediaType;
selectedGroup: Group;
};
export type SupportFormRequestDetailsState = SupportFormV2State & {
appName: string;
};
export type State = {
webState: NTUsersFormData | null;
supportFormV2: SupportFormV2State;
};
export type Action =
| {
type: 'SET_SUPPORT_FORM_STATE';
payload: NTUsersFormData | null;
}
| {
type: 'SET_SUPPORT_FORM_V2_STATE';
payload: SupportFormV2State;
}
| {
type: 'RESET_SUPPORT_FORM_V2_STATE';
};
export type NTUsersFormData = {
flipper_trace: string | null;
};
export const initialState: () => State = () => ({
webState: null,
supportFormV2: {
title: '',
description: [
'## Context',
'What are you trying to accomplish at a high level? Feel free to include mocks and tasks.',
'',
'## Problem',
"What's blocking you?",
'',
"## Workarounds I've Tried",
'',
].join('\n'),
commitHash: '',
appName: '',
selectedGroup: DEFAULT_GROUP,
},
});
export default function reducer(
state: State | undefined,
action: Actions,
): State {
state = state || initialState();
if (action.type === 'SET_SUPPORT_FORM_STATE') {
return {
...state,
webState: action.payload,
};
} else if (action.type === 'SET_SUPPORT_FORM_V2_STATE') {
return {
...state,
supportFormV2: action.payload,
};
} else if (action.type === 'RESET_SUPPORT_FORM_V2_STATE') {
return initialState();
} else {
return state;
}
}
export const setSupportFormState = (
payload: NTUsersFormData | null,
): Action => ({
type: 'SET_SUPPORT_FORM_STATE',
payload,
});
export const setSupportFormV2State = (payload: SupportFormV2State): Action => ({
type: 'SET_SUPPORT_FORM_V2_STATE',
payload,
});
export const resetSupportFormV2State = (): Action => ({
type: 'RESET_SUPPORT_FORM_V2_STATE',
});

View File

@@ -0,0 +1,108 @@
/**
* 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 {produce} from 'immer';
import {Actions} from './';
import {SelectionInfo} from '../utils/info';
import {getRenderHostInstance} from '../RenderHost';
export type TrackingEvent =
| {
type: 'WINDOW_FOCUS_CHANGE';
time: number;
isFocused: boolean;
}
| {
type: 'SELECTION_CHANGED';
selectionKey: string | null;
selection: SelectionInfo | null;
time: number;
}
| {type: 'TIMELINE_START'; time: number; isFocused: boolean};
export type State = {
timeline: TrackingEvent[];
};
const INITAL_STATE: () => State = () => ({
timeline: [
{
type: 'TIMELINE_START',
time: Date.now(),
isFocused: getRenderHostInstance().hasFocus(),
},
],
});
export type Action =
| {
type: 'windowIsFocused';
payload: {isFocused: boolean; time: number};
}
| {type: 'CLEAR_TIMELINE'; payload: {time: number; isFocused: boolean}}
| {
type: 'SELECTION_CHANGED';
payload: {selection: SelectionInfo; time: number};
};
export default function reducer(
state: State = INITAL_STATE(),
action: Actions,
): State {
if (action.type === 'CLEAR_TIMELINE') {
return {
...state,
timeline: [
{
type: 'TIMELINE_START',
time: action.payload.time,
isFocused: action.payload.isFocused,
},
],
};
} else if (action.type === 'windowIsFocused') {
return produce(state, (draft) => {
draft.timeline.push({
type: 'WINDOW_FOCUS_CHANGE',
time: action.payload.time,
isFocused: action.payload.isFocused,
});
});
} else if (action.type === 'SELECTION_CHANGED') {
const {selection, time} = action.payload;
return produce(state, (draft) => {
draft.timeline.push({
type: 'SELECTION_CHANGED',
time,
selectionKey: selection?.plugin ? JSON.stringify(selection) : null,
selection,
});
});
}
return state;
}
export function clearTimeline(time: number): Action {
return {
type: 'CLEAR_TIMELINE',
payload: {
time,
isFocused: getRenderHostInstance().hasFocus(),
},
};
}
export function selectionChanged(payload: {
selection: SelectionInfo;
time: number;
}): Action {
return {
type: 'SELECTION_CHANGED',
payload,
};
}

View File

@@ -0,0 +1,56 @@
/**
* 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 {Actions} from './';
export type User = {
id?: string;
name?: string;
profile_picture?: {
uri: string;
};
};
export type State = User;
export type Action =
| {
type: 'LOGIN';
payload: User;
}
| {
type: 'LOGOUT';
};
const INITIAL_STATE: State = {};
export default function reducer(
state: State = INITIAL_STATE,
action: Actions,
): State {
if (action.type === 'LOGOUT') {
return {};
} else if (action.type === 'LOGIN') {
return {
...state,
...action.payload,
};
} else {
return state;
}
}
export const login = (payload: User): Action => ({
type: 'LOGIN',
payload,
});
export const logout = (): Action => ({
type: 'LOGOUT',
});