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,123 @@
/**
* 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 {CancelledPromiseError} from 'flipper-common';
import {Idler, sleep} from 'flipper-plugin';
export class IdlerImpl implements Idler {
private lastIdle = performance.now();
private kill = false;
constructor(private interval = 16) {}
shouldIdle(): boolean {
return this.kill || performance.now() - this.lastIdle > this.interval;
}
async idle(): Promise<void> {
if (this.kill) {
throw new CancelledPromiseError('Idler got killed');
}
const now = performance.now();
if (now - this.lastIdle > this.interval) {
this.lastIdle = now;
return new Promise((resolve) => {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => {
resolve();
});
} else {
setTimeout(resolve, 0);
}
});
}
return undefined;
}
cancel() {
this.kill = true;
}
isCancelled() {
return this.kill;
}
}
// This smills like we should be using generators :)
export class TestIdler implements Idler {
private resolver?: () => void;
private kill = false;
private autoRun = false;
private hasProgressed = false;
constructor(autorun = false) {
this.autoRun = autorun;
}
shouldIdle() {
if (this.kill) {
return true;
}
if (this.autoRun) {
return false;
}
// In turns we signal that idling is needed and that it isn't
if (!this.hasProgressed) {
this.hasProgressed = true;
return false;
}
return true;
}
async idle() {
if (this.kill) {
throw new CancelledPromiseError('Idler got killed');
}
if (this.autoRun) {
return undefined;
}
if (this.resolver) {
throw new Error('Already idling');
}
return new Promise<void>((resolve) => {
this.resolver = () => {
this.resolver = undefined;
this.hasProgressed = false;
resolve();
};
});
}
cancel() {
this.kill = true;
this.run();
}
async next() {
if (!this.resolver) {
throw new Error('Not yet idled');
}
this.resolver();
// make sure waiting promise runs first
await sleep(10);
}
/**
* Automatically progresses through all idle calls
*/
run() {
this.resolver?.();
this.autoRun = true;
}
isCancelled() {
return this.kill;
}
}

View File

@@ -0,0 +1,79 @@
/**
* 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
*/
/* eslint-disable promise/catch-or-return */
import {IdlerImpl, TestIdler} from '../Idler';
import {sleep} from 'flipper-plugin';
test('Idler should interrupt', async () => {
const idler = new IdlerImpl();
let i = 0;
try {
for (; i < 500; i++) {
if (i == 100) {
expect(idler.shouldIdle()).toBe(false);
idler.cancel();
expect(idler.isCancelled()).toBe(true);
expect(idler.shouldIdle()).toBe(true);
}
await idler.idle();
}
expect('error').toBe('thrown');
} catch (e) {
expect(i).toEqual(100);
}
});
// TODO(T98901996): Re-enable with flakiness fixed.
test.skip('Idler should want to idle', async () => {
const idler = new IdlerImpl(100);
expect(idler.shouldIdle()).toBe(false);
await sleep(10);
expect(idler.shouldIdle()).toBe(false);
await sleep(200);
expect(idler.shouldIdle()).toBe(true);
await idler.idle();
expect(idler.shouldIdle()).toBe(false);
});
test('TestIdler can be controlled', async () => {
const idler = new TestIdler();
expect(idler.shouldIdle()).toBe(false);
expect(idler.shouldIdle()).toBe(true);
let resolved = false;
idler.idle().then(() => {
resolved = true;
});
expect(resolved).toBe(false);
await idler.next();
expect(resolved).toBe(true);
expect(idler.shouldIdle()).toBe(false);
expect(idler.shouldIdle()).toBe(true);
idler.idle();
await idler.next();
expect(idler.isCancelled()).toBe(false);
idler.cancel();
expect(idler.isCancelled()).toBe(true);
expect(idler.shouldIdle()).toBe(true);
let threw = false;
const p = idler.idle().catch((e: any) => {
threw = true;
expect(e).toMatchInlineSnapshot(
`[CancelledPromiseError: Idler got killed]`,
);
});
await p;
expect(threw).toBe(true);
});

View File

@@ -0,0 +1,10 @@
{
"androidHome": "/opt/android_sdk",
"something": {
"else": 4
},
"_persist": {
"version": -1,
"rehydrated": true
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
/**
* 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 {buildLocalIconPath, buildIconURLSync} from '../icons';
import * as path from 'path';
test('filled icons get correct local path', () => {
const iconPath = buildLocalIconPath('star', 12, 2);
expect(iconPath).toBe(path.join('icons', 'star-filled-12@2x.png'));
});
test('outline icons get correct local path', () => {
const iconPath = buildLocalIconPath('star-outline', 12, 2);
expect(iconPath).toBe(path.join('icons', 'star-outline-12@2x.png'));
});
test('filled icons get correct URL', () => {
const iconUrl = buildIconURLSync('star', 12, 2);
expect(iconUrl).toBe(
'https://facebook.com/assets/?name=star&variant=filled&size=12&set=facebook_icons&density=2x',
);
});
test('outline icons get correct URL', () => {
const iconUrl = buildIconURLSync('star-outline', 12, 2);
expect(iconUrl).toBe(
'https://facebook.com/assets/?name=star&variant=outline&size=12&set=facebook_icons&density=2x',
);
});

View File

@@ -0,0 +1,112 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Store} from '../../reducers/index';
import {createStore} from 'redux';
import {createRootReducer} from '../../reducers';
import initialize, {getInfo} from '../info';
import {registerLoadedPlugins} from '../../reducers/plugins';
import {TestUtils} from 'flipper-plugin';
import {getLogger} from 'flipper-common';
import {selectPlugin} from '../../reducers/connections';
import {renderMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
const networkPluginDetails = TestUtils.createMockPluginDetails({
id: 'Network',
name: 'flipper-plugin-network',
version: '0.78.0',
dir: '/plugins/public/network',
pluginType: 'client',
});
const inspectorPluginDetails = TestUtils.createMockPluginDetails({
id: 'Inspector',
name: 'flipper-plugin-inspector',
version: '0.59.0',
dir: '/plugins/public/layout',
pluginType: 'client',
});
describe('info', () => {
let mockStore: Store;
beforeEach(() => {
mockStore = createStore(createRootReducer());
mockStore.dispatch({type: 'INIT'});
});
test('retrieve selection info', async () => {
const networkPlugin = TestUtils.createTestPlugin(
{
plugin() {
return {};
},
},
networkPluginDetails,
);
const inspectorPlugin = TestUtils.createTestPlugin(
{
plugin() {
return {};
},
},
inspectorPluginDetails,
);
const {client, device, store} = await renderMockFlipperWithPlugin(
networkPlugin,
{
additionalPlugins: [inspectorPlugin],
},
);
initialize(store, getLogger());
store.dispatch(
registerLoadedPlugins([networkPluginDetails, inspectorPluginDetails]),
);
const networkPluginSelectionInfo = getInfo();
store.dispatch(
selectPlugin({
selectedPlugin: inspectorPlugin.id,
selectedAppId: client.id,
selectedDevice: device,
deepLinkPayload: null,
}),
);
const inspectorPluginSelectionInfo = getInfo();
expect(networkPluginSelectionInfo.selection).toMatchInlineSnapshot(`
Object {
"app": "TestApp",
"archived": false,
"device": "MockAndroidDevice",
"deviceName": "MockAndroidDevice",
"deviceSerial": "serial",
"deviceType": "physical",
"os": "Android",
"plugin": "Network",
"pluginEnabled": true,
"pluginName": "flipper-plugin-network",
"pluginVersion": "0.78.0",
}
`);
expect(inspectorPluginSelectionInfo.selection).toMatchInlineSnapshot(`
Object {
"app": "TestApp",
"archived": false,
"device": "MockAndroidDevice",
"deviceName": "MockAndroidDevice",
"deviceSerial": "serial",
"deviceType": "physical",
"os": "Android",
"plugin": "Inspector",
"pluginEnabled": true,
"pluginName": "flipper-plugin-inspector",
"pluginVersion": "0.59.0",
}
`);
});
});

View File

@@ -0,0 +1,44 @@
/**
* 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
*/
/* eslint-disable node/no-sync */
import JsonFileStorage from '../jsonFileReduxPersistStorage';
import fs from 'fs';
const validSerializedData = fs
.readFileSync(
'flipper-ui-core/src/utils/__tests__/data/settings-v1-valid.json',
)
.toString()
.replace(/\r\n/g, '\n')
.trim();
const validDeserializedData =
'{"androidHome":"\\"/opt/android_sdk\\"","something":"{\\"else\\":4}","_persist":"{\\"version\\":-1,\\"rehydrated\\":true}"}';
const storage = new JsonFileStorage(
'flipper-ui-core/src/utils/__tests__/data/settings-v1-valid.json',
);
test('A valid settings file gets parsed correctly', () => {
return storage
.getItem('anykey')
.then((result) => expect(result).toEqual(validDeserializedData));
});
test('deserialize works as expected', () => {
const deserialized = storage.deserializeValue(validSerializedData);
expect(deserialized).toEqual(validDeserializedData);
});
test('serialize works as expected', () => {
const serialized = storage.serializeValue(validDeserializedData);
expect(serialized).toEqual(validSerializedData);
});

View File

@@ -0,0 +1,753 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {FlipperPlugin} from '../../plugin';
import {
createMockFlipperWithPlugin,
wrapSandy,
} from '../../test-utils/createMockFlipperWithPlugin';
import {sleep} from 'flipper-common';
import {Store} from '../../reducers';
import Client from '../../Client';
import {
selectPlugin,
selectClient,
selectDevice,
} from '../../reducers/connections';
import {processMessageQueue} from '../messageQueue';
import {getPluginKey} from '../pluginKey';
import {TestIdler} from '../Idler';
import {registerPlugins} from '../../reducers/plugins';
import {
_SandyPluginDefinition,
TestUtils,
PluginClient,
_SandyPluginInstance,
} from 'flipper-plugin';
import {switchPlugin} from '../../reducers/pluginManager';
import pluginMessageQueue, {
State,
queueMessages,
} from '../../reducers/pluginMessageQueue';
type Events = {
inc: {
delta?: number;
};
};
function plugin(client: PluginClient<Events, {}>) {
const state = {
count: 0,
};
client.onMessage('inc', (params) => {
state.count += params.delta || 1;
});
return {
state,
};
}
const TestPlugin = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
plugin,
Component() {
return null;
},
},
);
function switchTestPlugin(store: Store, client: Client) {
store.dispatch(
switchPlugin({
plugin: TestPlugin,
selectedApp: client.query.app,
}),
);
}
function selectDeviceLogs(store: Store) {
store.dispatch(
selectPlugin({
selectedPlugin: 'DeviceLogs',
selectedAppId: null,
deepLinkPayload: null,
selectedDevice: store.getState().connections.selectedDevice!,
}),
);
}
function selectTestPlugin(store: Store, client: Client) {
store.dispatch(
selectPlugin({
selectedPlugin: TestPlugin.id,
selectedAppId: client.id,
deepLinkPayload: null,
selectedDevice: store.getState().connections.selectedDevice!,
}),
);
}
function getTestPluginState(
client: Client,
): ReturnType<typeof plugin>['state'] {
return client.sandyPluginStates.get(TestPlugin.id)!.instanceApi.state;
}
test('queue - events are processed immediately if plugin is selected', async () => {
const {store, client, sendMessage} = await createMockFlipperWithPlugin(
TestPlugin,
);
expect(store.getState().connections.selectedPlugin).toBe('TestPlugin');
sendMessage('noop', {});
sendMessage('noop', {});
sendMessage('inc', {});
sendMessage('inc', {delta: 4});
sendMessage('noop', {});
client.flushMessageBuffer();
expect(getTestPluginState(client)).toMatchInlineSnapshot(`
Object {
"count": 5,
}
`);
expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(
`Object {}`,
);
});
test('queue - events are NOT processed immediately if plugin is NOT selected (but enabled)', async () => {
const {store, client, sendMessage, device} =
await createMockFlipperWithPlugin(TestPlugin);
selectDeviceLogs(store);
expect(store.getState().connections.selectedPlugin).not.toBe('TestPlugin');
sendMessage('inc', {});
sendMessage('inc', {delta: 2});
sendMessage('inc', {delta: 3});
expect(getTestPluginState(client).count).toBe(0);
// the first message is already visible cause of the leading debounce
expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
Object {
"TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {},
},
],
}
`);
client.flushMessageBuffer();
expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
Object {
"TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {},
},
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 2,
},
},
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 3,
},
},
],
}
`);
// process the message
const pluginKey = getPluginKey(client.id, device, TestPlugin.id);
await processMessageQueue(
client.sandyPluginStates.get(TestPlugin.id)!,
pluginKey,
store,
);
expect(getTestPluginState(client)).toEqual({
count: 6,
});
expect(store.getState().pluginMessageQueue).toEqual({
[pluginKey]: [],
});
// disable. Messages don't arrive anymore
switchTestPlugin(store, client);
// weird state...
selectTestPlugin(store, client);
sendMessage('inc', {delta: 3});
client.flushMessageBuffer();
// active, immediately processed
expect(client.sandyPluginStates.has(TestPlugin.id)).toBe(false);
// different plugin, and not enabled, message will never arrive
selectDeviceLogs(store);
sendMessage('inc', {delta: 4});
client.flushMessageBuffer();
expect(client.messageBuffer).toMatchInlineSnapshot(`Object {}`);
expect(store.getState().pluginMessageQueue).toEqual({});
// star again, plugin still not selected, message is queued
switchTestPlugin(store, client);
sendMessage('inc', {delta: 5});
client.flushMessageBuffer();
expect(store.getState().pluginMessageQueue).toEqual({
[pluginKey]: [{api: 'TestPlugin', method: 'inc', params: {delta: 5}}],
});
});
test('queue - events ARE processed immediately if plugin is NOT selected / enabled BUT NAVIGATION', async () => {
const NavigationPlugin = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails({
id: 'Navigation',
}),
{
plugin,
Component() {
return null;
},
},
);
const {store, client, sendMessage} = await createMockFlipperWithPlugin(
NavigationPlugin,
);
// Pre setup, deselect AND disable
selectDeviceLogs(store);
expect(store.getState().connections.selectedPlugin).toBe('DeviceLogs');
store.dispatch(
switchPlugin({
plugin: NavigationPlugin,
selectedApp: client.query.app,
}),
);
expect(store.getState().connections.enabledPlugins).toMatchInlineSnapshot(`
Object {
"TestApp": Array [],
}
`);
// ...mesages are still going to arrive
const pluginState = () =>
client.sandyPluginStates.get(NavigationPlugin.id)!.instanceApi.state;
sendMessage('inc', {});
sendMessage('inc', {delta: 2});
sendMessage('inc', {delta: 3});
// the first message is already visible cause of the leading debounce
expect(pluginState().count).toBe(1);
// message queue was never involved due to the bypass...
expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(
`Object {}`,
);
// flush will make the others visible
client.flushMessageBuffer();
expect(pluginState().count).toBe(6);
});
test('queue - events are queued for plugins that are favorite when app is not selected', async () => {
const {client, device, store, sendMessage, createClient} =
await createMockFlipperWithPlugin(TestPlugin);
selectDeviceLogs(store);
expect(store.getState().connections.selectedPlugin).not.toBe('TestPlugin');
const client2 = await createClient(device, 'TestApp2');
store.dispatch(selectClient(client2.id));
// Now we send a message to the second client, it should arrive,
// as the plugin was enabled already on the first client as well
sendMessage('inc', {delta: 2});
expect(getTestPluginState(client)).toEqual({count: 0});
expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
Object {
"TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 2,
},
},
],
}
`);
});
test('queue - events are queued for plugins that are favorite when app is selected on different device', async () => {
const {client, store, sendMessage, createDevice, createClient} =
await createMockFlipperWithPlugin(TestPlugin);
selectDeviceLogs(store);
expect(store.getState().connections.selectedPlugin).not.toBe('TestPlugin');
const device2 = createDevice({serial: 'serial2'});
const client2 = await createClient(device2, client.query.app); // same app id
store.dispatch(selectDevice(device2));
store.dispatch(selectClient(client2.id));
// Now we send a message to the first and second client, it should arrive,
// as the plugin was enabled already on the first client as well
sendMessage('inc', {delta: 2});
sendMessage('inc', {delta: 3}, client2);
client.flushMessageBuffer();
client2.flushMessageBuffer();
expect(getTestPluginState(client)).toEqual({count: 0});
expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
Object {
"TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 2,
},
},
],
"TestApp#Android#MockAndroidDevice#serial2#TestPlugin": Array [
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 3,
},
},
],
}
`);
});
test('queue - events processing will be paused', async () => {
const {client, device, store, sendMessage} =
await createMockFlipperWithPlugin(TestPlugin);
selectDeviceLogs(store);
sendMessage('inc', {});
sendMessage('inc', {delta: 3});
sendMessage('inc', {delta: 5});
client.flushMessageBuffer();
// process the message
const pluginKey = getPluginKey(client.id, device, TestPlugin.id);
// controlled idler will signal and and off that idling is needed
const idler = new TestIdler();
const p = processMessageQueue(
client.sandyPluginStates.get(TestPlugin.id)!,
pluginKey,
store,
undefined,
idler,
);
expect(getTestPluginState(client)).toEqual({
count: 4,
});
expect(store.getState().pluginMessageQueue).toEqual({
[pluginKey]: [{api: 'TestPlugin', method: 'inc', params: {delta: 5}}],
});
await idler.next();
expect(getTestPluginState(client)).toEqual({
count: 9,
});
expect(store.getState().pluginMessageQueue).toEqual({
[pluginKey]: [],
});
// don't idle anymore
idler.run();
await p;
});
test('queue - messages that arrive during processing will be queued', async () => {
const {client, device, store, sendMessage} =
await createMockFlipperWithPlugin(TestPlugin);
selectDeviceLogs(store);
sendMessage('inc', {});
sendMessage('inc', {delta: 2});
sendMessage('inc', {delta: 3});
client.flushMessageBuffer();
// process the message
const pluginKey = getPluginKey(client.id, device, TestPlugin.id);
const idler = new TestIdler();
const p = processMessageQueue(
client.sandyPluginStates.get(TestPlugin.id)!,
pluginKey,
store,
undefined,
idler,
);
// first message is consumed
expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(1);
expect(getTestPluginState(client).count).toBe(3);
// Select the current plugin as active, still, messages should end up in the queue
store.dispatch(
selectPlugin({
selectedPlugin: TestPlugin.id,
selectedAppId: client.id,
deepLinkPayload: null,
selectedDevice: device,
}),
);
expect(store.getState().connections.selectedPlugin).toBe('TestPlugin');
sendMessage('inc', {delta: 4});
client.flushMessageBuffer();
// should not be processed yet
expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(2);
expect(getTestPluginState(client).count).toBe(3);
await idler.next();
expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(0);
expect(getTestPluginState(client).count).toBe(10);
idler.run();
await p;
});
test('queue - processing can be cancelled', async () => {
const {client, device, store, sendMessage} =
await createMockFlipperWithPlugin(TestPlugin);
selectDeviceLogs(store);
sendMessage('inc', {});
sendMessage('inc', {delta: 2});
sendMessage('inc', {delta: 3});
sendMessage('inc', {delta: 4});
sendMessage('inc', {delta: 5});
client.flushMessageBuffer();
// process the message
const pluginKey = getPluginKey(client.id, device, TestPlugin.id);
const idler = new TestIdler();
const p = processMessageQueue(
client.sandyPluginStates.get(TestPlugin.id)!,
pluginKey,
store,
undefined,
idler,
);
// first message is consumed
await idler.next();
expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(1);
expect(getTestPluginState(client).count).toBe(10);
idler.cancel();
expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(1);
expect(getTestPluginState(client).count).toBe(10);
await p;
});
test('queue - make sure resetting plugin state clears the message queue', async () => {
const {client, device, store, sendMessage} =
await createMockFlipperWithPlugin(TestPlugin);
selectDeviceLogs(store);
sendMessage('inc', {});
sendMessage('inc', {delta: 2});
client.flushMessageBuffer();
const pluginKey = getPluginKey(client.id, device, TestPlugin.id);
expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(2);
store.dispatch({
type: 'CLEAR_CLIENT_PLUGINS_STATE',
payload: {clientId: client.id, devicePlugins: new Set()},
});
expect(store.getState().pluginMessageQueue[pluginKey]).toBe(undefined);
});
test('client - incoming messages are buffered and flushed together', async () => {
class StubPlugin extends FlipperPlugin<any, any, any> {
static id = 'StubPlugin';
static persistedStateReducer = jest.fn();
}
const StubPluginWrapped = wrapSandy(StubPlugin);
const {client, store, device, sendMessage, pluginKey} =
await createMockFlipperWithPlugin(TestPlugin, {
additionalPlugins: [StubPluginWrapped],
});
selectDeviceLogs(store);
store.dispatch(registerPlugins([StubPluginWrapped]));
sendMessage('inc', {});
sendMessage('inc', {delta: 2});
sendMessage('inc', {delta: 3});
// send a message to device logs
client.onMessage(
JSON.stringify({
method: 'execute',
params: {
api: 'StubPlugin',
method: 'log',
params: {line: 'suff'},
},
}),
);
expect(getTestPluginState(client).count).toBe(0);
// the first message is already visible cause of the leading debounce
expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
Object {
"TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {},
},
],
}
`);
expect(client.messageBuffer).toMatchInlineSnapshot(`
Object {
"TestApp#Android#MockAndroidDevice#serial#StubPlugin": Object {
"messages": Array [
Object {
"api": "StubPlugin",
"method": "log",
"params": Object {
"line": "suff",
},
},
],
"plugin": "[SandyPluginInstance]",
},
"TestApp#Android#MockAndroidDevice#serial#TestPlugin": Object {
"messages": Array [
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 2,
},
},
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 3,
},
},
],
"plugin": "[SandyPluginInstance]",
},
}
`);
expect(client.messageBuffer[pluginKey].plugin).toBeInstanceOf(
_SandyPluginInstance,
);
await sleep(500);
expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
Object {
"TestApp#Android#MockAndroidDevice#serial#StubPlugin": Array [
Object {
"api": "StubPlugin",
"method": "log",
"params": Object {
"line": "suff",
},
},
],
"TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {},
},
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 2,
},
},
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 3,
},
},
],
}
`);
expect(client.messageBuffer).toMatchInlineSnapshot(`Object {}`);
expect(StubPlugin.persistedStateReducer.mock.calls).toMatchInlineSnapshot(
`Array []`,
);
// tigger processing the queue
const pluginKeyDevice = getPluginKey(client.id, device, StubPlugin.id);
await processMessageQueue(
client.sandyPluginStates.get(StubPlugin.id)!,
pluginKeyDevice,
store,
);
expect(StubPlugin.persistedStateReducer.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
undefined,
"log",
Object {
"line": "suff",
},
],
]
`);
expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
Object {
"TestApp#Android#MockAndroidDevice#serial#StubPlugin": Array [],
"TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {},
},
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 2,
},
},
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 3,
},
},
],
}
`);
});
test('queue - messages that have not yet flushed be lost when disabling the plugin', async () => {
const {client, store, sendMessage, pluginKey} =
await createMockFlipperWithPlugin(TestPlugin);
selectDeviceLogs(store);
sendMessage('inc', {});
sendMessage('inc', {delta: 2});
expect(client.messageBuffer).toMatchInlineSnapshot(`
Object {
"TestApp#Android#MockAndroidDevice#serial#TestPlugin": Object {
"messages": Array [
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {
"delta": 2,
},
},
],
"plugin": "[SandyPluginInstance]",
},
}
`);
expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
Object {
"TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
Object {
"api": "TestPlugin",
"method": "inc",
"params": Object {},
},
],
}
`);
// disable
switchTestPlugin(store, client);
expect(client.messageBuffer).toMatchInlineSnapshot(`Object {}`);
expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(
`Object {}`,
);
// re-enable, no messages arrive
switchTestPlugin(store, client);
client.flushMessageBuffer();
processMessageQueue(
client.sandyPluginStates.get(TestPlugin.id)!,
pluginKey,
store,
);
expect(getTestPluginState(client)).toEqual({count: 0});
});
test('queue will be cleaned up when it exceeds maximum size', () => {
let state: State = {};
const pluginKey = 'test';
const queueSize = 5000;
let i = 0;
for (i = 0; i < queueSize; i++) {
state = pluginMessageQueue(
state,
queueMessages(pluginKey, [{method: 'test', params: {i}}], queueSize),
);
}
// almost full
expect(state[pluginKey][0]).toEqual({method: 'test', params: {i: 0}});
expect(state[pluginKey].length).toBe(queueSize); // ~5000
expect(state[pluginKey][queueSize - 1]).toEqual({
method: 'test',
params: {i: queueSize - 1}, // ~4999
});
state = pluginMessageQueue(
state,
queueMessages(pluginKey, [{method: 'test', params: {i: ++i}}], queueSize),
);
const newLength = Math.ceil(0.9 * queueSize) + 1; // ~4500
expect(state[pluginKey].length).toBe(newLength);
expect(state[pluginKey][0]).toEqual({
method: 'test',
params: {i: queueSize - newLength + 1}, // ~500
});
expect(state[pluginKey][newLength - 1]).toEqual({
method: 'test',
params: {i: i}, // ~50001
});
});

View File

@@ -0,0 +1,15 @@
/**
* 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 {readCurrentRevision} from '../packageMetadata';
test('readCurrentRevision does not return something meaningful in dev mode', async () => {
const ret = await readCurrentRevision();
expect(ret).toBeUndefined();
});

View File

@@ -0,0 +1,143 @@
/**
* 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 {getPluginKey} from '../pluginKey';
import {FlipperPlugin, FlipperDevicePlugin} from '../../plugin';
import {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
import {getExportablePlugins} from '../../selectors/connections';
function createMockFlipperPluginWithDefaultPersistedState(id: string) {
return class MockFlipperPluginWithDefaultPersistedState extends FlipperPlugin<
any,
any,
any
> {
static id = id;
static defaultPersistedState = {msg: 'MockFlipperPluginWithPersistedState'};
['constructor']: any;
subscriptions = null as any;
client = null as any;
realClient = null as any;
getDevice = null as any;
};
}
function createMockDeviceFlipperPlugin(id: string) {
return class MockFlipperDevicePlugin extends FlipperDevicePlugin<
any,
any,
any
> {
static id = id;
['constructor']: any;
static supportsDevice() {
return true;
}
};
}
function createMockFlipperPluginWithExportPersistedState(id: string) {
return class MockFlipperPluginWithExportPersistedState extends FlipperPlugin<
any,
any,
any
> {
static id = id;
static exportPersistedState = (): Promise<any> => {
return Promise.resolve({
msg: 'MockFlipperPluginWithExportPersistedState',
});
};
['constructor']: any;
};
}
function createMockFlipperPluginWithNoPersistedState(id: string) {
return class MockFlipperPluginWithNoPersistedState extends FlipperPlugin<
any,
any,
any
> {
static id = id;
['constructor']: any;
};
}
test('getActivePersistentPlugins, where the non persistent plugins getting excluded', async () => {
const {store} = await createMockFlipperWithPlugin(
createMockFlipperPluginWithDefaultPersistedState('ClientPlugin1'),
{
additionalPlugins: [
createMockFlipperPluginWithDefaultPersistedState('ClientPlugin2'),
createMockFlipperPluginWithNoPersistedState('ClientPlugin3'),
createMockFlipperPluginWithNoPersistedState('ClientPlugin4'),
createMockFlipperPluginWithExportPersistedState('ClientPlugin5'),
],
},
);
const state = store.getState();
const list = getExportablePlugins(state);
expect(list).toEqual([
{
id: 'ClientPlugin1',
label: 'ClientPlugin1',
},
{
id: 'ClientPlugin2',
label: 'ClientPlugin2',
},
{
id: 'ClientPlugin5',
label: 'ClientPlugin5',
},
]);
});
test('getActivePersistentPlugins, with message queue', async () => {
const {store, device, client} = await createMockFlipperWithPlugin(
createMockFlipperPluginWithDefaultPersistedState('Plugin1'),
{
additionalPlugins: [
createMockDeviceFlipperPlugin('DevicePlugin2'),
createMockFlipperPluginWithNoPersistedState('ClientPlugin1'),
createMockFlipperPluginWithDefaultPersistedState('ClientPlugin2'),
createMockFlipperPluginWithDefaultPersistedState('ClientPlugin3'),
],
},
);
const state = store.getState();
state.pluginMessageQueue = {
[getPluginKey(client.id, device, 'ClientPlugin3')]: [
{method: 'msg', params: {msg: 'ClientPlugin3'}},
],
};
const list = getExportablePlugins(store.getState());
expect(list).toEqual([
{
id: 'ClientPlugin2', // has state
label: 'ClientPlugin2',
},
{
id: 'ClientPlugin3', // queued
label: 'ClientPlugin3',
},
{
// in Sandy wrapper, a plugin is either persistable or not, but it doesn't depend on the current state.
// So this plugin will show up, even though its state is still the default
id: 'Plugin1',
label: 'Plugin1',
},
]);
});

View File

@@ -0,0 +1,44 @@
/**
* 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 config, resetConfigForTesting} from '../processConfig';
afterEach(() => {
resetConfigForTesting();
});
test('config is decoded from env', () => {
process.env.CONFIG = JSON.stringify({
disabledPlugins: ['pluginA', 'pluginB', 'pluginC'],
lastWindowPosition: {x: 4, y: 8, width: 15, height: 16},
launcherMsg: 'wubba lubba dub dub',
screenCapturePath: '/my/screenshot/path',
launcherEnabled: false,
});
expect(config()).toEqual({
disabledPlugins: new Set(['pluginA', 'pluginB', 'pluginC']),
lastWindowPosition: {x: 4, y: 8, width: 15, height: 16},
launcherMsg: 'wubba lubba dub dub',
screenCapturePath: '/my/screenshot/path',
launcherEnabled: false,
});
});
test('config is decoded from env with defaults', () => {
process.env.CONFIG = '{}';
expect(config()).toEqual({
disabledPlugins: new Set([]),
lastWindowPosition: undefined,
launcherMsg: undefined,
screenCapturePath: undefined,
launcherEnabled: true,
});
});

View File

@@ -0,0 +1,40 @@
/**
* 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 promiseTimeout from '../promiseTimeout';
test('test promiseTimeout for timeout to happen', () => {
const promise = promiseTimeout(
200,
new Promise<void>((resolve) => {
const id = setTimeout(() => {
clearTimeout(id);
resolve();
}, 500);
return 'Executed';
}),
'Timed out',
);
return expect(promise).rejects.toThrow('Timed out');
});
test('test promiseTimeout for timeout not to happen', () => {
const promise = promiseTimeout(
200,
new Promise<string | void>((resolve) => {
const id = setTimeout(() => {
clearTimeout(id);
resolve();
}, 100);
resolve('Executed');
}),
'Timed out',
);
return expect(promise).resolves.toBe('Executed');
});

View File

@@ -0,0 +1,258 @@
/**
* 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 {sideEffect} from '../sideEffect';
import {createStore, Store} from 'redux';
import produce from 'immer';
import {sleep} from 'flipper-plugin';
jest.useFakeTimers();
const initialState = {
counter: {count: 0},
somethingUnrelated: false,
};
type State = typeof initialState;
type Action = {type: 'inc'} | {type: 'unrelated'};
function reducer(state: State, action: Action): State {
return produce(state, (draft) => {
if (action.type === 'inc') {
draft.counter.count++;
}
if (action.type === 'unrelated') {
draft.somethingUnrelated = !draft.somethingUnrelated;
}
});
}
describe('sideeffect', () => {
let store: Store<State, Action>;
let events: string[];
let unsubscribe: undefined | (() => void) = undefined;
let warn: jest.Mock;
let error: jest.Mock;
const origWarning = console.warn;
const origError = console.error;
beforeEach(() => {
// @ts-ignore
store = createStore(reducer, initialState);
events = [];
warn = console.warn = jest.fn();
error = console.error = jest.fn();
});
afterEach(() => {
unsubscribe?.();
});
afterEach(() => {
console.warn = origWarning;
console.error = origError;
});
test.local('can run a basic effect', async () => {
unsubscribe = sideEffect(
store,
{name: 'test', throttleMs: 1},
(s) => s,
(s, passedStore) => {
expect(passedStore).toBe(store);
events.push(`counter: ${s.counter.count}`);
},
);
store.dispatch({type: 'inc'});
store.dispatch({type: 'inc'});
expect(events.length).toBe(0);
// arrive as a single effect
jest.advanceTimersByTime(10);
expect(events).toEqual(['counter: 2']);
// no more events arrive after unsubscribe
unsubscribe();
store.dispatch({type: 'inc'});
jest.advanceTimersByTime(10);
expect(events).toEqual(['counter: 2']);
expect(warn).not.toBeCalled();
expect(error).not.toBeCalled();
});
test('respects selector', async () => {
unsubscribe = sideEffect(
store,
{name: 'test', throttleMs: 1},
(s) => s.counter.count,
(count) => {
events.push(`counter: ${count}`);
},
);
store.dispatch({type: 'unrelated'});
expect(events.length).toBe(0);
// unrelated event doesn't trigger
jest.advanceTimersByTime(10);
expect(events.length).toBe(0);
// counter increment does
store.dispatch({type: 'inc'});
jest.advanceTimersByTime(10);
expect(events).toEqual(['counter: 1']);
expect(warn).not.toBeCalled();
expect(error).not.toBeCalled();
});
test('respects shallow equal selector', async () => {
unsubscribe = sideEffect(
store,
{name: 'test', throttleMs: 1},
(s) => ({number: s.counter.count}),
({number}) => {
events.push(`counter: ${number}`);
},
);
store.dispatch({type: 'unrelated'});
expect(events.length).toBe(0);
// unrelated event doesn't trigger
jest.advanceTimersByTime(10);
expect(events.length).toBe(0);
// counter increment does
store.dispatch({type: 'inc'});
jest.advanceTimersByTime(10);
expect(events).toEqual(['counter: 1']);
expect(warn).not.toBeCalled();
expect(error).not.toBeCalled();
});
test('handles errors', async () => {
unsubscribe = sideEffect(
store,
{name: 'test', throttleMs: 1},
(s) => s,
() => {
throw new Error('oops');
},
);
expect(() => {
store.dispatch({type: 'inc'});
}).not.toThrow();
jest.advanceTimersByTime(10);
expect(error.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"Error while running side effect 'test': Error: oops",
[Error: oops],
],
]
`);
});
test('warns about long running effects', async () => {
let done = false;
unsubscribe = sideEffect(
store,
{name: 'test', throttleMs: 10},
(s) => s,
() => {
const end = Date.now() + 100;
while (Date.now() < end) {
// block
}
done = true;
},
);
store.dispatch({type: 'inc'});
jest.advanceTimersByTime(200);
expect(done).toBe(true);
expect(warn.mock.calls[0][0]).toContain("Side effect 'test' took");
});
test('throttles correctly', async () => {
unsubscribe = sideEffect(
store,
{name: 'test', throttleMs: 1000},
(s) => s.counter.count,
(number) => {
events.push(`counter: ${number}`);
},
);
// Fires immediately
store.dispatch({type: 'inc'});
jest.advanceTimersByTime(100);
expect(events).toEqual(['counter: 1']);
// no new tick in the next 100 ms
jest.advanceTimersByTime(300);
store.dispatch({type: 'inc'});
jest.advanceTimersByTime(300);
store.dispatch({type: 'inc'});
expect(events).toEqual(['counter: 1']);
jest.advanceTimersByTime(1000);
expect(events).toEqual(['counter: 1', 'counter: 3']);
// long time no effect, it will fire right away again
// N.b. we need call sleep here to create a timeout, as time wouldn't progress otherwise
const p = sleep(2000);
jest.advanceTimersByTime(2000);
await p;
// ..but firing an event that doesn't match the selector doesn't reset the timer
store.dispatch({type: 'unrelated'});
expect(events).toEqual(['counter: 1', 'counter: 3']);
jest.advanceTimersByTime(100);
store.dispatch({type: 'inc'});
store.dispatch({type: 'inc'});
jest.advanceTimersByTime(100);
const p2 = sleep(2000);
jest.advanceTimersByTime(2000);
await p2;
expect(events).toEqual(['counter: 1', 'counter: 3', 'counter: 5']);
});
test('can fire immediately', async () => {
store.dispatch({type: 'inc'});
store.dispatch({type: 'inc'});
unsubscribe = sideEffect(
store,
{name: 'test', throttleMs: 1, fireImmediately: true},
(s) => s,
(s) => {
events.push(`counter: ${s.counter.count}`);
},
);
expect(events).toEqual(['counter: 2']);
store.dispatch({type: 'inc'});
store.dispatch({type: 'inc'});
// arrive as a single effect
jest.advanceTimersByTime(10);
expect(events).toEqual(['counter: 2', 'counter: 4']);
unsubscribe?.();
});
});

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export function assertNotNull<T extends any>(
value: T,
message: string = 'Unexpected null/undefined value found',
): asserts value is Exclude<T, undefined | null> {
if (value === null || value === undefined) {
throw new Error(message);
}
}

View File

@@ -0,0 +1,29 @@
/**
* 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 {deconstructClientId} from 'flipper-common';
import type Client from '../Client';
import type BaseDevice from '../devices/BaseDevice';
export function currentActiveApps(
clients: Array<Client>,
selectedDevice: null | BaseDevice,
): Array<string> {
const currentActiveApps: Array<string> = clients
.map(({id}: {id: string}) => {
const appName = deconstructClientId(id).app || '';
const os = deconstructClientId(id).os || '';
return {appName, os};
})
.filter(
({os}: {os: string}) => os && selectedDevice && os == selectedDevice.os,
)
.map((client) => client.appName);
return currentActiveApps;
}

View File

@@ -0,0 +1,229 @@
/**
* 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 * as React from 'react';
import {
createState,
useLogger,
usePlugin,
useValue,
_SandyPluginDefinition,
PluginClient,
DevicePluginClient,
} from 'flipper-plugin';
import {useEffect} from 'react';
import {
BaseAction,
FlipperDevicePlugin,
FlipperPlugin,
Props as PluginProps,
} from '../plugin';
import {useStore} from './useStore';
import {setStaticView, StaticView} from '../reducers/connections';
import {getStore} from '../store';
import {setActiveNotifications} from '../reducers/notifications';
import BaseDevice from '../devices/BaseDevice';
export type SandyPluginModule = ConstructorParameters<
typeof _SandyPluginDefinition
>[1];
export function createSandyPluginWrapper<S, A extends BaseAction, P>(
Plugin: typeof FlipperPlugin | typeof FlipperDevicePlugin,
): SandyPluginModule {
const isDevicePlugin = Plugin.prototype instanceof FlipperDevicePlugin;
function legacyPluginWrapper(client: PluginClient | DevicePluginClient) {
const store = getStore();
const appClient = isDevicePlugin
? undefined
: (client as PluginClient<any, any>);
const instanceRef = React.createRef<FlipperPlugin<S, A, P> | null>();
const persistedState = createState<P>(Plugin.defaultPersistedState);
const deeplink = createState<unknown>();
client.onDeepLink((link) => {
deeplink.set(link);
});
appClient?.onUnhandledMessage((event, params) => {
if (Plugin.persistedStateReducer) {
persistedState.set(
Plugin.persistedStateReducer(persistedState.get(), event, params),
);
}
});
if (
Plugin.persistedStateReducer ||
Plugin.exportPersistedState ||
Plugin.defaultPersistedState
) {
client.onExport(async (idler, onStatusMessage) => {
const state = Plugin.exportPersistedState
? await Plugin.exportPersistedState(
isDevicePlugin
? undefined
: (method: string, params: any) =>
appClient!.send(method, params),
persistedState.get(),
undefined, // passing an undefined Store is safe, as no plugin actually uses this param
idler,
onStatusMessage,
isDevicePlugin
? undefined
: (method: string) => appClient!.supportsMethod(method),
)
: persistedState.get();
// respect custom serialization
return Plugin.serializePersistedState
? await Plugin.serializePersistedState(
state,
onStatusMessage,
idler,
Plugin.id,
)
: state;
});
client.onImport((data) => {
if (Plugin.deserializePersistedState) {
data = Plugin.deserializePersistedState(data);
}
persistedState.set(data);
});
}
if (Plugin.keyboardActions) {
function executeKeyboardAction(action: string) {
instanceRef?.current?.onKeyboardAction?.(action);
}
client.addMenuEntry(
...Plugin.keyboardActions.map((def) => {
if (typeof def === 'string') {
return {
action: def,
handler() {
executeKeyboardAction(def);
},
};
} else {
const {action, label, accelerator} = def;
return {
label,
accelerator,
handler() {
executeKeyboardAction(action);
},
};
}
}),
);
}
if (Plugin.getActiveNotifications && !isDevicePlugin) {
const unsub = persistedState.subscribe((state) => {
try {
const notifications = Plugin.getActiveNotifications!(state);
store.dispatch(
setActiveNotifications({
notifications,
client: appClient!.appId,
pluginId: Plugin.id,
}),
);
} catch (e) {
console.error(
'Failed to compute notifications for plugin ' + Plugin.id,
e,
);
}
});
client.onDestroy(unsub);
}
return {
instanceRef,
device: client.device,
persistedState,
deeplink,
selectPlugin: client.selectPlugin,
setPersistedState(state: Partial<P>) {
persistedState.set({...persistedState.get(), ...state});
},
get appId() {
return appClient?.appId;
},
get appName() {
return appClient?.appName ?? null;
},
get isArchived() {
return client.device.isArchived;
},
setStaticView(payload: StaticView) {
store.dispatch(setStaticView(payload));
},
};
}
function Component() {
const instance = usePlugin(legacyPluginWrapper);
const logger = useLogger();
const persistedState = useValue(instance.persistedState);
const deepLinkPayload = useValue(instance.deeplink);
const settingsState = useStore((state) => state.settingsState);
const target = isDevicePlugin
? // in the client, all Device's are BaseDevice, so this is safe..
(instance.device as BaseDevice)
: // eslint-disable-next-line
useStore((state) =>
state.connections.clients.get(instance.appId!),
);
if (!target) {
throw new Error('Illegal state: missing target');
}
useEffect(
function triggerInitAndTeardown() {
const ref = instance.instanceRef.current!;
ref._init();
return () => {
ref._teardown();
};
},
[instance.instanceRef],
);
const props: PluginProps<P> = {
logger,
persistedState,
deepLinkPayload,
settingsState,
target,
setPersistedState: instance.setPersistedState,
selectPlugin: instance.selectPlugin,
isArchivedDevice: instance.isArchived,
selectedApp: instance.appName,
setStaticView: instance.setStaticView,
// @ts-ignore ref is not on Props
ref: instance.instanceRef,
};
return React.createElement(Plugin, props);
}
return isDevicePlugin
? {devicePlugin: legacyPluginWrapper, Component}
: {
plugin: legacyPluginWrapper,
Component,
};
}

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export function unloadModule(path: string) {
const resolvedPath = global.electronRequire.resolve(path);
if (!resolvedPath || !global.electronRequire.cache[resolvedPath]) {
return;
}
delete global.electronRequire.cache[resolvedPath];
}

View File

@@ -0,0 +1,669 @@
/**
* 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 * as React from 'react';
import os from 'os';
import path from 'path';
import {getLogger} from 'flipper-common';
import {Store, MiddlewareAPI} from '../reducers';
import {DeviceExport} from '../devices/BaseDevice';
import {selectedPlugins, State as PluginsState} from '../reducers/plugins';
import {PluginNotification} from '../reducers/notifications';
import Client, {ClientExport} from '../Client';
import {getAppVersion} from './info';
import {pluginKey} from '../utils/pluginKey';
import {DevicePluginMap, ClientPluginMap} from '../plugin';
import {default as BaseDevice} from '../devices/BaseDevice';
import {default as ArchivedDevice} from '../devices/ArchivedDevice';
import fs from 'fs-extra';
import {v4 as uuidv4} from 'uuid';
import {readCurrentRevision} from './packageMetadata';
import {tryCatchReportPlatformFailures} from 'flipper-common';
import {TestIdler} from './Idler';
import {setStaticView} from '../reducers/connections';
import {
resetSupportFormV2State,
SupportFormRequestDetailsState,
} from '../reducers/supportForm';
import {deconstructClientId} from 'flipper-common';
import {performance} from 'perf_hooks';
import {processMessageQueue} from './messageQueue';
import {getPluginTitle} from './pluginUtils';
import {capture} from './screenshot';
import {uploadFlipperMedia} from '../fb-stubs/user';
import {Dialog, Idler} from 'flipper-plugin';
import {ClientQuery} from 'flipper-common';
import ShareSheetExportUrl from '../chrome/ShareSheetExportUrl';
import ShareSheetExportFile from '../chrome/ShareSheetExportFile';
import ExportDataPluginSheet from '../chrome/ExportDataPluginSheet';
import {getRenderHostInstance} from '../RenderHost';
export const IMPORT_FLIPPER_TRACE_EVENT = 'import-flipper-trace';
export const EXPORT_FLIPPER_TRACE_EVENT = 'export-flipper-trace';
export const EXPORT_FLIPPER_TRACE_TIME_SERIALIZATION_EVENT = `${EXPORT_FLIPPER_TRACE_EVENT}:serialization`;
// maps clientId -> pluginId -> persistence key -> state
export type SandyPluginStates = Record<
string,
Record<string, Record<string, any>>
>;
export type PluginStatesExportState = {
[pluginKey: string]: string;
};
export type ExportType = {
fileVersion: string;
flipperReleaseRevision: string | undefined;
clients: Array<ClientExport>;
device: DeviceExport | null;
deviceScreenshot: string | null;
store: {
activeNotifications: Array<PluginNotification>;
};
// The GraphQL plugin relies on this format for generating
// Flipper traces from employee dogfooding. See D28209561.
pluginStates2: SandyPluginStates;
supportRequestDetails?: SupportFormRequestDetailsState;
};
type ProcessNotificationStatesOptions = {
clients: Array<ClientExport>;
serial: string;
allActiveNotifications: Array<PluginNotification>;
devicePlugins: DevicePluginMap;
statusUpdate?: (msg: string) => void;
};
type PluginsToProcess = {
pluginKey: string;
pluginId: string;
pluginName: string;
client: Client;
}[];
type AddSaltToDeviceSerialOptions = {
salt: string;
device: BaseDevice;
deviceScreenshot: string | null;
clients: Array<ClientExport>;
pluginStates2: SandyPluginStates;
devicePluginStates: Record<string, any>;
pluginNotification: Array<PluginNotification>;
selectedPlugins: Array<string>;
statusUpdate: (msg: string) => void;
idler: Idler;
};
export function displayFetchMetadataErrors(
fetchMetaDataErrors: {
[plugin: string]: Error;
} | null,
): {title: string; errorArray: Array<Error>} {
const errors = fetchMetaDataErrors ? Object.values(fetchMetaDataErrors) : [];
const pluginsWithFetchMetadataErrors = fetchMetaDataErrors
? Object.keys(fetchMetaDataErrors)
: [];
const title =
fetchMetaDataErrors && pluginsWithFetchMetadataErrors.length > 0
? `Export was successfull, but plugin${
pluginsWithFetchMetadataErrors.length > 1 ? 's' : ''
} ${pluginsWithFetchMetadataErrors.join(
', ',
)} might be ignored because of the following errors.`
: '';
return {title, errorArray: errors};
}
export function processClients(
clients: Array<ClientExport>,
serial: string,
statusUpdate?: (msg: string) => void,
): Array<ClientExport> {
statusUpdate &&
statusUpdate(`Filtering Clients for the device id ${serial}...`);
const filteredClients = clients.filter(
(client) => client.query.device_id === serial,
);
return filteredClients;
}
export function processNotificationStates(
options: ProcessNotificationStatesOptions,
): Array<PluginNotification> {
const {clients, serial, allActiveNotifications, devicePlugins, statusUpdate} =
options;
statusUpdate &&
statusUpdate('Filtering the notifications for the filtered Clients...');
const activeNotifications = allActiveNotifications.filter((notif) => {
const filteredClients = clients.filter((client) =>
notif.client ? client.id.includes(notif.client) : false,
);
return (
filteredClients.length > 0 ||
(devicePlugins.has(notif.pluginId) && serial === notif.client)
); // There need not be any client for device Plugins
});
return activeNotifications;
}
async function exportSandyPluginStates(
pluginsToProcess: PluginsToProcess,
idler: Idler,
statusUpdate: (msg: string) => void,
): Promise<SandyPluginStates> {
const res: SandyPluginStates = {};
for (const key in pluginsToProcess) {
const {pluginId, client} = pluginsToProcess[key];
if (client.sandyPluginStates.has(pluginId)) {
if (!res[client.id]) {
res[client.id] = {};
}
try {
res[client.id][pluginId] = await client.sandyPluginStates
.get(pluginId)!
.exportState(idler, statusUpdate);
} catch (error) {
console.error('Error while serializing plugin ' + pluginId, error);
throw new Error(`Failed to serialize plugin ${pluginId}: ${error}`);
}
}
}
return res;
}
function replaceSerialsInKeys<T extends Record<string, any>>(
collection: T,
baseSerial: string,
newSerial: string,
): T {
const result: Record<string, any> = {};
for (const key in collection) {
if (!key.includes(baseSerial)) {
continue;
}
result[key.replace(baseSerial, newSerial)] = collection[key];
}
return result as T;
}
async function addSaltToDeviceSerial({
salt,
device,
deviceScreenshot,
clients,
pluginNotification,
statusUpdate,
pluginStates2,
devicePluginStates,
}: AddSaltToDeviceSerialOptions): Promise<ExportType> {
const {serial} = device;
const newSerial = salt + '-' + serial;
const newDevice = new ArchivedDevice({
serial: newSerial,
deviceType: device.deviceType,
title: device.title,
os: device.os,
screenshotHandle: deviceScreenshot,
});
statusUpdate &&
statusUpdate('Adding salt to the selected device id in the client data...');
const updatedClients = clients.map((client: ClientExport) => {
return {
...client,
id: client.id.replace(serial, newSerial),
query: {...client.query, device_id: newSerial},
};
});
statusUpdate &&
statusUpdate(
'Adding salt to the selected device id in the plugin states...',
);
const updatedPluginStates2 = replaceSerialsInKeys(
pluginStates2,
serial,
newSerial,
);
statusUpdate &&
statusUpdate(
'Adding salt to the selected device id in the notification data...',
);
const updatedPluginNotifications = pluginNotification.map((notif) => {
if (!notif.client || !notif.client.includes(serial)) {
throw new Error(
`Error while exporting, plugin state (${notif.pluginId}) does not have ${serial} in it`,
);
}
return {...notif, client: notif.client.replace(serial, newSerial)};
});
const revision: string | undefined = await readCurrentRevision();
return {
fileVersion: getAppVersion() || 'unknown',
flipperReleaseRevision: revision,
clients: updatedClients,
device: {...newDevice.toJSON(), pluginStates: devicePluginStates},
deviceScreenshot: deviceScreenshot,
store: {
activeNotifications: updatedPluginNotifications,
},
pluginStates2: updatedPluginStates2,
};
}
type ProcessStoreOptions = {
activeNotifications: Array<PluginNotification>;
device: BaseDevice | null;
pluginStates2: SandyPluginStates;
clients: Array<ClientExport>;
devicePlugins: DevicePluginMap;
clientPlugins: ClientPluginMap;
salt: string;
selectedPlugins: Array<string>;
statusUpdate?: (msg: string) => void;
};
export async function processStore(
{
activeNotifications,
device,
pluginStates2,
clients,
devicePlugins,
salt,
selectedPlugins,
statusUpdate,
}: ProcessStoreOptions,
idler: Idler = new TestIdler(true),
): Promise<ExportType> {
if (device) {
const {serial} = device;
if (!statusUpdate) {
statusUpdate = () => {};
}
statusUpdate('Capturing screenshot...');
const deviceScreenshot = device.connected.get()
? await capture(device).catch((e) => {
console.warn(
'Failed to capture device screenshot when exporting. ' + e,
);
return null;
})
: null;
const processedClients = processClients(clients, serial, statusUpdate);
const processedActiveNotifications = processNotificationStates({
clients: processedClients,
serial,
allActiveNotifications: activeNotifications,
devicePlugins,
statusUpdate,
});
const devicePluginStates = await device.exportState(
idler,
statusUpdate,
selectedPlugins,
);
statusUpdate('Uploading screenshot...');
const deviceScreenshotLink =
deviceScreenshot &&
(await uploadFlipperMedia(deviceScreenshot, 'Image').catch((e) => {
console.warn('Failed to upload device screenshot when exporting. ' + e);
return null;
}));
// Adding salt to the device id, so that the device_id in the device list is unique.
const exportFlipperData = await addSaltToDeviceSerial({
salt,
device,
deviceScreenshot: deviceScreenshotLink,
clients: processedClients,
pluginNotification: processedActiveNotifications,
statusUpdate,
selectedPlugins,
pluginStates2,
devicePluginStates,
idler,
});
return exportFlipperData;
}
throw new Error('Selected device is null, please select a device');
}
async function processQueues(
store: MiddlewareAPI,
pluginsToProcess: PluginsToProcess,
statusUpdate?: (msg: string) => void,
idler?: Idler,
) {
for (const {pluginName, pluginId, pluginKey, client} of pluginsToProcess) {
client.flushMessageBuffer();
const processQueueMarker = `${EXPORT_FLIPPER_TRACE_EVENT}:process-queue-per-plugin`;
performance.mark(processQueueMarker);
const plugin = client.sandyPluginStates.get(pluginId);
if (!plugin) continue;
await processMessageQueue(
plugin,
pluginKey,
store,
({current, total}) => {
statusUpdate?.(
`Processing event ${current} / ${total} (${Math.round(
(current / total) * 100,
)}%) for plugin ${pluginName}`,
);
},
idler,
);
getLogger().trackTimeSince(processQueueMarker, processQueueMarker, {
pluginId,
});
}
}
export function determinePluginsToProcess(
clients: Array<Client>,
selectedDevice: null | BaseDevice,
plugins: PluginsState,
): PluginsToProcess {
const pluginsToProcess: PluginsToProcess = [];
const selectedPlugins = plugins.selectedPlugins;
for (const client of clients) {
if (!selectedDevice || client.query.device_id !== selectedDevice.serial) {
continue;
}
const selectedFilteredPlugins = client
? selectedPlugins.length > 0
? Array.from(client.plugins).filter((plugin) =>
selectedPlugins.includes(plugin),
)
: client.plugins
: [];
for (const plugin of selectedFilteredPlugins) {
if (!client.plugins.has(plugin)) {
// Ignore clients which doesn't support the selected plugins.
continue;
}
const pluginClass =
plugins.clientPlugins.get(plugin) || plugins.devicePlugins.get(plugin);
if (pluginClass) {
const key = pluginKey(client.id, plugin);
pluginsToProcess.push({
pluginKey: key,
client,
pluginId: plugin,
pluginName: getPluginTitle(pluginClass),
});
}
}
}
return pluginsToProcess;
}
async function getStoreExport(
store: MiddlewareAPI,
statusUpdate: (msg: string) => void = () => {},
idler: Idler,
): Promise<{
exportData: ExportType;
fetchMetaDataErrors: {[plugin: string]: Error} | null;
}> {
let state = store.getState();
const {clients, selectedAppId, selectedDevice} = state.connections;
const pluginsToProcess = determinePluginsToProcess(
Array.from(clients.values()),
selectedDevice,
state.plugins,
);
statusUpdate?.('Preparing to process data queues for plugins...');
await processQueues(store, pluginsToProcess, statusUpdate, idler);
state = store.getState();
statusUpdate && statusUpdate('Preparing to fetch metadata from client...');
const fetchMetaDataMarker = `${EXPORT_FLIPPER_TRACE_EVENT}:fetch-meta-data`;
performance.mark(fetchMetaDataMarker);
const client = clients.get(selectedAppId!);
const pluginStates2 = pluginsToProcess
? await exportSandyPluginStates(pluginsToProcess, idler, statusUpdate)
: {};
getLogger().trackTimeSince(fetchMetaDataMarker, fetchMetaDataMarker, {
plugins: state.plugins.selectedPlugins,
});
const {activeNotifications} = state.notifications;
const {devicePlugins, clientPlugins} = state.plugins;
const exportData = await processStore(
{
activeNotifications,
device: selectedDevice,
pluginStates2,
clients: client ? [client.toJSON()] : [],
devicePlugins,
clientPlugins,
salt: uuidv4(),
selectedPlugins: state.plugins.selectedPlugins,
statusUpdate,
},
idler,
);
return {exportData, fetchMetaDataErrors: null};
}
export async function exportStore(
store: MiddlewareAPI,
includeSupportDetails?: boolean,
idler: Idler = new TestIdler(true),
statusUpdate: (msg: string) => void = () => {},
): Promise<{
serializedString: string;
fetchMetaDataErrors: {
[plugin: string]: Error;
} | null;
exportStoreData: ExportType;
}> {
getLogger().track('usage', EXPORT_FLIPPER_TRACE_EVENT);
performance.mark(EXPORT_FLIPPER_TRACE_TIME_SERIALIZATION_EVENT);
statusUpdate && statusUpdate('Preparing to export Flipper data...');
const state = store.getState();
const {exportData, fetchMetaDataErrors} = await getStoreExport(
store,
statusUpdate,
idler,
);
if (includeSupportDetails) {
exportData.supportRequestDetails = {
...state.supportForm?.supportFormV2,
appName:
state.connections.selectedAppId == null
? ''
: deconstructClientId(state.connections.selectedAppId).app,
};
}
statusUpdate && statusUpdate('Serializing Flipper data...');
const serializedString = JSON.stringify(exportData);
if (serializedString.length <= 0) {
throw new Error('Serialize function returned empty string');
}
getLogger().trackTimeSince(
EXPORT_FLIPPER_TRACE_TIME_SERIALIZATION_EVENT,
EXPORT_FLIPPER_TRACE_TIME_SERIALIZATION_EVENT,
{
plugins: state.plugins.selectedPlugins,
},
);
return {serializedString, fetchMetaDataErrors, exportStoreData: exportData};
}
export const exportStoreToFile = (
exportFilePath: string,
store: MiddlewareAPI,
includeSupportDetails: boolean,
idler?: Idler,
statusUpdate?: (msg: string) => void,
): Promise<{
fetchMetaDataErrors: {
[plugin: string]: Error;
} | null;
}> => {
return exportStore(store, includeSupportDetails, idler, statusUpdate).then(
async ({serializedString, fetchMetaDataErrors}) => {
await fs.writeFile(exportFilePath, serializedString);
store.dispatch(resetSupportFormV2State());
return {fetchMetaDataErrors};
},
);
};
export function importDataToStore(source: string, data: string, store: Store) {
getLogger().track('usage', IMPORT_FLIPPER_TRACE_EVENT);
const json: ExportType = JSON.parse(data);
const {device, clients, supportRequestDetails, deviceScreenshot} = json;
if (device == null) {
return;
}
const {serial, deviceType, title, os} = device;
const archivedDevice = new ArchivedDevice({
serial,
deviceType,
title,
os,
screenshotHandle: deviceScreenshot,
source,
supportRequestDetails,
});
archivedDevice.loadDevicePlugins(
store.getState().plugins.devicePlugins,
store.getState().connections.enabledDevicePlugins,
device.pluginStates,
);
store.dispatch({
type: 'REGISTER_DEVICE',
payload: archivedDevice,
});
store.dispatch({
type: 'SELECT_DEVICE',
payload: archivedDevice,
});
clients.forEach((client: {id: string; query: ClientQuery}) => {
const sandyPluginStates = json.pluginStates2[client.id] || {};
const clientPlugins = new Set(Object.keys(sandyPluginStates));
store.dispatch({
type: 'NEW_CLIENT',
payload: new Client(
client.id,
client.query,
null,
getLogger(),
store,
clientPlugins,
archivedDevice,
).initFromImport(sandyPluginStates),
});
});
if (supportRequestDetails) {
store.dispatch(
// Late require to avoid circular dependency issue
setStaticView(require('../fb-stubs/SupportRequestDetails').default),
);
}
}
export const importFileToStore = (file: string, store: Store) => {
fs.readFile(file, 'utf8', (err, data) => {
if (err) {
console.error(
`[exportData] importFileToStore for file ${file} failed:`,
err,
);
return;
}
importDataToStore(file, data, store);
});
};
export function canOpenDialog() {
return !!getRenderHostInstance().showOpenDialog;
}
export function showOpenDialog(store: Store) {
return getRenderHostInstance()
.showOpenDialog?.({
filter: {extensions: ['flipper', 'json', 'txt'], name: 'Flipper files'},
})
.then((filePath) => {
if (filePath) {
tryCatchReportPlatformFailures(() => {
importFileToStore(filePath, store);
}, `${IMPORT_FLIPPER_TRACE_EVENT}:UI`);
}
});
}
export function canFileExport() {
return !!getRenderHostInstance().showSaveDialog;
}
export async function startFileExport(dispatch: Store['dispatch']) {
const file = await getRenderHostInstance().showSaveDialog?.({
title: 'FlipperExport',
defaultPath: path.join(os.homedir(), 'FlipperExport.flipper'),
});
if (!file) {
return;
}
const plugins = await selectPlugins();
if (plugins === false) {
return; // cancelled
}
// TODO: no need to put this in the store,
// need to be cleaned up later in combination with SupportForm
dispatch(selectedPlugins(plugins));
Dialog.showModal((onHide) => (
<ShareSheetExportFile onHide={onHide} file={file} logger={getLogger()} />
));
}
export async function startLinkExport(dispatch: Store['dispatch']) {
const plugins = await selectPlugins();
if (plugins === false) {
return; // cancelled
}
// TODO: no need to put this in the store,
// need to be cleaned up later in combination with SupportForm
dispatch(selectedPlugins(plugins));
Dialog.showModal((onHide) => (
<ShareSheetExportUrl onHide={onHide} logger={getLogger()} />
));
}
async function selectPlugins() {
return await Dialog.select<string[]>({
title: 'Select plugins to export',
defaultValue: [],
renderer: (value, onChange, onCancel) => (
<ExportDataPluginSheet
onHide={onCancel}
selectedPlugins={value}
setSelectedPlugins={onChange}
/>
),
});
}

View File

@@ -0,0 +1,28 @@
/**
* 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 util from 'util';
import {exec as execImport} from 'child_process';
const exec = util.promisify(execImport);
const cmd = 'klist --json';
const endWith = '@THEFACEBOOK.COM';
export async function isFBEmployee(): Promise<boolean> {
return exec(cmd).then(
(stdobj: {stderr: string; stdout: string}) => {
const principal = String(JSON.parse(stdobj.stdout).principal);
return principal.endsWith(endWith);
},
(_err: Error) => {
return false;
},
);
}

View File

@@ -0,0 +1,71 @@
/**
* 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 {_setFlipperLibImplementation} from 'flipper-plugin';
import type {Logger} from 'flipper-common';
import type {Store} from '../reducers';
import createPaste from '../fb-stubs/createPaste';
import GK from '../fb-stubs/GK';
import type BaseDevice from '../devices/BaseDevice';
import constants from '../fb-stubs/constants';
import {addNotification} from '../reducers/notifications';
import {deconstructPluginKey} from 'flipper-common';
import {DetailSidebarImpl} from '../sandy-chrome/DetailSidebarImpl';
import {RenderHost} from '../RenderHost';
import {setMenuEntries} from '../reducers/connections';
export function initializeFlipperLibImplementation(
renderHost: RenderHost,
store: Store,
logger: Logger,
) {
_setFlipperLibImplementation({
isFB: !constants.IS_PUBLIC_BUILD,
logger,
enableMenuEntries(entries) {
store.dispatch(setMenuEntries(entries));
},
createPaste,
GK(gatekeeper: string) {
return GK.get(gatekeeper);
},
selectPlugin(device, client, pluginId, deeplink) {
store.dispatch({
type: 'SELECT_PLUGIN',
payload: {
selectedPlugin: pluginId,
selectedDevice: device as BaseDevice,
selectedAppId: client ? client.id : null,
deepLinkPayload: deeplink,
time: Date.now(),
},
});
},
writeTextToClipboard: renderHost.writeTextToClipboard,
openLink: renderHost.openLink,
showNotification(pluginId, notification) {
const parts = deconstructPluginKey(pluginId);
store.dispatch(
addNotification({
pluginId: parts.pluginName,
client: parts.client,
notification,
}),
);
},
DetailsSidebarImplementation: DetailSidebarImpl,
showSaveDialog: renderHost.showSaveDialog,
showOpenDialog: renderHost.showOpenDialog,
showSelectDirectoryDialog: renderHost.showSelectDirectoryDialog,
paths: {
appPath: renderHost.paths.appPath,
homePath: renderHost.paths.homePath,
},
});
}

View File

@@ -0,0 +1,45 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export type Rect = {
top: number;
left: number;
height: number;
width: number;
};
export function isOverlappedRect(a: Rect, b: Rect): boolean {
const aRight = a.left + a.width;
const bRight = b.left + b.width;
const aBottom = a.top + a.height;
const bBottom = b.top + b.height;
return (
a.left < bRight && b.left < aRight && a.top < bBottom && b.top < aBottom
);
}
export function getDistanceRect(a: Rect, b: Rect): number {
const mostLeft = a.left < b.left ? a : b;
const mostRight = b.left < a.left ? a : b;
let xDifference =
mostLeft.left === mostRight.left
? 0
: mostRight.left - (mostLeft.left + mostLeft.width);
xDifference = Math.max(0, xDifference);
const upper = a.top < b.top ? a : b;
const lower = b.top < a.top ? a : b;
let yDifference =
upper.top === lower.top ? 0 : lower.top - (upper.top + upper.height);
yDifference = Math.max(0, yDifference);
return Math.min(xDifference, yDifference);
}

View File

@@ -0,0 +1,20 @@
/**
* 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 {IconSize} from '../ui/components/Glyph';
declare function getIconURL(
name: string,
size?: IconSize,
density?: number,
): string;
declare const ICONS: {
[key: string]: Array<IconSize>;
};

View File

@@ -0,0 +1,173 @@
/**
* 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
*/
// We should get rid of sync use entirely but until then the
// methods are marked as such.
/* eslint-disable node/no-sync */
import fs from 'fs';
import path from 'path';
import {getRenderHostInstance} from '../RenderHost';
import {getStaticPath} from './pathUtils';
const AVAILABLE_SIZES = [8, 10, 12, 16, 18, 20, 24, 32];
const DENSITIES = [1, 1.5, 2, 3, 4];
function getIconsPath() {
return getStaticPath('icons.json');
}
export type Icons = {
[key: string]: number[];
};
let _icons: Icons | undefined;
export function getIconsSync(): Icons {
return (
_icons! ??
(_icons = JSON.parse(fs.readFileSync(getIconsPath(), {encoding: 'utf8'})))
);
}
// Takes a string like 'star', or 'star-outline', and converts it to
// {trimmedName: 'star', variant: 'filled'} or {trimmedName: 'star', variant: 'outline'}
function getIconPartsFromName(icon: string): {
trimmedName: string;
variant: 'outline' | 'filled';
} {
const isOutlineVersion = icon.endsWith('-outline');
const trimmedName = isOutlineVersion ? icon.replace('-outline', '') : icon;
const variant = isOutlineVersion ? 'outline' : 'filled';
return {trimmedName: trimmedName, variant: variant};
}
function getIconFileName(
icon: {trimmedName: string; variant: 'outline' | 'filled'},
size: number,
density: number,
) {
return `${icon.trimmedName}-${icon.variant}-${size}@${density}x.png`;
}
export function buildLocalIconPath(
name: string,
size: number,
density: number,
) {
const icon = getIconPartsFromName(name);
return path.join('icons', getIconFileName(icon, size, density));
}
export function buildLocalIconURL(name: string, size: number, density: number) {
const icon = getIconPartsFromName(name);
return `icons/${getIconFileName(icon, size, density)}`;
}
export function buildIconURLSync(name: string, size: number, density: number) {
const icon = getIconPartsFromName(name);
// eslint-disable-next-line prettier/prettier
const url = `https://facebook.com/assets/?name=${
icon.trimmedName
}&variant=${
icon.variant
}&size=${size}&set=facebook_icons&density=${density}x`;
if (
typeof window !== 'undefined' &&
(!getIconsSync()[name] || !getIconsSync()[name].includes(size))
) {
// From utils/isProduction
const isProduction = !/node_modules[\\/]electron[\\/]/.test(
getRenderHostInstance().paths.execPath,
);
if (!isProduction) {
const existing = getIconsSync()[name] || (getIconsSync()[name] = []);
if (!existing.includes(size)) {
// Check if that icon actually exists!
fetch(url)
.then((res) => {
if (res.status === 200 && !existing.includes(size)) {
// the icon exists
existing.push(size);
existing.sort();
fs.writeFileSync(
getIconsPath(),
JSON.stringify(getIconsSync(), null, 2),
'utf8',
);
console.warn(
`Added uncached icon "${name}: [${size}]" to /static/icons.json. Restart Flipper to apply the change.`,
);
} else {
throw new Error(
// eslint-disable-next-line prettier/prettier
`Trying to use icon '${name}' with size ${size} and density ${density}, however the icon doesn't seem to exists at ${url}: ${
res.status
}`,
);
}
})
.catch((e) => console.error(e));
}
} else {
console.warn(
`Using uncached icon: "${name}: [${size}]". Add it to /static/icons.json to preload it.`,
);
}
}
return url;
}
export function getIconURLSync(
name: string,
size: number,
density: number,
basePath: string = getRenderHostInstance().paths.appPath,
) {
if (name.indexOf('/') > -1) {
return name;
}
let requestedSize = size;
if (!AVAILABLE_SIZES.includes(size)) {
// find the next largest size
const possibleSize = AVAILABLE_SIZES.find((size) => {
return size > requestedSize;
});
// set to largest size if the real size is larger than what we have
if (possibleSize == null) {
requestedSize = Math.max(...AVAILABLE_SIZES);
} else {
requestedSize = possibleSize;
}
}
if (!DENSITIES.includes(density)) {
// find the next largest size
const possibleDensity = DENSITIES.find((scale) => {
return scale > density;
});
// set to largest size if the real size is larger than what we have
if (possibleDensity == null) {
density = Math.max(...DENSITIES);
} else {
density = possibleDensity;
}
}
// resolve icon locally if possible
const iconPath = path.join(basePath, buildLocalIconPath(name, size, density));
if (fs.existsSync(iconPath)) {
return buildLocalIconURL(name, size, density);
}
return buildIconURLSync(name, requestedSize, density);
}

View File

@@ -0,0 +1,167 @@
/**
* 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
*/
// Use of sync methods is cached.
/* eslint-disable node/no-sync */
import os from 'os';
import isProduction from './isProduction';
import fs from 'fs-extra';
import {getStaticPath} from './pathUtils';
import type {State, Store} from '../reducers/index';
import {sideEffect} from './sideEffect';
import {Logger, isTest, deconstructClientId} from 'flipper-common';
type PlatformInfo = {
arch: string;
platform: string;
unixname: string;
versions: {
[key: string]: string | undefined;
};
};
export type SelectionInfo = {
plugin: string | null;
pluginName: string | null;
pluginVersion: string | null;
pluginEnabled: boolean | null;
app: string | null;
os: string | null;
device: string | null;
deviceName: string | null;
deviceSerial: string | null;
deviceType: string | null;
archived: boolean | null;
};
export type Info = PlatformInfo & {
selection: SelectionInfo;
};
let platformInfo: PlatformInfo | undefined;
let selection: SelectionInfo = {
plugin: null,
pluginName: null,
pluginVersion: null,
pluginEnabled: null,
app: null,
os: null,
device: null,
deviceName: null,
deviceSerial: null,
deviceType: null,
archived: null,
};
export default (store: Store, _logger: Logger) => {
return sideEffect(
store,
{
name: 'recomputeSelectionInfo',
throttleMs: 0,
noTimeBudgetWarns: true,
runSynchronously: true,
fireImmediately: true,
},
getSelectionInfo,
(newSelection, _store) => {
selection = newSelection;
},
);
};
/**
* This method builds up some metadata about the users environment that we send
* on bug reports, analytic events, errors etc.
*/
export function getInfo(): Info {
if (!platformInfo) {
platformInfo = {
arch: process.arch,
platform: process.platform,
unixname: os.userInfo().username,
versions: {
electron: process.versions.electron,
node: process.versions.node,
platform: os.release(),
},
};
}
return {
...platformInfo,
selection,
};
}
let APP_VERSION: string | undefined;
export function getAppVersion(): string {
return (APP_VERSION =
APP_VERSION ??
process.env.FLIPPER_FORCE_VERSION ??
(isTest()
? '0.0.0'
: (isProduction()
? fs.readJsonSync(getStaticPath('package.json'), {
throws: false,
})?.version
: require('../../package.json').version) ?? '0.0.0'));
}
export function stringifyInfo(info: Info): string {
const lines = [
`Platform: ${info.platform} ${info.arch}`,
`Unixname: ${info.unixname}`,
`Versions:`,
];
for (const key in info.versions) {
lines.push(` ${key}: ${String(info.versions[key])}`);
}
return lines.join('\n');
}
export function getSelectionInfo({
plugins: {clientPlugins, devicePlugins, loadedPlugins},
connections: {
selectedAppId,
selectedPlugin,
enabledDevicePlugins,
enabledPlugins,
selectedDevice,
},
}: State): SelectionInfo {
const clientIdParts = selectedAppId
? deconstructClientId(selectedAppId)
: null;
const loadedPlugin = selectedPlugin
? loadedPlugins.get(selectedPlugin)
: null;
const pluginEnabled =
!!selectedPlugin &&
((enabledDevicePlugins.has(selectedPlugin) &&
devicePlugins.has(selectedPlugin)) ||
(clientIdParts &&
enabledPlugins[clientIdParts.app]?.includes(selectedPlugin) &&
clientPlugins.has(selectedPlugin)));
return {
plugin: selectedPlugin || null,
pluginName: loadedPlugin?.name || null,
pluginVersion: loadedPlugin?.version || null,
pluginEnabled,
app: clientIdParts?.app || null,
device: selectedDevice?.title || null,
deviceName: clientIdParts?.device || null,
deviceSerial: selectedDevice?.serial || null,
deviceType: selectedDevice?.deviceType || null,
os: selectedDevice?.os || null,
archived: selectedDevice?.isArchived || false,
};
}

View File

@@ -0,0 +1,25 @@
/**
* 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 {PluginDetails} from 'flipper-plugin-lib';
import semver from 'semver';
import GK from '../fb-stubs/GK';
import {getAppVersion} from './info';
export function isPluginCompatible(plugin: PluginDetails) {
const flipperVersion = getAppVersion();
return (
GK.get('flipper_disable_plugin_compatibility_checks') ||
flipperVersion === '0.0.0' ||
!plugin.engines?.flipper ||
semver.lte(plugin.engines?.flipper, flipperVersion)
);
}
export default isPluginCompatible;

View File

@@ -0,0 +1,47 @@
/**
* 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 {ConcretePluginDetails} from 'flipper-plugin-lib';
import semver from 'semver';
import isPluginCompatible from './isPluginCompatible';
export function isPluginVersionMoreRecent(
versionDetails: ConcretePluginDetails,
otherVersionDetails: ConcretePluginDetails,
) {
const isPlugin1Compatible = isPluginCompatible(versionDetails);
const isPlugin2Compatible = isPluginCompatible(otherVersionDetails);
// prefer compatible plugins
if (isPlugin1Compatible && !isPlugin2Compatible) return true;
if (!isPlugin1Compatible && isPlugin2Compatible) return false;
// prefer plugins with greater version
if (semver.gt(versionDetails.version, otherVersionDetails.version)) {
return true;
}
if (
semver.eq(versionDetails.version, otherVersionDetails.version) &&
versionDetails.isBundled
) {
// prefer bundled versions
return true;
}
if (
semver.eq(versionDetails.version, otherVersionDetails.version) &&
versionDetails.isActivatable &&
!otherVersionDetails.isActivatable
) {
// prefer locally available versions to the versions available remotely on marketplace
return true;
}
return false;
}
export default isPluginVersionMoreRecent;

View File

@@ -0,0 +1,14 @@
/**
* 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 {getRenderHostInstance} from '../RenderHost';
export default function isProduction() {
return getRenderHostInstance().isProduction;
}

View File

@@ -0,0 +1,98 @@
/**
* 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 {readFile, pathExists, mkdirp, writeFile} from 'fs-extra';
import path from 'path';
/**
* Redux-persist storage engine for storing state in a human readable JSON file.
*
* Differs from the usual engines in two ways:
* * The key is ignored. This storage will only hold one key, so each setItem() call will overwrite the previous one.
* * Stored files are "human readable". Redux-persist calls storage engines with preserialized values that contain escaped strings inside json.
* This engine re-serializes them by parsing the inner strings to store them as top-level json.
* Transforms haven't been used because they operate before serialization, so all serialized values would still end up as strings.
*/
export default class JsonFileStorage {
filepath: string;
constructor(filepath: string) {
this.filepath = filepath;
}
private parseFile(): Promise<any> {
return readFile(this.filepath)
.then((buffer) => buffer.toString())
.then(this.deserializeValue)
.catch(async (e) => {
console.warn(
`Failed to read settings file: "${this.filepath}". ${e}. Replacing file with default settings.`,
);
await this.writeContents(prettyStringify({}));
return {};
});
}
getItem(_key: string, callback?: (_: any) => any): Promise<any> {
const promise = this.parseFile();
callback && promise.then(callback);
return promise;
}
// Sets a new value and returns the value that was PREVIOUSLY set.
// This mirrors the behaviour of the localForage storage engine.
// Not thread-safe.
setItem(_key: string, value: any, callback?: (_: any) => any): Promise<any> {
const originalValue = this.parseFile();
const writePromise = originalValue.then((_) =>
this.writeContents(this.serializeValue(value)),
);
return Promise.all([originalValue, writePromise]).then(([o, _]) => {
callback && callback(o);
return o;
});
}
removeItem(_key: string, callback?: () => any): Promise<void> {
return this.writeContents(prettyStringify({}))
.then((_) => callback && callback())
.then(() => {});
}
serializeValue(value: string): string {
const reconstructedObject = Object.entries(JSON.parse(value))
.map(([k, v]: [string, unknown]) => [k, JSON.parse(v as string)])
.reduce((acc: {[key: string]: any}, cv) => {
acc[cv[0]] = cv[1];
return acc;
}, {});
return prettyStringify(reconstructedObject);
}
deserializeValue(value: string): string {
const reconstructedObject = Object.entries(JSON.parse(value))
.map(([k, v]: [string, unknown]) => [k, JSON.stringify(v)])
.reduce((acc: {[key: string]: string}, cv) => {
acc[cv[0]] = cv[1];
return acc;
}, {});
return JSON.stringify(reconstructedObject);
}
writeContents(content: string): Promise<void> {
const dir = path.dirname(this.filepath);
return pathExists(dir)
.then((dirExists) => (dirExists ? Promise.resolve() : mkdirp(dir)))
.then(() => writeFile(this.filepath, content));
}
}
function prettyStringify(data: Object) {
return JSON.stringify(data, null, 2);
}

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export type JSON = JSONPrimitive | JSONArray | JSONObject;
export type JSONPrimitive = null | boolean | number | string;
export type JSONArray = JSON[];
export type JSONObject = {[key: string]: JSON};

View File

@@ -0,0 +1,40 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import path from 'path';
import os from 'os';
import xdg from 'xdg-basedir';
import {ProcessConfig} from './processConfig';
import {Store} from '../reducers/index';
// There is some disagreement among the XDG Base Directory implementations
// whether to use ~/Library/Preferences or ~/.config on MacOS. The Launcher
// expects the former, whereas `xdg-basedir` implements the latter.
const xdgConfigDir = () =>
os.platform() === 'darwin'
? path.join(os.homedir(), 'Library', 'Preferences')
: xdg.config || path.join(os.homedir(), '.config');
export const launcherConfigDir = () =>
path.join(
xdgConfigDir(),
os.platform() == 'darwin' ? 'rs.flipper-launcher' : 'flipper-launcher',
);
export function initLauncherHooks(config: ProcessConfig, store: Store) {
if (config.launcherMsg) {
store.dispatch({
type: 'LAUNCHER_MSG',
payload: {
severity: 'warning',
message: config.launcherMsg,
},
});
}
}

View File

@@ -0,0 +1,89 @@
/**
* 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 fs from 'fs-extra';
import path from 'path';
import TOML, {JsonMap} from '@iarna/toml';
import {Storage} from 'redux-persist/es/types';
import {
defaultLauncherSettings,
LauncherSettings,
} from '../reducers/launcherSettings';
import ReleaseChannel from '../ReleaseChannel';
export default class LauncherSettingsStorage implements Storage {
constructor(readonly filepath: string) {}
async getItem(_key: string): Promise<any> {
return await this.parseFile();
}
async setItem(_key: string, value: LauncherSettings): Promise<any> {
const originalValue = await this.parseFile();
await this.writeFile(value);
return originalValue;
}
removeItem(_key: string): Promise<void> {
return this.writeFile(defaultLauncherSettings);
}
private async parseFile(): Promise<LauncherSettings> {
try {
const content = (await fs.readFile(this.filepath)).toString();
return deserialize(content);
} catch (e) {
console.warn(
`Failed to read settings file: "${this.filepath}". ${e}. Replacing file with default settings.`,
);
await this.writeFile(defaultLauncherSettings);
return defaultLauncherSettings;
}
}
private async writeFile(value: LauncherSettings): Promise<void> {
this.ensureDirExists();
const content = serialize(value);
return fs.writeFile(this.filepath, content);
}
private async ensureDirExists(): Promise<void> {
const dir = path.dirname(this.filepath);
const exists = await fs.pathExists(dir);
if (!exists) {
await fs.mkdir(dir, {recursive: true});
}
}
}
interface FormattedSettings {
ignore_local_pin?: boolean;
release_channel?: ReleaseChannel;
}
function serialize(value: LauncherSettings): string {
const {ignoreLocalPin, releaseChannel, ...rest} = value;
const formattedSettings: FormattedSettings = {
...rest,
ignore_local_pin: ignoreLocalPin,
release_channel: releaseChannel,
};
return TOML.stringify(formattedSettings as JsonMap);
}
function deserialize(content: string): LauncherSettings {
const {ignore_local_pin, release_channel, ...rest} = TOML.parse(
content,
) as FormattedSettings;
return {
...rest,
ignoreLocalPin: !!ignore_local_pin,
releaseChannel: release_channel ?? ReleaseChannel.DEFAULT,
};
}

View File

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

View File

@@ -0,0 +1,129 @@
/**
* 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 {FlipperDevicePlugin} from '../plugin';
import type {MiddlewareAPI} from '../reducers/index';
import {
clearMessageQueue,
queueMessages,
Message,
DEFAULT_MAX_QUEUE_SIZE,
} from '../reducers/pluginMessageQueue';
import {IdlerImpl} from './Idler';
import {isPluginEnabled, getSelectedPluginKey} from '../reducers/connections';
import {deconstructPluginKey} from 'flipper-common';
import {defaultEnabledBackgroundPlugins} from './pluginUtils';
import {batch, Idler, _SandyPluginInstance} from 'flipper-plugin';
import {addBackgroundStat} from './pluginStats';
function processMessagesImmediately(
plugin: _SandyPluginInstance,
messages: Message[],
) {
const reducerStartTime = Date.now();
try {
plugin.receiveMessages(messages);
addBackgroundStat(plugin.definition.id, Date.now() - reducerStartTime);
} catch (e) {
console.error(
`Failed to process event for plugin ${plugin.definition.id}`,
e,
);
}
}
export function processMessagesLater(
store: MiddlewareAPI,
pluginKey: string,
plugin: _SandyPluginInstance,
messages: Message[],
) {
const pluginId = plugin.definition.id;
const isSelected =
pluginKey === getSelectedPluginKey(store.getState().connections);
switch (true) {
// Navigation events are always processed immediately, to make sure the navbar stays up to date, see also T69991064
case pluginId === 'Navigation':
case isSelected && getPendingMessages(store, pluginKey).length === 0:
processMessagesImmediately(plugin, messages);
break;
case isSelected:
case plugin instanceof _SandyPluginInstance:
case plugin instanceof FlipperDevicePlugin:
case (plugin as any).prototype instanceof FlipperDevicePlugin:
case isPluginEnabled(
store.getState().connections.enabledPlugins,
store.getState().connections.enabledDevicePlugins,
deconstructPluginKey(pluginKey).client,
pluginId,
):
store.dispatch(
queueMessages(pluginKey, messages, DEFAULT_MAX_QUEUE_SIZE),
);
break;
default:
// In all other cases, messages will be dropped...
if (!defaultEnabledBackgroundPlugins.includes(pluginId))
console.warn(
`Received message for disabled plugin ${pluginId}, dropping..`,
);
}
}
export async function processMessageQueue(
plugin: _SandyPluginInstance,
pluginKey: string,
store: MiddlewareAPI,
progressCallback?: (progress: {current: number; total: number}) => void,
idler: Idler = new IdlerImpl(),
): Promise<boolean> {
const total = getPendingMessages(store, pluginKey).length;
let progress = 0;
do {
const messages = getPendingMessages(store, pluginKey);
if (!messages.length) {
break;
}
// there are messages to process! lets do so until we have to idle
let offset = 0;
batch(() => {
do {
// Optimization: we could send a batch of messages here
processMessagesImmediately(plugin, [messages[offset]]);
offset++;
progress++;
progressCallback?.({
total: Math.max(total, progress),
current: progress,
});
} while (offset < messages.length && !idler.shouldIdle());
// save progress
// by writing progress away first and then idling, we make sure this logic is
// resistent to kicking off this process twice; grabbing, processing messages, saving state is done synchronosly
// until the idler has to break
store.dispatch(clearMessageQueue(pluginKey, offset));
});
if (idler.isCancelled()) {
return false;
}
await idler.idle();
// new messages might have arrived, so keep looping
} while (getPendingMessages(store, pluginKey).length);
return true;
}
function getPendingMessages(
store: MiddlewareAPI,
pluginKey: string,
): Message[] {
return store.getState().pluginMessageQueue[pluginKey] || [];
}

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 {notification, Typography} from 'antd';
import React from 'react';
import {FlipperDevTools} from '../chrome/FlipperDevTools';
import {setStaticView} from '../reducers/connections';
import {getStore} from '../store';
import {Layout} from '../ui';
import {v4 as uuid} from 'uuid';
const {Link} = Typography;
export function showErrorNotification(message: string, description?: string) {
const key = uuid();
notification.error({
key,
message,
description: (
<Layout.Container gap>
{description ?? <p>{description}</p>}
<p>
See{' '}
<Link
onClick={() => {
getStore().dispatch(setStaticView(FlipperDevTools));
notification.close(key);
}}>
logs
</Link>{' '}
for details.
</p>
</Layout.Container>
),
placement: 'bottomLeft',
});
}

View File

@@ -0,0 +1,33 @@
/**
* 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 lodash from 'lodash';
import path from 'path';
import fs from 'fs';
import {promisify} from 'util';
import {getRenderHostInstance} from '../RenderHost';
const getPackageJSON = async () => {
const base = getRenderHostInstance().paths.appPath;
const content = await promisify(fs.readFile)(
path.join(base, 'package.json'),
'utf-8',
);
return JSON.parse(content);
};
export const readCurrentRevision: () => Promise<string | undefined> =
lodash.memoize(async () => {
// This is provided as part of the bundling process for headless.
if (global.__REVISION__) {
return global.__REVISION__;
}
const json = await getPackageJSON();
return json.revision;
});

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
// We use sync access once per startup.
/* eslint-disable node/no-sync */
import path from 'path';
import fs from 'fs';
import config from '../fb-stubs/config';
import {getRenderHostInstance} from '../RenderHost';
export function getStaticPath(
relativePath: string = '.',
{asarUnpacked}: {asarUnpacked: boolean} = {asarUnpacked: false},
) {
const staticDir = getRenderHostInstance().paths.staticPath;
const absolutePath = path.resolve(staticDir, relativePath);
// Unfortunately, path.resolve, fs.pathExists, fs.read etc do not automatically work with asarUnpacked files.
// All these functions still look for files in "app.asar" even if they are unpacked.
// Looks like automatic resolving for asarUnpacked files only work for "child_process" module.
// So we're using a hack here to actually look to "app.asar.unpacked" dir instead of app.asar package.
return asarUnpacked
? absolutePath.replace('app.asar', 'app.asar.unpacked')
: absolutePath;
}
export function getChangelogPath() {
const changelogPath = getStaticPath(config.isFBBuild ? 'facebook' : '.');
if (fs.existsSync(changelogPath)) {
return changelogPath;
} else {
throw new Error('Changelog path path does not exist: ' + changelogPath);
}
}

View File

@@ -0,0 +1,22 @@
/**
* 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 {Persistor} from 'redux-persist';
let _persistor: Persistor | null = null;
export function setPersistor(persistor: Persistor) {
_persistor = persistor;
}
export function flush(): Promise<void> {
return _persistor
? _persistor.flush()
: Promise.reject(new Error('Persistor not set.'));
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export function getPluginKey(
selectedAppId: string | null | undefined,
baseDevice: {serial: string} | null | undefined,
pluginID: string,
): string {
if (selectedAppId) {
return `${selectedAppId}#${pluginID}`;
}
if (baseDevice) {
// If selected App is not defined, then the plugin is a device plugin
return `${baseDevice.serial}#${pluginID}`;
}
return `unknown#${pluginID}`;
}
export const pluginKey = (serial: string, pluginName: string): string => {
return `${serial}#${pluginName}`;
};

View File

@@ -0,0 +1,123 @@
/**
* 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 {onBytesReceived} from '../dispatcher/tracking';
type StatEntry = {
cpuTimeTotal: number; // Total time spend in persisted Reducer
cpuTimeDelta: number; // Time spend since previous tracking tick
messageCountTotal: number; // amount of message received for this plugin
messageCountDelta: number; // amout of messages received since previous tracking tick
maxTime: number; // maximum time spend in a single reducer call
bytesReceivedTotal: number; // Bytes received
bytesReceivedDelta: number; // Bytes received since last tick
};
const MAX_BACKGROUND_TASK_TIME = 25;
const pluginBackgroundStats = new Map<string, StatEntry>();
export function resetPluginBackgroundStatsDelta() {
pluginBackgroundStats.forEach((stat) => {
stat.cpuTimeDelta = 0;
stat.messageCountDelta = 0;
stat.bytesReceivedDelta = 0;
});
}
onBytesReceived((plugin: string, bytes: number) => {
if (!pluginBackgroundStats.has(plugin)) {
pluginBackgroundStats.set(plugin, createEmptyStat());
}
const stat = pluginBackgroundStats.get(plugin)!;
stat.bytesReceivedTotal += bytes;
stat.bytesReceivedDelta += bytes;
});
export function getPluginBackgroundStats(): {
cpuTime: number; // amount of ms cpu used since the last stats (typically every minute)
bytesReceived: number;
byPlugin: {[plugin: string]: StatEntry};
} {
let cpuTime: number = 0;
let bytesReceived: number = 0;
const byPlugin = Array.from(pluginBackgroundStats.entries()).reduce(
(aggregated, [pluginName, data]) => {
cpuTime += data.cpuTimeDelta;
bytesReceived += data.bytesReceivedDelta;
aggregated[pluginName] = data;
return aggregated;
},
{} as {[plugin: string]: StatEntry},
);
return {
cpuTime,
bytesReceived,
byPlugin,
};
}
if (window) {
// @ts-ignore
window.flipperPrintPluginBackgroundStats = () => {
console.table(
Array.from(pluginBackgroundStats.entries()).map(
([
plugin,
{
cpuTimeDelta,
cpuTimeTotal,
messageCountDelta,
messageCountTotal,
maxTime,
bytesReceivedTotal,
bytesReceivedDelta,
},
]) => ({
plugin,
cpuTimeTotal,
messageCountTotal,
cpuTimeDelta,
messageCountDelta,
maxTime,
bytesReceivedTotal,
bytesReceivedDelta,
}),
),
);
};
}
function createEmptyStat(): StatEntry {
return {
cpuTimeDelta: 0,
cpuTimeTotal: 0,
messageCountDelta: 0,
messageCountTotal: 0,
maxTime: 0,
bytesReceivedTotal: 0,
bytesReceivedDelta: 0,
};
}
export function addBackgroundStat(plugin: string, cpuTime: number) {
if (!pluginBackgroundStats.has(plugin)) {
pluginBackgroundStats.set(plugin, createEmptyStat());
}
const stat = pluginBackgroundStats.get(plugin)!;
stat.cpuTimeDelta += cpuTime;
stat.cpuTimeTotal += cpuTime;
stat.messageCountDelta += 1;
stat.messageCountTotal += 1;
stat.maxTime = Math.max(stat.maxTime, cpuTime);
if (cpuTime > MAX_BACKGROUND_TASK_TIME) {
console.warn(
`Plugin ${plugin} took too much time while doing background: ${cpuTime}ms. Handling background messages should take less than ${MAX_BACKGROUND_TASK_TIME}ms.`,
);
}
}

View File

@@ -0,0 +1,457 @@
/**
* 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 {PluginDefinition} from '../plugin';
import type {State, Store} from '../reducers';
import type {State as PluginsState} from '../reducers/plugins';
import type BaseDevice from '../devices/BaseDevice';
import type Client from '../Client';
import type {
ActivatablePluginDetails,
BundledPluginDetails,
DownloadablePluginDetails,
PluginDetails,
} from 'flipper-plugin-lib';
import {getLatestCompatibleVersionOfEachPlugin} from '../dispatcher/plugins';
import {getPluginKey} from './pluginKey';
export type PluginLists = {
devicePlugins: PluginDefinition[];
metroPlugins: PluginDefinition[];
enabledPlugins: PluginDefinition[];
disabledPlugins: PluginDefinition[];
unavailablePlugins: [plugin: PluginDetails, reason: string][];
downloadablePlugins: (DownloadablePluginDetails | BundledPluginDetails)[];
};
export type ActivePluginListItem =
| {
status: 'enabled';
details: ActivatablePluginDetails;
definition: PluginDefinition;
}
| {
status: 'disabled';
details: ActivatablePluginDetails;
definition: PluginDefinition;
}
| {
status: 'uninstalled';
details: DownloadablePluginDetails | BundledPluginDetails;
}
| {
status: 'unavailable';
details: PluginDetails;
reason: string;
};
export type ActivePluginList = Record<string, ActivePluginListItem | undefined>;
export const defaultEnabledBackgroundPlugins = ['Navigation']; // The navigation plugin is enabled always, to make sure the navigation features works
export function pluginsClassMap(
plugins: PluginsState,
): Map<string, PluginDefinition> {
return new Map<string, PluginDefinition>([
...plugins.clientPlugins.entries(),
...plugins.devicePlugins.entries(),
]);
}
export function computeExportablePlugins(
state: Pick<State, 'plugins' | 'connections' | 'pluginMessageQueue'>,
device: BaseDevice | null,
client: Client | null,
availablePlugins: PluginLists,
): {id: string; label: string}[] {
return [
...availablePlugins.devicePlugins.filter((plugin) => {
return isExportablePlugin(state, device, client, plugin);
}),
...availablePlugins.enabledPlugins.filter((plugin) => {
return isExportablePlugin(state, device, client, plugin);
}),
].map((p) => ({
id: p.id,
label: getPluginTitle(p),
}));
}
function isExportablePlugin(
{pluginMessageQueue}: Pick<State, 'pluginMessageQueue'>,
device: BaseDevice | null,
client: Client | null,
plugin: PluginDefinition,
): boolean {
const pluginKey = isDevicePluginDefinition(plugin)
? getPluginKey(undefined, device, plugin.id)
: getPluginKey(client?.id, undefined, plugin.id);
// plugin has exportable sandy state
if (client?.sandyPluginStates.get(plugin.id)?.isPersistable()) {
return true;
}
if (device?.sandyPluginStates.get(plugin.id)?.isPersistable()) {
return true;
}
// plugin has pending messages and a persisted state reducer or isSandy
if (pluginMessageQueue[pluginKey]) {
return true;
}
// nothing to serialize
return false;
}
export function getPluginTitle(pluginClass: {
title?: string | null;
id: string;
}) {
return pluginClass.title || pluginClass.id;
}
export function sortPluginsByName(
a: PluginDefinition,
b: PluginDefinition,
): number {
// make sure Device plugins are sorted before normal plugins
if (isDevicePluginDefinition(a) && !isDevicePluginDefinition(b)) {
return -1;
}
if (isDevicePluginDefinition(b) && !isDevicePluginDefinition(a)) {
return 1;
}
return getPluginTitle(a) > getPluginTitle(b) ? 1 : -1;
}
export function isDevicePlugin(activePlugin: ActivePluginListItem) {
if (activePlugin.details.pluginType === 'device') {
return true;
}
return (
(activePlugin.status === 'enabled' || activePlugin.status === 'disabled') &&
isDevicePluginDefinition(activePlugin.definition)
);
}
export function isDevicePluginDefinition(
definition: PluginDefinition,
): boolean {
return definition.isDevicePlugin;
}
export function getPluginTooltip(details: PluginDetails): string {
return `${getPluginTitle(details)} (${details.id}@${details.version}) ${
details.description ?? ''
}`;
}
export function computePluginLists(
connections: Pick<
State['connections'],
'enabledDevicePlugins' | 'enabledPlugins'
>,
plugins: Pick<
State['plugins'],
| 'bundledPlugins'
| 'marketplacePlugins'
| 'loadedPlugins'
| 'devicePlugins'
| 'disabledPlugins'
| 'gatekeepedPlugins'
| 'failedPlugins'
| 'clientPlugins'
>,
device: BaseDevice | null,
metroDevice: BaseDevice | null,
client: Client | null,
): {
devicePlugins: PluginDefinition[];
metroPlugins: PluginDefinition[];
enabledPlugins: PluginDefinition[];
disabledPlugins: PluginDefinition[];
unavailablePlugins: [plugin: PluginDetails, reason: string][];
downloadablePlugins: (DownloadablePluginDetails | BundledPluginDetails)[];
} {
const enabledDevicePluginsState = connections.enabledDevicePlugins;
const enabledPluginsState = connections.enabledPlugins;
const uninstalledMarketplacePlugins = getLatestCompatibleVersionOfEachPlugin([
...plugins.bundledPlugins.values(),
...plugins.marketplacePlugins,
]).filter((p) => !plugins.loadedPlugins.has(p.id));
const devicePlugins: PluginDefinition[] = [...plugins.devicePlugins.values()]
.filter((p) => device?.supportsPlugin(p))
.filter((p) => enabledDevicePluginsState.has(p.id));
const metroPlugins: PluginDefinition[] = [...plugins.devicePlugins.values()]
.filter((p) => metroDevice?.supportsPlugin(p))
.filter((p) => enabledDevicePluginsState.has(p.id));
const enabledPlugins: PluginDefinition[] = [];
const disabledPlugins: PluginDefinition[] = [
...plugins.devicePlugins.values(),
]
.filter(
(p) =>
device?.supportsPlugin(p.details) ||
metroDevice?.supportsPlugin(p.details),
)
.filter((p) => !enabledDevicePluginsState.has(p.id));
const unavailablePlugins: [plugin: PluginDetails, reason: string][] = [];
const downloadablePlugins: (
| DownloadablePluginDetails
| BundledPluginDetails
)[] = [];
if (device) {
// find all device plugins that aren't part of the current device / metro
for (const p of plugins.devicePlugins.values()) {
if (!device.supportsPlugin(p) && !metroDevice?.supportsPlugin(p)) {
unavailablePlugins.push([
p.details,
`Device plugin '${getPluginTitle(
p.details,
)}' is not supported by the selected device '${device.title}' (${
device.os
})`,
]);
}
}
for (const plugin of uninstalledMarketplacePlugins.filter(
(d) => d.pluginType === 'device',
)) {
if (
device.supportsPlugin(plugin) ||
metroDevice?.supportsPlugin(plugin)
) {
downloadablePlugins.push(plugin);
}
}
} else {
for (const p of plugins.devicePlugins.values()) {
unavailablePlugins.push([
p.details,
`Device plugin '${getPluginTitle(
p.details,
)}' is not available because no device is currently selected`,
]);
}
}
// process problematic plugins
plugins.disabledPlugins.forEach((plugin) => {
unavailablePlugins.push([
plugin,
`Plugin '${plugin.title}' is disabled by configuration`,
]);
});
plugins.gatekeepedPlugins.forEach((plugin) => {
unavailablePlugins.push([
plugin,
`Plugin '${plugin.title}' is only available to members of gatekeeper '${plugin.gatekeeper}'`,
]);
});
plugins.failedPlugins.forEach(([plugin, error]) => {
unavailablePlugins.push([
plugin,
`Plugin '${plugin.title}' failed to load: '${error}'`,
]);
});
const clientPlugins = Array.from(plugins.clientPlugins.values()).sort(
sortPluginsByName,
);
// process all client plugins
if (device && client) {
const favoritePlugins = getFavoritePlugins(
device,
client,
clientPlugins,
client && enabledPluginsState[client.query.app],
true,
);
clientPlugins.forEach((plugin) => {
if (!client.supportsPlugin(plugin.id)) {
unavailablePlugins.push([
plugin.details,
`Plugin '${getPluginTitle(
plugin.details,
)}' is not supported by the selected application '${
client.query.app
}' (${client.query.os})`,
]);
} else if (favoritePlugins.includes(plugin)) {
enabledPlugins.push(plugin);
} else {
disabledPlugins.push(plugin);
}
});
uninstalledMarketplacePlugins.forEach((plugin) => {
if (plugin.pluginType !== 'device' && client.supportsPlugin(plugin.id)) {
downloadablePlugins.push(plugin);
}
});
} else {
clientPlugins.forEach((plugin) => {
unavailablePlugins.push([
plugin.details,
`Plugin '${getPluginTitle(
plugin.details,
)}' is not available because no application is currently selected`,
]);
});
}
const downloadablePluginSet = new Set<string>(
downloadablePlugins.map((p) => p.id),
);
uninstalledMarketplacePlugins
.filter((p) => !downloadablePluginSet.has(p.id))
.forEach((plugin) => {
unavailablePlugins.push([
plugin,
`Plugin '${getPluginTitle(plugin)}' is not supported by the selected ${
plugin.pluginType === 'device' ? 'device' : 'application'
} '${
(plugin.pluginType === 'device'
? device?.title
: client?.query.app) ?? 'unknown'
}' (${
plugin.pluginType === 'device' ? device?.os : client?.query.os
}) and not installed in Flipper`,
]);
});
enabledPlugins.sort(sortPluginsByName);
devicePlugins.sort(sortPluginsByName);
disabledPlugins.sort(sortPluginsByName);
metroPlugins.sort(sortPluginsByName);
unavailablePlugins.sort(([a], [b]) => {
return getPluginTitle(a) > getPluginTitle(b) ? 1 : -1;
});
downloadablePlugins.sort((a, b) => {
return getPluginTitle(a) > getPluginTitle(b) ? 1 : -1;
});
return {
devicePlugins,
metroPlugins,
enabledPlugins,
disabledPlugins,
unavailablePlugins,
downloadablePlugins,
};
}
function getFavoritePlugins(
device: BaseDevice,
client: Client,
allPlugins: PluginDefinition[],
enabledPlugins: undefined | string[],
returnFavoredPlugins: boolean, // if false, unfavoried plugins are returned
): PluginDefinition[] {
if (device.isArchived) {
if (!returnFavoredPlugins) {
return [];
}
// for *imported* devices, all stored plugins are enabled
return allPlugins.filter((plugin) => client.plugins.has(plugin.id));
}
if (!enabledPlugins || !enabledPlugins.length) {
return returnFavoredPlugins ? [] : allPlugins;
}
return allPlugins.filter((plugin) => {
const idx = enabledPlugins.indexOf(plugin.id);
return idx === -1 ? !returnFavoredPlugins : returnFavoredPlugins;
});
}
export function computeActivePluginList({
enabledPlugins,
devicePlugins,
metroPlugins,
disabledPlugins,
downloadablePlugins,
unavailablePlugins,
}: PluginLists) {
const pluginList: ActivePluginList = {};
for (const plugin of enabledPlugins) {
pluginList[plugin.id] = {
status: 'enabled',
details: plugin.details,
definition: plugin,
};
}
for (const plugin of devicePlugins) {
pluginList[plugin.id] = {
status: 'enabled',
details: plugin.details,
definition: plugin,
};
}
for (const plugin of metroPlugins) {
pluginList[plugin.id] = {
status: 'enabled',
details: plugin.details,
definition: plugin,
};
}
for (const plugin of disabledPlugins) {
pluginList[plugin.id] = {
status: 'disabled',
details: plugin.details,
definition: plugin,
};
}
for (const plugin of downloadablePlugins) {
pluginList[plugin.id] = {
status: 'uninstalled',
details: plugin,
};
}
for (const [plugin, reason] of unavailablePlugins) {
pluginList[plugin.id] = {
status: 'unavailable',
details: plugin,
reason,
};
}
return pluginList;
}
export type PluginStatus =
| 'ready'
| 'unknown'
| 'failed'
| 'gatekeeped'
| 'bundle_installable'
| 'marketplace_installable';
export function getPluginStatus(
store: Store,
id: string,
): [state: PluginStatus, reason?: string] {
const state: PluginsState = store.getState().plugins;
if (state.devicePlugins.has(id) || state.clientPlugins.has(id)) {
return ['ready'];
}
const gateKeepedDetails = state.gatekeepedPlugins.find((d) => d.id === id);
if (gateKeepedDetails) {
return ['gatekeeped', gateKeepedDetails.gatekeeper];
}
const failedPluginEntry = state.failedPlugins.find(
([details]) => details.id === id,
);
if (failedPluginEntry) {
return ['failed', failedPluginEntry[1]];
}
if (state.bundledPlugins.has(id)) {
return ['bundle_installable'];
}
if (state.marketplacePlugins.find((d) => d.id === id)) {
return ['marketplace_installable'];
}
return ['unknown'];
}

View File

@@ -0,0 +1,45 @@
/**
* 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 {getRenderHostInstance} from '../RenderHost';
export type ProcessConfig = {
disabledPlugins: Set<string>;
lastWindowPosition: {
x: number;
y: number;
width: number;
height: number;
} | null;
screenCapturePath: string | null;
launcherMsg: string | null;
// Controls whether to delegate to the launcher if present.
launcherEnabled: boolean;
};
let configObj: ProcessConfig | null = null;
export default function config(): ProcessConfig {
if (configObj === null) {
const json = JSON.parse(getRenderHostInstance().env.CONFIG || '{}');
configObj = {
disabledPlugins: new Set(json.disabledPlugins || []),
lastWindowPosition: json.lastWindowPosition,
launcherMsg: json.launcherMsg,
screenCapturePath: json.screenCapturePath,
launcherEnabled:
typeof json.launcherEnabled === 'boolean' ? json.launcherEnabled : true,
};
}
return configObj;
}
export function resetConfigForTesting() {
configObj = null;
}

View File

@@ -0,0 +1,66 @@
/**
* 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 {timeout} from 'flipper-plugin';
import {StatusMessageType} from '../reducers/application';
/**
* @deprecated use timeout from flipper-plugin
* @param ms @
* @param promise
* @param timeoutMessage
* @returns
*/
export default function promiseTimeout<T>(
ms: number,
promise: Promise<T>,
timeoutMessage?: string,
): Promise<T> {
return timeout(ms, promise, timeoutMessage);
}
export function showStatusUpdatesForPromise<T>(
promise: Promise<T>,
message: string,
sender: string,
addStatusMessage: (payload: StatusMessageType) => void,
removeStatusMessage: (payload: StatusMessageType) => void,
): Promise<T> {
const statusMsg = {msg: message, sender};
addStatusMessage(statusMsg);
return promise
.then((result) => {
removeStatusMessage(statusMsg);
return result;
})
.catch((e) => {
removeStatusMessage(statusMsg);
throw e;
});
}
export function showStatusUpdatesForDuration(
message: string,
sender: string,
duration: number,
addStatusMessage: (payload: StatusMessageType) => void,
removeStatusMessage: (payload: StatusMessageType) => void,
): void {
showStatusUpdatesForPromise(
new Promise<void>((resolve, _reject) => {
setTimeout(function () {
resolve();
}, duration);
}),
message,
sender,
addStatusMessage,
removeStatusMessage,
);
}

View File

@@ -0,0 +1,32 @@
/**
* 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 {State} from '../reducers/index';
import {DeviceExport} from '../devices/BaseDevice';
export const stateSanitizer = (state: State) => {
if (state.connections && state.connections.devices) {
const {devices} = state.connections;
const {selectedDevice} = state.connections;
return {
...state,
connections: {
...state.connections,
devices: devices.map<DeviceExport>((device) => {
return device.toJSON() as any;
}),
selectedDevice: selectedDevice
? (selectedDevice.toJSON() as any)
: null,
},
};
}
return state;
};

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export default () => window.location.reload();

View File

@@ -0,0 +1,118 @@
/**
* 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 {HealthcheckResult} from '../reducers/healthchecks';
import {getHealthchecks, getEnvInfo, Healthchecks} from 'flipper-doctor';
import {logPlatformSuccessRate, reportPlatformFailures} from 'flipper-common';
let healthcheckIsRunning: boolean;
let runningHealthcheck: Promise<void>;
export type HealthcheckEventsHandler = {
updateHealthcheckResult: (
categoryKey: string,
itemKey: string,
result: HealthcheckResult,
) => void;
startHealthchecks: (healthchecks: Healthchecks) => void;
finishHealthchecks: () => void;
};
export type HealthcheckSettings = {
settings: {
enableAndroid: boolean;
enableIOS: boolean;
enablePhysicalIOS: boolean;
idbPath: string;
};
};
export type HealthcheckOptions = HealthcheckEventsHandler & HealthcheckSettings;
async function launchHealthchecks(options: HealthcheckOptions): Promise<void> {
const healthchecks = getHealthchecks();
if (!options.settings.enableAndroid) {
healthchecks.android = {
label: healthchecks.android.label,
isSkipped: true,
skipReason:
'Healthcheck is skipped, because "Android Development" option is disabled in the Flipper settings',
};
}
if (!options.settings.enableIOS) {
healthchecks.ios = {
label: healthchecks.ios.label,
isSkipped: true,
skipReason:
'Healthcheck is skipped, because "iOS Development" option is disabled in the Flipper settings',
};
}
options.startHealthchecks(healthchecks);
const environmentInfo = await getEnvInfo();
let hasProblems = false;
for (const [categoryKey, category] of Object.entries(healthchecks)) {
if (category.isSkipped) {
continue;
}
for (const h of category.healthchecks) {
const checkResult = await h.run(environmentInfo, options.settings);
const metricName = `doctor:${h.key.replace('.', ':')}.healthcheck`; // e.g. "doctor:ios:xcode-select.healthcheck"
if (checkResult.hasProblem) {
hasProblems = true;
logPlatformSuccessRate(metricName, {
kind: 'failure',
supportedOperation: true,
error: null,
});
} else {
logPlatformSuccessRate(metricName, {
kind: 'success',
});
}
const result: HealthcheckResult =
checkResult.hasProblem && h.isRequired
? {
status: 'FAILED',
message: checkResult.message,
}
: checkResult.hasProblem && !h.isRequired
? {
status: 'WARNING',
message: checkResult.message,
}
: {status: 'SUCCESS', message: checkResult.message};
options.updateHealthcheckResult(categoryKey, h.key, result);
}
}
options.finishHealthchecks();
if (hasProblems) {
logPlatformSuccessRate('doctor.healthcheck', {
kind: 'failure',
supportedOperation: true,
error: null,
});
} else {
logPlatformSuccessRate('doctor.healthcheck', {
kind: 'success',
});
}
}
export default async function runHealthchecks(
options: HealthcheckOptions,
): Promise<void> {
if (healthcheckIsRunning) {
return runningHealthcheck;
}
runningHealthcheck = reportPlatformFailures(
launchHealthchecks(options),
'doctor:runHealthchecks',
);
return runningHealthcheck;
}

View File

@@ -0,0 +1,66 @@
/**
* 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 fs from 'fs';
import path from 'path';
import BaseDevice from '../devices/BaseDevice';
import {reportPlatformFailures} from 'flipper-common';
import expandTilde from 'expand-tilde';
import config from '../utils/processConfig';
import {getRenderHostInstance} from '../RenderHost';
export function getCaptureLocation() {
return expandTilde(
config().screenCapturePath || getRenderHostInstance().paths.desktopPath,
);
}
// TODO: refactor so this doesn't need to be exported
export function getFileName(extension: 'png' | 'mp4'): string {
// Windows does not like `:` in its filenames. Yes, I know ...
return `screencap-${new Date().toISOString().replace(/:/g, '')}.${extension}`;
}
export async function capture(device: BaseDevice): Promise<string> {
if (!device.connected.get()) {
console.log('Skipping screenshot for disconnected device');
return '';
}
const pngPath = path.join(getCaptureLocation(), getFileName('png'));
return reportPlatformFailures(
device.screenshot().then((buffer) => writeBufferToFile(pngPath, buffer)),
'captureScreenshot',
);
}
/**
* Writes a buffer to a specified file path.
* Returns a Promise which resolves to the file path.
*/
export const writeBufferToFile = (
filePath: string,
buffer: Buffer,
): Promise<string> => {
return new Promise((resolve, reject) => {
fs.writeFile(filePath, buffer, (err) => {
if (err) {
reject(err);
} else {
resolve(filePath);
}
});
});
};
/**
* Creates a Blob from a Buffer
*/
export const bufferToBlob = (buffer: Buffer): Blob => {
return new Blob([buffer]);
};

View File

@@ -0,0 +1,109 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Store as ReduxStore} from 'redux';
import {shallowEqual} from 'react-redux';
/**
* Registers a sideEffect for the given redux store. Use this utility rather than subscribing to the Redux store directly, which fixes a few problems:
* 1. It decouples and throttles the effect so that no arbitrary expensive burden is added to every store update.
* 2. It makes sure that a crashing side effect doesn't crash the entire store update.
* 3. It helps with tracing and monitoring perf problems.
* 4. It puts the side effect behind a selector so that the side effect is only triggered if a relevant part of the store changes, like we do for components.
*
* @param store
* @param options
* @param selector
* @param effect
*/
export function sideEffect<
Store extends ReduxStore<any, any>,
V,
State = Store extends ReduxStore<infer S, any> ? S : never,
>(
store: Store,
options: {
name: string;
throttleMs: number;
fireImmediately?: boolean;
noTimeBudgetWarns?: boolean;
runSynchronously?: boolean;
},
selector: (state: State) => V,
effect: (selectedState: V, store: Store) => void,
): () => void {
let scheduled = false;
let lastRun = -1;
let lastSelectedValue: V = selector(store.getState());
let timeout: NodeJS.Timeout;
function run() {
scheduled = false;
const start = performance.now();
try {
// Future idea: support effects that return promises as well
lastSelectedValue = selector(store.getState());
effect(lastSelectedValue, store);
} catch (e) {
console.error(
`Error while running side effect '${options.name}': ${e}`,
e,
);
}
lastRun = performance.now();
const duration = lastRun - start;
if (
!options.noTimeBudgetWarns &&
duration > 15 &&
duration > options.throttleMs / 10
) {
console.warn(
`Side effect '${options.name}' took ${Math.round(
duration,
)}ms, which exceeded its budget of ${Math.floor(
options.throttleMs / 10,
)}ms. Please make the effect faster or increase the throttle time.`,
);
}
}
const unsubscribe = store.subscribe(() => {
if (scheduled) {
return;
}
const newValue = selector(store.getState());
if (
newValue === lastSelectedValue ||
shallowEqual(newValue, lastSelectedValue)
) {
return; // no new value, no need to schedule
}
scheduled = true;
if (options.runSynchronously) {
run();
} else {
timeout = setTimeout(
run,
// Run ASAP (but async) or, if we recently did run, delay until at least 'throttle' time has expired
lastRun === -1
? 1
: Math.max(1, lastRun + options.throttleMs - performance.now()),
);
}
});
if (options.fireImmediately) {
run();
}
return () => {
clearTimeout(timeout);
unsubscribe();
};
}

View File

@@ -0,0 +1,83 @@
/**
* 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 {
ActivatablePluginDetails,
DownloadablePluginDetails,
} from 'flipper-plugin-lib';
export function createMockDownloadablePluginDetails(
params: {
id?: string;
name?: string;
version?: string;
title?: string;
flipperEngineVersion?: string;
downloadUrl?: string;
gatekeeper?: string;
lastUpdated?: Date;
} = {},
): DownloadablePluginDetails {
const {id, version, title, flipperEngineVersion, gatekeeper, lastUpdated} = {
id: 'test',
version: '3.0.1',
flipperEngineVersion: '0.46.0',
lastUpdated: new Date(1591226525 * 1000),
...params,
};
const lowercasedID = id.toLowerCase();
const name = params.name || `flipper-plugin-${lowercasedID}`;
const details: DownloadablePluginDetails = {
name: name || `flipper-plugin-${lowercasedID}`,
id: id,
bugs: {
email: 'bugs@localhost',
url: 'bugs.localhost',
},
category: 'tools',
description: 'Description of Test Plugin',
flipperSDKVersion: flipperEngineVersion,
engines: {
flipper: flipperEngineVersion,
},
gatekeeper: gatekeeper ?? `GK_${lowercasedID}`,
icon: 'internet',
main: 'dist/bundle.js',
source: 'src/index.tsx',
specVersion: 2,
pluginType: 'client',
title: title ?? id,
version: version,
downloadUrl: `http://localhost/${lowercasedID}/${version}`,
lastUpdated: lastUpdated,
isBundled: false,
isActivatable: false,
};
return details;
}
export function createMockActivatablePluginDetails(
base: Partial<ActivatablePluginDetails>,
): ActivatablePluginDetails {
return {
id: 'Hello',
specVersion: 2,
isBundled: false,
isActivatable: true,
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample1',
entry: './test/index.js',
name: 'flipper-plugin-hello',
version: '0.1.0',
pluginType: 'client',
source: 'src/index.js',
main: 'dist/bundle.js',
title: 'Hello',
...base,
};
}

View File

@@ -0,0 +1,14 @@
/**
* 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
*/
// Typescript doesn't know Array.filter(Boolean) won't contain nulls.
// So use Array.filter(notNull) instead.
export function notNull<T>(x: T | null | undefined): x is T {
return x !== null && x !== undefined;
}

View File

@@ -0,0 +1,28 @@
/**
* 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 {useStore} from './useStore';
import {getRenderHostInstance} from '../RenderHost';
/**
* This hook returns whether dark mode is currently being used.
* Generally should be avoided in favor of using the above theme object,
* which will provide colors that reflect the theme
*/
export function useIsDarkMode(): boolean {
const darkMode = useStore((state) => state.settingsState.darkMode);
if (darkMode === 'dark') {
return true;
} else if (darkMode === 'light') {
return false;
} else if (darkMode === 'system') {
return getRenderHostInstance().shouldUseDarkColors();
}
return false;
}

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 {
useStore as useReduxStore,
useSelector,
shallowEqual,
useDispatch as useDispatchBase,
} from 'react-redux';
import {Dispatch as ReduxDispatch} from 'redux';
import {State, Actions, Store} from '../reducers/index';
/**
* Strongly typed wrapper or Redux's useSelector.
*
* Equality defaults to shallowEquality
*/
export function useStore<Selected>(
selector: (state: State) => Selected,
equalityFn?: (left: Selected, right: Selected) => boolean,
): Selected;
export function useStore(): Store;
export function useStore(selector?: any, equalityFn?: any) {
// eslint-disable-next-line react-hooks/rules-of-hooks
if (arguments.length === 0) return useReduxStore();
// eslint-disable-next-line react-hooks/rules-of-hooks
return useSelector(selector, equalityFn ?? shallowEqual);
}
export type Dispatch = ReduxDispatch<Actions>;
/**
* Strongly typed useDispatch wrapper for the Flipper redux store.
*/
export function useDispatch(): Dispatch {
return useDispatchBase() as any;
}

View File

@@ -0,0 +1,38 @@
/**
* 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 {useEffect, useState} from 'react';
const isClient = typeof window === 'object';
function getSize() {
return {
width: isClient ? window.innerWidth : undefined,
height: isClient ? window.innerHeight : undefined,
};
}
export function useWindowSize() {
const [windowSize, setWindowSize] = useState(getSize);
useEffect(() => {
if (!isClient) {
return;
}
function handleResize() {
setWindowSize(getSize());
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty array ensures that effect is only run on mount and unmount
return windowSize;
}

View File

@@ -0,0 +1,23 @@
/**
* 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 isProduction from '../utils/isProduction';
import {getAppVersion} from './info';
import config from '../fb-stubs/config';
import ReleaseChannel from '../ReleaseChannel';
export function getVersionString() {
return (
getAppVersion() +
(isProduction() ? '' : '-dev') +
(config.getReleaseChannel() !== ReleaseChannel.STABLE
? `-${config.getReleaseChannel()}`
: '')
);
}

View File

@@ -0,0 +1,32 @@
/**
* 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 {getFlipperLib} from 'flipper-plugin';
import {getPreferredEditorUriScheme} from '../fb-stubs/user';
let preferredEditorUriScheme: string | undefined = undefined;
export function callVSCode(plugin: string, command: string, params?: string) {
return getVSCodeUrl(plugin, command, params).then((url) =>
getFlipperLib().openLink(url),
);
}
export async function getVSCodeUrl(
plugin: string,
command: string,
params?: string,
): Promise<string> {
if (preferredEditorUriScheme === undefined) {
preferredEditorUriScheme = await getPreferredEditorUriScheme();
}
return `${preferredEditorUriScheme}://${plugin}/${command}${
params == null ? '' : `?${params}`
}`;
}