Move app/src (mostly) to flipper-ui-core/src
Summary: This diff moves all UI code from app/src to app/flipper-ui-core. That is now slightly too much (e.g. node deps are not removed yet), but from here it should be easier to move things out again, as I don't want this diff to be open for too long to avoid too much merge conflicts. * But at least flipper-ui-core is Electron free :) * Killed all cross module imports as well, as they where now even more in the way * Some unit test needed some changes, most not too big (but emotion hashes got renumbered in the snapshots, feel free to ignore that) * Found some files that were actually meaningless (tsconfig in plugins, WatchTools files, that start generating compile errors, removed those Follow up work: * make flipper-ui-core configurable, and wire up flipper-server-core in Electron instead of here * remove node deps (aigoncharov) * figure out correct place to load GKs, plugins, make intern requests etc., and move to the correct module * clean up deps Reviewed By: aigoncharov Differential Revision: D32427722 fbshipit-source-id: 14fe92e1ceb15b9dcf7bece367c8ab92df927a70
This commit is contained in:
committed by
Facebook GitHub Bot
parent
54b7ce9308
commit
7e50c0466a
123
desktop/flipper-ui-core/src/utils/Idler.tsx
Normal file
123
desktop/flipper-ui-core/src/utils/Idler.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
79
desktop/flipper-ui-core/src/utils/__tests__/Idler.node.tsx
Normal file
79
desktop/flipper-ui-core/src/utils/__tests__/Idler.node.tsx
Normal 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);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"androidHome": "/opt/android_sdk",
|
||||
"something": {
|
||||
"else": 4
|
||||
},
|
||||
"_persist": {
|
||||
"version": -1,
|
||||
"rehydrated": true
|
||||
}
|
||||
}
|
||||
1723
desktop/flipper-ui-core/src/utils/__tests__/exportData.node.tsx
Normal file
1723
desktop/flipper-ui-core/src/utils/__tests__/exportData.node.tsx
Normal file
File diff suppressed because it is too large
Load Diff
35
desktop/flipper-ui-core/src/utils/__tests__/icons.node.tsx
Normal file
35
desktop/flipper-ui-core/src/utils/__tests__/icons.node.tsx
Normal 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',
|
||||
);
|
||||
});
|
||||
112
desktop/flipper-ui-core/src/utils/__tests__/info.node.tsx
Normal file
112
desktop/flipper-ui-core/src/utils/__tests__/info.node.tsx
Normal 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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
143
desktop/flipper-ui-core/src/utils/__tests__/pluginUtils.node.tsx
Normal file
143
desktop/flipper-ui-core/src/utils/__tests__/pluginUtils.node.tsx
Normal 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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
258
desktop/flipper-ui-core/src/utils/__tests__/sideEffect.node.tsx
Normal file
258
desktop/flipper-ui-core/src/utils/__tests__/sideEffect.node.tsx
Normal 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?.();
|
||||
});
|
||||
});
|
||||
17
desktop/flipper-ui-core/src/utils/assertNotNull.tsx
Normal file
17
desktop/flipper-ui-core/src/utils/assertNotNull.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
29
desktop/flipper-ui-core/src/utils/clientUtils.tsx
Normal file
29
desktop/flipper-ui-core/src/utils/clientUtils.tsx
Normal 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;
|
||||
}
|
||||
229
desktop/flipper-ui-core/src/utils/createSandyPluginWrapper.tsx
Normal file
229
desktop/flipper-ui-core/src/utils/createSandyPluginWrapper.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
16
desktop/flipper-ui-core/src/utils/electronModuleCache.tsx
Normal file
16
desktop/flipper-ui-core/src/utils/electronModuleCache.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
export function unloadModule(path: string) {
|
||||
const resolvedPath = global.electronRequire.resolve(path);
|
||||
if (!resolvedPath || !global.electronRequire.cache[resolvedPath]) {
|
||||
return;
|
||||
}
|
||||
delete global.electronRequire.cache[resolvedPath];
|
||||
}
|
||||
669
desktop/flipper-ui-core/src/utils/exportData.tsx
Normal file
669
desktop/flipper-ui-core/src/utils/exportData.tsx
Normal 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}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
28
desktop/flipper-ui-core/src/utils/fbEmployee.tsx
Normal file
28
desktop/flipper-ui-core/src/utils/fbEmployee.tsx
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
45
desktop/flipper-ui-core/src/utils/geometry.tsx
Normal file
45
desktop/flipper-ui-core/src/utils/geometry.tsx
Normal 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);
|
||||
}
|
||||
20
desktop/flipper-ui-core/src/utils/icons.d.ts
vendored
Normal file
20
desktop/flipper-ui-core/src/utils/icons.d.ts
vendored
Normal 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>;
|
||||
};
|
||||
173
desktop/flipper-ui-core/src/utils/icons.ts
Normal file
173
desktop/flipper-ui-core/src/utils/icons.ts
Normal 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);
|
||||
}
|
||||
167
desktop/flipper-ui-core/src/utils/info.tsx
Normal file
167
desktop/flipper-ui-core/src/utils/info.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
25
desktop/flipper-ui-core/src/utils/isPluginCompatible.tsx
Normal file
25
desktop/flipper-ui-core/src/utils/isPluginCompatible.tsx
Normal 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;
|
||||
@@ -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;
|
||||
14
desktop/flipper-ui-core/src/utils/isProduction.tsx
Normal file
14
desktop/flipper-ui-core/src/utils/isProduction.tsx
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
16
desktop/flipper-ui-core/src/utils/jsonTypes.tsx
Normal file
16
desktop/flipper-ui-core/src/utils/jsonTypes.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
export type JSON = JSONPrimitive | JSONArray | JSONObject;
|
||||
|
||||
export type JSONPrimitive = null | boolean | number | string;
|
||||
|
||||
export type JSONArray = JSON[];
|
||||
|
||||
export type JSONObject = {[key: string]: JSON};
|
||||
40
desktop/flipper-ui-core/src/utils/launcher.tsx
Normal file
40
desktop/flipper-ui-core/src/utils/launcher.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
82
desktop/flipper-ui-core/src/utils/loadDynamicPlugins.tsx
Normal file
82
desktop/flipper-ui-core/src/utils/loadDynamicPlugins.tsx
Normal 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];
|
||||
}
|
||||
129
desktop/flipper-ui-core/src/utils/messageQueue.tsx
Normal file
129
desktop/flipper-ui-core/src/utils/messageQueue.tsx
Normal 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] || [];
|
||||
}
|
||||
43
desktop/flipper-ui-core/src/utils/notifications.tsx
Normal file
43
desktop/flipper-ui-core/src/utils/notifications.tsx
Normal 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',
|
||||
});
|
||||
}
|
||||
33
desktop/flipper-ui-core/src/utils/packageMetadata.tsx
Normal file
33
desktop/flipper-ui-core/src/utils/packageMetadata.tsx
Normal 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;
|
||||
});
|
||||
41
desktop/flipper-ui-core/src/utils/pathUtils.tsx
Normal file
41
desktop/flipper-ui-core/src/utils/pathUtils.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
22
desktop/flipper-ui-core/src/utils/persistor.tsx
Normal file
22
desktop/flipper-ui-core/src/utils/persistor.tsx
Normal 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.'));
|
||||
}
|
||||
27
desktop/flipper-ui-core/src/utils/pluginKey.tsx
Normal file
27
desktop/flipper-ui-core/src/utils/pluginKey.tsx
Normal 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}`;
|
||||
};
|
||||
123
desktop/flipper-ui-core/src/utils/pluginStats.tsx
Normal file
123
desktop/flipper-ui-core/src/utils/pluginStats.tsx
Normal 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
457
desktop/flipper-ui-core/src/utils/pluginUtils.tsx
Normal file
457
desktop/flipper-ui-core/src/utils/pluginUtils.tsx
Normal 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'];
|
||||
}
|
||||
45
desktop/flipper-ui-core/src/utils/processConfig.tsx
Normal file
45
desktop/flipper-ui-core/src/utils/processConfig.tsx
Normal 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;
|
||||
}
|
||||
66
desktop/flipper-ui-core/src/utils/promiseTimeout.tsx
Normal file
66
desktop/flipper-ui-core/src/utils/promiseTimeout.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
32
desktop/flipper-ui-core/src/utils/reduxDevToolsConfig.tsx
Normal file
32
desktop/flipper-ui-core/src/utils/reduxDevToolsConfig.tsx
Normal 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;
|
||||
};
|
||||
10
desktop/flipper-ui-core/src/utils/reloadFlipper.tsx
Normal file
10
desktop/flipper-ui-core/src/utils/reloadFlipper.tsx
Normal 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();
|
||||
118
desktop/flipper-ui-core/src/utils/runHealthchecks.tsx
Normal file
118
desktop/flipper-ui-core/src/utils/runHealthchecks.tsx
Normal 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;
|
||||
}
|
||||
66
desktop/flipper-ui-core/src/utils/screenshot.tsx
Normal file
66
desktop/flipper-ui-core/src/utils/screenshot.tsx
Normal 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]);
|
||||
};
|
||||
109
desktop/flipper-ui-core/src/utils/sideEffect.tsx
Normal file
109
desktop/flipper-ui-core/src/utils/sideEffect.tsx
Normal 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();
|
||||
};
|
||||
}
|
||||
83
desktop/flipper-ui-core/src/utils/testUtils.tsx
Normal file
83
desktop/flipper-ui-core/src/utils/testUtils.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
14
desktop/flipper-ui-core/src/utils/typeUtils.tsx
Normal file
14
desktop/flipper-ui-core/src/utils/typeUtils.tsx
Normal 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;
|
||||
}
|
||||
28
desktop/flipper-ui-core/src/utils/useIsDarkMode.tsx
Normal file
28
desktop/flipper-ui-core/src/utils/useIsDarkMode.tsx
Normal 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;
|
||||
}
|
||||
43
desktop/flipper-ui-core/src/utils/useStore.tsx
Normal file
43
desktop/flipper-ui-core/src/utils/useStore.tsx
Normal 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;
|
||||
}
|
||||
38
desktop/flipper-ui-core/src/utils/useWindowSize.tsx
Normal file
38
desktop/flipper-ui-core/src/utils/useWindowSize.tsx
Normal 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;
|
||||
}
|
||||
23
desktop/flipper-ui-core/src/utils/versionString.tsx
Normal file
23
desktop/flipper-ui-core/src/utils/versionString.tsx
Normal 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()}`
|
||||
: '')
|
||||
);
|
||||
}
|
||||
32
desktop/flipper-ui-core/src/utils/vscodeUtils.tsx
Normal file
32
desktop/flipper-ui-core/src/utils/vscodeUtils.tsx
Normal 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}`
|
||||
}`;
|
||||
}
|
||||
Reference in New Issue
Block a user