Yarn workspaces
Summary: 1) moved "sonar/desktop/src" to "sonar/desktop/app/src", so "app" is now a separate package containing the core Flipper app code 2) Configured yarn workspaces with the root in "sonar/desktop": app, static, pkg, doctor, headless-tests. Plugins are not included for now, I plan to do this later. Reviewed By: jknoxville Differential Revision: D20535782 fbshipit-source-id: 600b2301960f37c7d72166e0d04eba462bec9fc1
This commit is contained in:
committed by
Facebook GitHub Bot
parent
676d7bbd24
commit
863f89351e
19
desktop/app/src/dispatcher/__tests__/TestPlugin.js
Normal file
19
desktop/app/src/dispatcher/__tests__/TestPlugin.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {FlipperPlugin} from 'flipper';
|
||||
|
||||
export default class extends FlipperPlugin {
|
||||
static id = 'Static ID';
|
||||
}
|
||||
|
||||
test('TestPlugin', () => {
|
||||
// supress jest warning
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
@@ -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 {uriComponents} from '../application.tsx';
|
||||
|
||||
test('test parsing of deeplink URL', () => {
|
||||
const url = 'flipper://app/plugin/meta/data';
|
||||
const components = uriComponents(url);
|
||||
expect(components).toEqual(['app', 'plugin', 'meta/data']);
|
||||
});
|
||||
|
||||
test('test parsing of deeplink URL when arguments are less', () => {
|
||||
const url = 'flipper://app/';
|
||||
const components = uriComponents(url);
|
||||
expect(components).toEqual(['app']);
|
||||
});
|
||||
|
||||
test('test parsing of deeplink URL when url is null', () => {
|
||||
const components = uriComponents(null);
|
||||
expect(components).toEqual([]);
|
||||
});
|
||||
|
||||
test('test parsing of deeplink URL when pattern does not match', () => {
|
||||
const url = 'Some random string';
|
||||
const components = uriComponents(url);
|
||||
expect(components).toEqual([]);
|
||||
});
|
||||
131
desktop/app/src/dispatcher/__tests__/plugins.node.js
Normal file
131
desktop/app/src/dispatcher/__tests__/plugins.node.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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 dispatcher, {
|
||||
getDynamicPlugins,
|
||||
checkDisabled,
|
||||
checkGK,
|
||||
requirePlugin,
|
||||
} from '../plugins.tsx';
|
||||
import path from 'path';
|
||||
import {ipcRenderer, remote} from 'electron';
|
||||
import {FlipperPlugin} from 'flipper';
|
||||
import reducers from '../../reducers/index.tsx';
|
||||
import {init as initLogger} from '../../fb-stubs/Logger.tsx';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {TEST_PASSING_GK, TEST_FAILING_GK} from '../../fb-stubs/GK.tsx';
|
||||
import TestPlugin from './TestPlugin';
|
||||
import {resetConfigForTesting} from '../../utils/processConfig.tsx';
|
||||
|
||||
const mockStore = configureStore([])(reducers(undefined, {type: 'INIT'}));
|
||||
const logger = initLogger(mockStore);
|
||||
|
||||
beforeEach(() => {
|
||||
resetConfigForTesting();
|
||||
});
|
||||
|
||||
test('dispatcher dispatches REGISTER_PLUGINS', () => {
|
||||
dispatcher(mockStore, logger);
|
||||
const actions = mockStore.getActions();
|
||||
expect(actions.map(a => a.type)).toContain('REGISTER_PLUGINS');
|
||||
});
|
||||
|
||||
test('getDynamicPlugins returns empty array on errors', () => {
|
||||
ipcRenderer.sendSync = jest.fn();
|
||||
ipcRenderer.sendSync.mockImplementation(() => {
|
||||
throw new Error('ooops');
|
||||
});
|
||||
const res = getDynamicPlugins();
|
||||
expect(res).toEqual([]);
|
||||
});
|
||||
|
||||
test('getDynamicPlugins from main process via ipc', () => {
|
||||
const plugins = [{name: 'test'}];
|
||||
ipcRenderer.sendSync = jest.fn();
|
||||
ipcRenderer.sendSync.mockReturnValue(plugins);
|
||||
const res = getDynamicPlugins();
|
||||
expect(res).toEqual(plugins);
|
||||
});
|
||||
|
||||
test('checkDisabled', () => {
|
||||
const disabledPlugin = 'pluginName';
|
||||
const config = {disabledPlugins: [disabledPlugin]};
|
||||
remote.process.env.CONFIG = JSON.stringify(config);
|
||||
const disabled = checkDisabled([]);
|
||||
|
||||
expect(
|
||||
disabled({
|
||||
name: 'other Name',
|
||||
out: './test/index.js',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
disabled({
|
||||
name: disabledPlugin,
|
||||
out: './test/index.js',
|
||||
}),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test('checkGK for plugin without GK', () => {
|
||||
expect(
|
||||
checkGK([])({
|
||||
name: 'pluginID',
|
||||
out: './test/index.js',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('checkGK for passing plugin', () => {
|
||||
expect(
|
||||
checkGK([])({
|
||||
name: 'pluginID',
|
||||
gatekeeper: TEST_PASSING_GK,
|
||||
out: './test/index.js',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('checkGK for failing plugin', () => {
|
||||
const gatekeepedPlugins = [];
|
||||
const name = 'pluginID';
|
||||
const plugins = checkGK(gatekeepedPlugins)({
|
||||
name,
|
||||
gatekeeper: TEST_FAILING_GK,
|
||||
out: './test/index.js',
|
||||
});
|
||||
|
||||
expect(plugins).toBeFalsy();
|
||||
expect(gatekeepedPlugins[0].name).toEqual(name);
|
||||
});
|
||||
|
||||
test('requirePlugin returns null for invalid requires', () => {
|
||||
const requireFn = requirePlugin([], require);
|
||||
const plugin = requireFn({
|
||||
name: 'pluginID',
|
||||
out: 'this/path/does not/exist',
|
||||
});
|
||||
|
||||
expect(plugin).toBeNull();
|
||||
});
|
||||
|
||||
test('requirePlugin loads plugin', () => {
|
||||
const name = 'pluginID';
|
||||
const homepage = 'https://fb.workplace.com/groups/flippersupport/';
|
||||
const requireFn = requirePlugin([], require);
|
||||
const plugin = requireFn({
|
||||
name,
|
||||
homepage,
|
||||
out: path.join(__dirname, 'TestPlugin.js'),
|
||||
});
|
||||
expect(plugin.prototype).toBeInstanceOf(FlipperPlugin);
|
||||
expect(plugin.homepage).toBe(homepage);
|
||||
expect(plugin.id).toBe(TestPlugin.id);
|
||||
});
|
||||
169
desktop/app/src/dispatcher/__tests__/tracking.node.tsx
Normal file
169
desktop/app/src/dispatcher/__tests__/tracking.node.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {computeUsageSummary, UsageSummary} from '../tracking';
|
||||
import {State} from '../../reducers/usageTracking';
|
||||
|
||||
test('Never focused', () => {
|
||||
const state: State = {
|
||||
timeline: [{type: 'TIMELINE_START', time: 100, isFocused: false}],
|
||||
};
|
||||
const result = computeUsageSummary(state, 200);
|
||||
expect(result).toReportTimeSpent('total', 0, 100);
|
||||
});
|
||||
|
||||
test('Always focused', () => {
|
||||
const state: State = {
|
||||
timeline: [{type: 'TIMELINE_START', time: 100, isFocused: true}],
|
||||
};
|
||||
const result = computeUsageSummary(state, 200);
|
||||
expect(result).toReportTimeSpent('total', 100, 0);
|
||||
});
|
||||
|
||||
test('Focused then unfocused', () => {
|
||||
const state: State = {
|
||||
timeline: [
|
||||
{type: 'TIMELINE_START', time: 100, isFocused: true},
|
||||
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: false},
|
||||
],
|
||||
};
|
||||
const result = computeUsageSummary(state, 350);
|
||||
expect(result).toReportTimeSpent('total', 50, 200);
|
||||
});
|
||||
|
||||
test('Unfocused then focused', () => {
|
||||
const state: State = {
|
||||
timeline: [
|
||||
{type: 'TIMELINE_START', time: 100, isFocused: false},
|
||||
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: true},
|
||||
],
|
||||
};
|
||||
const result = computeUsageSummary(state, 350);
|
||||
expect(result).toReportTimeSpent('total', 200, 50);
|
||||
});
|
||||
|
||||
test('Unfocused then focused then unfocused', () => {
|
||||
const state: State = {
|
||||
timeline: [
|
||||
{type: 'TIMELINE_START', time: 100, isFocused: false},
|
||||
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: true},
|
||||
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false},
|
||||
],
|
||||
};
|
||||
const result = computeUsageSummary(state, 650);
|
||||
expect(result).toReportTimeSpent('total', 200, 350);
|
||||
});
|
||||
|
||||
test('Focused then unfocused then focused', () => {
|
||||
const state: State = {
|
||||
timeline: [
|
||||
{type: 'TIMELINE_START', time: 100, isFocused: true},
|
||||
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: false},
|
||||
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: true},
|
||||
],
|
||||
};
|
||||
const result = computeUsageSummary(state, 650);
|
||||
expect(result).toReportTimeSpent('total', 350, 200);
|
||||
});
|
||||
|
||||
test('Always focused plugin change', () => {
|
||||
const state: State = {
|
||||
timeline: [
|
||||
{type: 'TIMELINE_START', time: 100, isFocused: true},
|
||||
{type: 'PLUGIN_SELECTED', time: 150, plugin: 'Layout'},
|
||||
],
|
||||
};
|
||||
const result = computeUsageSummary(state, 200);
|
||||
expect(result).toReportTimeSpent('total', 100, 0);
|
||||
expect(result).toReportTimeSpent('Layout', 50, 0);
|
||||
});
|
||||
|
||||
test('Focused then plugin change then unfocusd', () => {
|
||||
const state: State = {
|
||||
timeline: [
|
||||
{type: 'TIMELINE_START', time: 100, isFocused: true},
|
||||
{type: 'PLUGIN_SELECTED', time: 150, plugin: 'Layout'},
|
||||
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false},
|
||||
],
|
||||
};
|
||||
const result = computeUsageSummary(state, 650);
|
||||
expect(result).toReportTimeSpent('total', 250, 300);
|
||||
expect(result).toReportTimeSpent('Layout', 200, 300);
|
||||
});
|
||||
|
||||
test('Multiple plugin changes', () => {
|
||||
const state: State = {
|
||||
timeline: [
|
||||
{type: 'TIMELINE_START', time: 100, isFocused: true},
|
||||
{type: 'PLUGIN_SELECTED', time: 150, plugin: 'Layout'},
|
||||
{type: 'PLUGIN_SELECTED', time: 350, plugin: 'Network'},
|
||||
{type: 'PLUGIN_SELECTED', time: 650, plugin: 'Layout'},
|
||||
{type: 'PLUGIN_SELECTED', time: 1050, plugin: 'Databases'},
|
||||
],
|
||||
};
|
||||
const result = computeUsageSummary(state, 1550);
|
||||
expect(result).toReportTimeSpent('total', 1450, 0);
|
||||
expect(result).toReportTimeSpent('Layout', 600, 0);
|
||||
expect(result).toReportTimeSpent('Network', 300, 0);
|
||||
expect(result).toReportTimeSpent('Databases', 500, 0);
|
||||
});
|
||||
|
||||
declare global {
|
||||
namespace jest {
|
||||
interface Matchers<R> {
|
||||
toReportTimeSpent(
|
||||
plugin: string,
|
||||
focusedTimeSpent: number,
|
||||
unfocusedTimeSpent: number,
|
||||
): R;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect.extend({
|
||||
toReportTimeSpent(
|
||||
received: UsageSummary,
|
||||
plugin: string,
|
||||
focusedTimeSpent: number,
|
||||
unfocusedTimeSpent: number,
|
||||
) {
|
||||
const focusedPass = received[plugin].focusedTime === focusedTimeSpent;
|
||||
const unfocusedPass = received[plugin].unfocusedTime === unfocusedTimeSpent;
|
||||
if (!focusedPass) {
|
||||
return {
|
||||
message: () =>
|
||||
`expected ${JSON.stringify(
|
||||
received,
|
||||
)} to have focused time spent: ${focusedTimeSpent} for plugin ${plugin}, but was ${
|
||||
received[plugin]?.focusedTime
|
||||
}`,
|
||||
pass: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!unfocusedPass) {
|
||||
return {
|
||||
message: () =>
|
||||
`expected ${JSON.stringify(
|
||||
received,
|
||||
)} to have unfocused time spent: ${unfocusedTimeSpent} for plugin ${plugin}, but was ${
|
||||
received[plugin]?.unfocusedTime
|
||||
}`,
|
||||
pass: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: () =>
|
||||
`expected ${JSON.stringify(
|
||||
received,
|
||||
)} not to have focused time spent: ${focusedTimeSpent} and unfocused: ${unfocusedTimeSpent}`,
|
||||
pass: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
243
desktop/app/src/dispatcher/androidDevice.tsx
Normal file
243
desktop/app/src/dispatcher/androidDevice.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 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 AndroidDevice from '../devices/AndroidDevice';
|
||||
import KaiOSDevice from '../devices/KaiOSDevice';
|
||||
import child_process from 'child_process';
|
||||
import {Store} from '../reducers/index';
|
||||
import BaseDevice from '../devices/BaseDevice';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import {registerDeviceCallbackOnPlugins} from '../utils/onRegisterDevice';
|
||||
import {getAdbClient} from '../utils/adbClient';
|
||||
import {default as which} from 'which';
|
||||
import {promisify} from 'util';
|
||||
import {ServerPorts} from '../reducers/application';
|
||||
import {Client as ADBClient} from 'adbkit';
|
||||
|
||||
function createDevice(
|
||||
adbClient: ADBClient,
|
||||
device: any,
|
||||
ports?: ServerPorts,
|
||||
): Promise<AndroidDevice> {
|
||||
return new Promise(resolve => {
|
||||
const type =
|
||||
device.type !== 'device' || device.id.startsWith('emulator')
|
||||
? 'emulator'
|
||||
: 'physical';
|
||||
|
||||
adbClient.getProperties(device.id).then(async props => {
|
||||
let name = props['ro.product.model'];
|
||||
if (type === 'emulator') {
|
||||
name = (await getRunningEmulatorName(device.id)) || name;
|
||||
}
|
||||
const isKaiOSDevice = Object.keys(props).some(
|
||||
name => name.startsWith('kaios') || name.startsWith('ro.kaios'),
|
||||
);
|
||||
const androidLikeDevice = new (isKaiOSDevice
|
||||
? KaiOSDevice
|
||||
: AndroidDevice)(device.id, type, name, adbClient);
|
||||
if (ports) {
|
||||
androidLikeDevice.reverse([ports.secure, ports.insecure]);
|
||||
}
|
||||
resolve(androidLikeDevice);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getActiveAndroidDevices(
|
||||
store: Store,
|
||||
): Promise<Array<BaseDevice>> {
|
||||
const client = await getAdbClient(store);
|
||||
const androidDevices = await client.listDevices();
|
||||
return await Promise.all(
|
||||
androidDevices.map(device => createDevice(client, device)),
|
||||
);
|
||||
}
|
||||
|
||||
function getRunningEmulatorName(
|
||||
id: string,
|
||||
): Promise<string | null | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const port = id.replace('emulator-', '');
|
||||
// The GNU version of netcat doesn't terminate after 1s when
|
||||
// specifying `-w 1`, so we kill it after a timeout. Because
|
||||
// of that, even in case of an error, there may still be
|
||||
// relevant data for us to parse.
|
||||
child_process.exec(
|
||||
`echo "avd name" | nc -w 1 localhost ${port}`,
|
||||
{timeout: 1000, encoding: 'utf-8'},
|
||||
(error: Error | null | undefined, data) => {
|
||||
if (data != null && typeof data === 'string') {
|
||||
const match = data.trim().match(/(.*)\r\nOK$/);
|
||||
resolve(match != null && match.length > 0 ? match[1] : null);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default (store: Store, logger: Logger) => {
|
||||
const watchAndroidDevices = () => {
|
||||
// get emulators
|
||||
promisify(which)('emulator')
|
||||
.catch(() => `${process.env.ANDROID_HOME || ''}/tools/emulator`)
|
||||
.then(emulatorPath => {
|
||||
child_process.exec(
|
||||
`${emulatorPath} -list-avds`,
|
||||
(error: Error | null, data: string | null) => {
|
||||
if (error != null || data == null) {
|
||||
console.error(error || 'Failed to list AVDs');
|
||||
return;
|
||||
}
|
||||
const payload = data.split('\n').filter(Boolean);
|
||||
store.dispatch({
|
||||
type: 'REGISTER_ANDROID_EMULATORS',
|
||||
payload,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
getAdbClient(store)
|
||||
.then(client => {
|
||||
client
|
||||
.trackDevices()
|
||||
.then(tracker => {
|
||||
tracker.on('error', err => {
|
||||
if (err.message === 'Connection closed') {
|
||||
// adb server has shutdown, remove all android devices
|
||||
const {connections} = store.getState();
|
||||
const deviceIDsToRemove: Array<string> = connections.devices
|
||||
.filter(
|
||||
(device: BaseDevice) => device instanceof AndroidDevice,
|
||||
)
|
||||
.map((device: BaseDevice) => device.serial);
|
||||
|
||||
unregisterDevices(deviceIDsToRemove);
|
||||
console.error('adb server was shutdown');
|
||||
setTimeout(watchAndroidDevices, 500);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
tracker.on('add', async device => {
|
||||
if (device.type !== 'offline') {
|
||||
registerDevice(client, device, store);
|
||||
}
|
||||
});
|
||||
|
||||
tracker.on('change', async device => {
|
||||
if (device.type === 'offline') {
|
||||
unregisterDevices([device.id]);
|
||||
} else {
|
||||
registerDevice(client, device, store);
|
||||
}
|
||||
});
|
||||
|
||||
tracker.on('remove', device => {
|
||||
unregisterDevices([device.id]);
|
||||
});
|
||||
})
|
||||
.catch((err: {code: string}) => {
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
// adb server isn't running
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(`Failed to watch for android devices: ${e.message}`);
|
||||
});
|
||||
};
|
||||
|
||||
async function registerDevice(adbClient: any, deviceData: any, store: Store) {
|
||||
const androidDevice = await createDevice(
|
||||
adbClient,
|
||||
deviceData,
|
||||
store.getState().application.serverPorts,
|
||||
);
|
||||
logger.track('usage', 'register-device', {
|
||||
os: 'Android',
|
||||
name: androidDevice.title,
|
||||
serial: androidDevice.serial,
|
||||
});
|
||||
|
||||
// remove offline devices with same serial as the connected.
|
||||
const reconnectedDevices = store
|
||||
.getState()
|
||||
.connections.devices.filter(
|
||||
(device: BaseDevice) =>
|
||||
device.serial === androidDevice.serial && device.isArchived,
|
||||
)
|
||||
.map(device => device.serial);
|
||||
|
||||
store.dispatch({
|
||||
type: 'UNREGISTER_DEVICES',
|
||||
payload: new Set(reconnectedDevices),
|
||||
});
|
||||
|
||||
androidDevice.loadDevicePlugins(store.getState().plugins.devicePlugins);
|
||||
store.dispatch({
|
||||
type: 'REGISTER_DEVICE',
|
||||
payload: androidDevice,
|
||||
});
|
||||
|
||||
registerDeviceCallbackOnPlugins(
|
||||
store,
|
||||
store.getState().plugins.devicePlugins,
|
||||
store.getState().plugins.clientPlugins,
|
||||
androidDevice,
|
||||
);
|
||||
}
|
||||
|
||||
async function unregisterDevices(deviceIds: Array<string>) {
|
||||
deviceIds.forEach(id =>
|
||||
logger.track('usage', 'unregister-device', {
|
||||
os: 'Android',
|
||||
serial: id,
|
||||
}),
|
||||
);
|
||||
|
||||
const archivedDevices = deviceIds
|
||||
.map(id => {
|
||||
const device = store
|
||||
.getState()
|
||||
.connections.devices.find(device => device.serial === id);
|
||||
if (device && !device.isArchived) {
|
||||
return device.archive();
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
store.dispatch({
|
||||
type: 'UNREGISTER_DEVICES',
|
||||
payload: new Set(deviceIds),
|
||||
});
|
||||
|
||||
archivedDevices.forEach((device: BaseDevice) => {
|
||||
device.loadDevicePlugins(store.getState().plugins.devicePlugins);
|
||||
store.dispatch({
|
||||
type: 'REGISTER_DEVICE',
|
||||
payload: device,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
watchAndroidDevices();
|
||||
|
||||
// cleanup method
|
||||
return () =>
|
||||
getAdbClient(store).then(client => {
|
||||
client.kill();
|
||||
});
|
||||
};
|
||||
155
desktop/app/src/dispatcher/application.tsx
Normal file
155
desktop/app/src/dispatcher/application.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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 {remote, ipcRenderer, IpcRendererEvent} from 'electron';
|
||||
import {toggleAction} from '../reducers/application';
|
||||
import {
|
||||
Group,
|
||||
GRAPHQL_ANDROID_GROUP,
|
||||
GRAPHQL_IOS_GROUP,
|
||||
LITHO_GROUP,
|
||||
} from '../reducers/supportForm';
|
||||
import {Store} from '../reducers/index';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import {parseFlipperPorts} from '../utils/environmentVariables';
|
||||
import {
|
||||
importDataToStore,
|
||||
importFileToStore,
|
||||
IMPORT_FLIPPER_TRACE_EVENT,
|
||||
} from '../utils/exportData';
|
||||
import {tryCatchReportPlatformFailures} from '../utils/metrics';
|
||||
import {selectPlugin} from '../reducers/connections';
|
||||
import qs from 'query-string';
|
||||
|
||||
export const uriComponents = (url: string): Array<string> => {
|
||||
if (!url) {
|
||||
return [];
|
||||
}
|
||||
const match: Array<string> | undefined | null = url.match(
|
||||
/^flipper:\/\/([^\/]*)\/([^\/]*)\/?(.*)$/,
|
||||
);
|
||||
if (match) {
|
||||
return match
|
||||
.map(decodeURIComponent)
|
||||
.slice(1)
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export default (store: Store, _logger: Logger) => {
|
||||
const currentWindow = remote.getCurrentWindow();
|
||||
currentWindow.on('focus', () => {
|
||||
store.dispatch({
|
||||
type: 'windowIsFocused',
|
||||
payload: {isFocused: true, time: Date.now()},
|
||||
});
|
||||
});
|
||||
currentWindow.on('blur', () => {
|
||||
store.dispatch({
|
||||
type: 'windowIsFocused',
|
||||
payload: {isFocused: false, time: Date.now()},
|
||||
});
|
||||
});
|
||||
|
||||
// windowIsFocussed is initialized in the store before the app is fully ready.
|
||||
// So wait until everything is up and running and then check and set the isFocussed state.
|
||||
window.addEventListener('flipper-store-ready', () => {
|
||||
const isFocused = currentWindow.isFocused();
|
||||
store.dispatch({
|
||||
type: 'windowIsFocused',
|
||||
payload: {isFocused: isFocused, time: Date.now()},
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on(
|
||||
'flipper-protocol-handler',
|
||||
(_event: IpcRendererEvent, query: string) => {
|
||||
const uri = new URL(query);
|
||||
if (query.startsWith('flipper://import')) {
|
||||
const {search} = new URL(query);
|
||||
const {url} = qs.parse(search);
|
||||
store.dispatch(toggleAction('downloadingImportData', true));
|
||||
return (
|
||||
typeof url === 'string' &&
|
||||
fetch(url)
|
||||
.then(res => res.text())
|
||||
.then(data => importDataToStore(url, data, store))
|
||||
.then(() => {
|
||||
store.dispatch(toggleAction('downloadingImportData', false));
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
console.error(e);
|
||||
store.dispatch(toggleAction('downloadingImportData', false));
|
||||
})
|
||||
);
|
||||
} else if (
|
||||
uri.protocol === 'flipper:' &&
|
||||
uri.pathname.includes('support-form')
|
||||
) {
|
||||
const formParam = uri.searchParams.get('form');
|
||||
const grp = deeplinkFormParamToGroups(formParam);
|
||||
if (grp) {
|
||||
grp.handleSupportFormDeeplinks(store);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const match = uriComponents(query);
|
||||
if (match.length > 1) {
|
||||
// flipper://<client>/<pluginId>/<payload>
|
||||
return store.dispatch(
|
||||
selectPlugin({
|
||||
selectedApp: match[0],
|
||||
selectedPlugin: match[1],
|
||||
deepLinkPayload: match[2],
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function deeplinkFormParamToGroups(formParam: string | null): Group | null {
|
||||
if (!formParam) {
|
||||
return null;
|
||||
}
|
||||
if (formParam.toLowerCase() === 'litho') {
|
||||
return LITHO_GROUP;
|
||||
} else if (formParam.toLowerCase() === 'graphql_android') {
|
||||
return GRAPHQL_ANDROID_GROUP;
|
||||
} else if (formParam.toLowerCase() === 'graphql_ios') {
|
||||
return GRAPHQL_IOS_GROUP;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
ipcRenderer.on(
|
||||
'open-flipper-file',
|
||||
(_event: IpcRendererEvent, url: string) => {
|
||||
tryCatchReportPlatformFailures(() => {
|
||||
return importFileToStore(url, store);
|
||||
}, `${IMPORT_FLIPPER_TRACE_EVENT}:Deeplink`);
|
||||
},
|
||||
);
|
||||
|
||||
if (process.env.FLIPPER_PORTS) {
|
||||
const portOverrides = parseFlipperPorts(process.env.FLIPPER_PORTS);
|
||||
if (portOverrides) {
|
||||
store.dispatch({
|
||||
type: 'SET_SERVER_PORTS',
|
||||
payload: portOverrides,
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
`Ignoring malformed FLIPPER_PORTS env variable:
|
||||
"${process.env.FLIPPER_PORTS || ''}".
|
||||
Example expected format: "1111,2222".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
30
desktop/app/src/dispatcher/desktopDevice.tsx
Normal file
30
desktop/app/src/dispatcher/desktopDevice.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Store} from '../reducers/index';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
|
||||
import MacDevice from '../devices/MacDevice';
|
||||
import WindowsDevice from '../devices/WindowsDevice';
|
||||
|
||||
export default (store: Store, _logger: Logger) => {
|
||||
let device;
|
||||
if (process.platform === 'darwin') {
|
||||
device = new MacDevice();
|
||||
} else if (process.platform === 'win32') {
|
||||
device = new WindowsDevice();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
device.loadDevicePlugins(store.getState().plugins.devicePlugins);
|
||||
store.dispatch({
|
||||
type: 'REGISTER_DEVICE',
|
||||
payload: device,
|
||||
});
|
||||
};
|
||||
270
desktop/app/src/dispatcher/iOSDevice.tsx
Normal file
270
desktop/app/src/dispatcher/iOSDevice.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 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 {ChildProcess} from 'child_process';
|
||||
import {Store} from '../reducers/index';
|
||||
import {setXcodeDetected} from '../reducers/application';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import {DeviceType} from '../devices/BaseDevice';
|
||||
import {promisify} from 'util';
|
||||
import path from 'path';
|
||||
import child_process from 'child_process';
|
||||
const execFile = child_process.execFile;
|
||||
import iosUtil from '../fb-stubs/iOSContainerUtility';
|
||||
import IOSDevice from '../devices/IOSDevice';
|
||||
import isProduction from '../utils/isProduction';
|
||||
import GK from '../fb-stubs/GK';
|
||||
import {registerDeviceCallbackOnPlugins} from '../utils/onRegisterDevice';
|
||||
type iOSSimulatorDevice = {
|
||||
state: 'Booted' | 'Shutdown' | 'Shutting Down';
|
||||
availability?: string;
|
||||
isAvailable?: 'YES' | 'NO' | true | false;
|
||||
name: string;
|
||||
udid: string;
|
||||
};
|
||||
|
||||
type IOSDeviceParams = {udid: string; type: DeviceType; name: string};
|
||||
|
||||
function isAvailable(simulator: iOSSimulatorDevice): boolean {
|
||||
// For some users "availability" is set, for others it's "isAvailable"
|
||||
// It's not clear which key is set, so we are checking both.
|
||||
// We've also seen isAvailable return "YES" and true, depending on version.
|
||||
return (
|
||||
simulator.availability === '(available)' ||
|
||||
simulator.isAvailable === 'YES' ||
|
||||
simulator.isAvailable === true
|
||||
);
|
||||
}
|
||||
|
||||
const portforwardingClient = isProduction()
|
||||
? path.resolve(
|
||||
__dirname,
|
||||
'PortForwardingMacApp.app/Contents/MacOS/PortForwardingMacApp',
|
||||
)
|
||||
: 'PortForwardingMacApp.app/Contents/MacOS/PortForwardingMacApp';
|
||||
|
||||
function forwardPort(port: number, multiplexChannelPort: number) {
|
||||
return execFile(portforwardingClient, [
|
||||
`-portForward=${port}`,
|
||||
`-multiplexChannelPort=${multiplexChannelPort}`,
|
||||
]);
|
||||
}
|
||||
// start port forwarding server for real device connections
|
||||
const portForwarders: Array<ChildProcess> = GK.get('flipper_ios_device_support')
|
||||
? [forwardPort(8089, 8079), forwardPort(8088, 8078)]
|
||||
: [];
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
portForwarders.forEach(process => process.kill());
|
||||
});
|
||||
}
|
||||
|
||||
async function queryDevices(store: Store, logger: Logger): Promise<void> {
|
||||
if (!(await checkIfDevicesCanBeQueryied(store))) {
|
||||
return;
|
||||
}
|
||||
await checkXcodeVersionMismatch(store);
|
||||
const {connections} = store.getState();
|
||||
const currentDeviceIDs: Set<string> = new Set(
|
||||
connections.devices
|
||||
.filter(device => device instanceof IOSDevice)
|
||||
.map(device => device.serial),
|
||||
);
|
||||
return Promise.all([getActiveSimulators(), getActiveDevices()])
|
||||
.then(([a, b]) => a.concat(b))
|
||||
.then(activeDevices => {
|
||||
for (const {udid, type, name} of activeDevices) {
|
||||
if (currentDeviceIDs.has(udid)) {
|
||||
currentDeviceIDs.delete(udid);
|
||||
} else {
|
||||
logger.track('usage', 'register-device', {
|
||||
os: 'iOS',
|
||||
type: type,
|
||||
name: name,
|
||||
serial: udid,
|
||||
});
|
||||
const iOSDevice = new IOSDevice(udid, type, name);
|
||||
iOSDevice.loadDevicePlugins(store.getState().plugins.devicePlugins);
|
||||
store.dispatch({
|
||||
type: 'REGISTER_DEVICE',
|
||||
payload: iOSDevice,
|
||||
});
|
||||
registerDeviceCallbackOnPlugins(
|
||||
store,
|
||||
store.getState().plugins.devicePlugins,
|
||||
store.getState().plugins.clientPlugins,
|
||||
iOSDevice,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDeviceIDs.size > 0) {
|
||||
currentDeviceIDs.forEach(id =>
|
||||
logger.track('usage', 'unregister-device', {os: 'iOS', serial: id}),
|
||||
);
|
||||
store.dispatch({
|
||||
type: 'UNREGISTER_DEVICES',
|
||||
payload: currentDeviceIDs,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveSimulators(): Promise<Array<IOSDeviceParams>> {
|
||||
const deviceSetPath = process.env.DEVICE_SET_PATH
|
||||
? ['--set', process.env.DEVICE_SET_PATH]
|
||||
: [];
|
||||
return promisify(execFile)(
|
||||
'xcrun',
|
||||
['simctl', ...deviceSetPath, 'list', 'devices', '--json'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
.then(({stdout}) => JSON.parse(stdout).devices)
|
||||
.then((simulatorDevices: Array<iOSSimulatorDevice>) => {
|
||||
const simulators: Array<iOSSimulatorDevice> = Object.values(
|
||||
simulatorDevices,
|
||||
).reduce((acc: Array<iOSSimulatorDevice>, cv) => acc.concat(cv), []);
|
||||
|
||||
return simulators
|
||||
.filter(
|
||||
simulator => simulator.state === 'Booted' && isAvailable(simulator),
|
||||
)
|
||||
.map(simulator => {
|
||||
return {
|
||||
udid: simulator.udid,
|
||||
type: 'emulator',
|
||||
name: simulator.name,
|
||||
} as IOSDeviceParams;
|
||||
});
|
||||
})
|
||||
.catch(_ => []);
|
||||
}
|
||||
|
||||
function getActiveDevices(): Promise<Array<IOSDeviceParams>> {
|
||||
return iosUtil.targets().catch(e => {
|
||||
console.error(e.message);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function queryDevicesForever(store: Store, logger: Logger) {
|
||||
return queryDevices(store, logger)
|
||||
.then(() => {
|
||||
// It's important to schedule the next check AFTER the current one has completed
|
||||
// to avoid simultaneous queries which can cause multiple user input prompts.
|
||||
setTimeout(() => queryDevicesForever(store, logger), 3000);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
let xcodeVersionMismatchFound = false;
|
||||
async function checkXcodeVersionMismatch(store: Store) {
|
||||
if (xcodeVersionMismatchFound) {
|
||||
return;
|
||||
}
|
||||
const exec = promisify(child_process.exec);
|
||||
try {
|
||||
let {stdout: xcodeCLIVersion} = await exec('xcode-select -p');
|
||||
xcodeCLIVersion = xcodeCLIVersion.trim();
|
||||
const {stdout} = await exec('ps aux | grep CoreSimulator');
|
||||
for (const line of stdout.split('\n')) {
|
||||
const match = line.match(
|
||||
/\/Applications\/Xcode[^/]*\.app\/Contents\/Developer/,
|
||||
);
|
||||
const runningVersion = match && match.length > 0 ? match[0].trim() : null;
|
||||
if (runningVersion && runningVersion !== xcodeCLIVersion) {
|
||||
const errorMessage = `Xcode version mismatch: Simulator is running from "${runningVersion}" while Xcode CLI is "${xcodeCLIVersion}". Running "xcode-select --switch ${runningVersion}" can fix this.`;
|
||||
store.dispatch({
|
||||
type: 'SERVER_ERROR',
|
||||
payload: {
|
||||
message: errorMessage,
|
||||
details:
|
||||
"You might want to run 'sudo xcode-select -s /Applications/Xcode.app/Contents/Developer'",
|
||||
urgent: true,
|
||||
},
|
||||
});
|
||||
// Fire a console.error as well, so that it gets reported to the backend.
|
||||
console.error(errorMessage);
|
||||
xcodeVersionMismatchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
let canQueryDevices: boolean | undefined = undefined;
|
||||
|
||||
async function checkIfDevicesCanBeQueryied(store: Store): Promise<boolean> {
|
||||
if (canQueryDevices !== undefined) {
|
||||
return canQueryDevices;
|
||||
}
|
||||
try {
|
||||
const exec = promisify(child_process.exec);
|
||||
// make sure we can use instruments (it will throw otherwise)
|
||||
await exec('instruments -s devices');
|
||||
return (canQueryDevices = true);
|
||||
} catch (e) {
|
||||
store.dispatch({
|
||||
type: 'SERVER_ERROR',
|
||||
payload: {
|
||||
message:
|
||||
'It looks like XCode was not installed properly. Further actions are required if you want to use an iOS emulator.',
|
||||
details:
|
||||
"You might want to run 'sudo xcode-select -s /Applications/Xcode.app/Contents/Developer'",
|
||||
error: e,
|
||||
},
|
||||
});
|
||||
return (canQueryDevices = false);
|
||||
}
|
||||
}
|
||||
|
||||
async function isXcodeDetected(): Promise<boolean> {
|
||||
return promisify(child_process.exec)('xcode-select -p')
|
||||
.then(_ => true)
|
||||
.catch(_ => false);
|
||||
}
|
||||
|
||||
export async function getActiveDevicesAndSimulators(): Promise<
|
||||
Array<IOSDevice>
|
||||
> {
|
||||
const activeDevices: Array<Array<IOSDeviceParams>> = await Promise.all([
|
||||
getActiveSimulators(),
|
||||
getActiveDevices(),
|
||||
]);
|
||||
const allDevices = activeDevices[0].concat(activeDevices[1]);
|
||||
return allDevices.map(device => {
|
||||
const {udid, type, name} = device;
|
||||
return new IOSDevice(udid, type, name);
|
||||
});
|
||||
}
|
||||
|
||||
export default (store: Store, logger: Logger) => {
|
||||
// monitoring iOS devices only available on MacOS.
|
||||
if (process.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
if (!store.getState().settingsState.enableIOS) {
|
||||
return;
|
||||
}
|
||||
isXcodeDetected()
|
||||
.then(isDetected => {
|
||||
store.dispatch(setXcodeDetected(isDetected));
|
||||
return isDetected;
|
||||
})
|
||||
.then(isDetected =>
|
||||
isDetected ? queryDevicesForever(store, logger) : Promise.resolve(),
|
||||
);
|
||||
};
|
||||
56
desktop/app/src/dispatcher/index.tsx
Normal file
56
desktop/app/src/dispatcher/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {remote} from 'electron';
|
||||
import androidDevice from './androidDevice';
|
||||
import metroDevice from './metroDevice';
|
||||
import iOSDevice from './iOSDevice';
|
||||
import desktopDevice from './desktopDevice';
|
||||
import application from './application';
|
||||
import tracking from './tracking';
|
||||
import server from './server';
|
||||
import notifications from './notifications';
|
||||
import plugins from './plugins';
|
||||
import user from './user';
|
||||
import pluginManager from './pluginManager';
|
||||
import reactNative from './reactNative';
|
||||
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import {Store} from '../reducers/index';
|
||||
import {Dispatcher} from './types';
|
||||
import {notNull} from '../utils/typeUtils';
|
||||
|
||||
export default function(store: Store, logger: Logger): () => Promise<void> {
|
||||
// This only runs in development as when the reload
|
||||
// kicks in it doesn't unregister the shortcuts
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
remote.globalShortcut.unregisterAll();
|
||||
}
|
||||
|
||||
const dispatchers: Array<Dispatcher> = [
|
||||
application,
|
||||
store.getState().settingsState.enableAndroid ? androidDevice : null,
|
||||
iOSDevice,
|
||||
metroDevice,
|
||||
desktopDevice,
|
||||
tracking,
|
||||
server,
|
||||
notifications,
|
||||
plugins,
|
||||
user,
|
||||
pluginManager,
|
||||
reactNative,
|
||||
].filter(notNull);
|
||||
const globalCleanup = dispatchers
|
||||
.map(dispatcher => dispatcher(store, logger))
|
||||
.filter(Boolean);
|
||||
return () => {
|
||||
return Promise.all(globalCleanup).then(() => {});
|
||||
};
|
||||
}
|
||||
164
desktop/app/src/dispatcher/metroDevice.tsx
Normal file
164
desktop/app/src/dispatcher/metroDevice.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Store} from '../reducers/index';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import {registerDeviceCallbackOnPlugins} from '../utils/onRegisterDevice';
|
||||
import MetroDevice from '../devices/MetroDevice';
|
||||
import {ArchivedDevice} from 'flipper';
|
||||
import http from 'http';
|
||||
|
||||
const METRO_PORT = 8081;
|
||||
const METRO_HOST = 'localhost';
|
||||
const METRO_URL = `http://${METRO_HOST}:${METRO_PORT}`;
|
||||
const METRO_LOGS_ENDPOINT = `ws://${METRO_HOST}:${METRO_PORT}/events`;
|
||||
const METRO_MESSAGE = ['React Native packager is running', 'Metro is running'];
|
||||
const QUERY_INTERVAL = 5000;
|
||||
|
||||
async function isMetroRunning(): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
// We use Node's http library, rather than fetch api, as the latter cannot supress network errors being shown in the devtools console
|
||||
// which generates a lot of noise
|
||||
http
|
||||
.get(METRO_URL, resp => {
|
||||
let data = '';
|
||||
resp
|
||||
.on('data', chunk => {
|
||||
data += chunk;
|
||||
})
|
||||
.on('end', () => {
|
||||
const isMetro = METRO_MESSAGE.some(msg => data.includes(msg));
|
||||
resolve(isMetro);
|
||||
});
|
||||
})
|
||||
.on('error', err => {
|
||||
console.debug('Could not connect to METRO ' + err);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function registerDevice(
|
||||
ws: WebSocket | undefined,
|
||||
store: Store,
|
||||
logger: Logger,
|
||||
) {
|
||||
const metroDevice = new MetroDevice(METRO_URL, ws);
|
||||
logger.track('usage', 'register-device', {
|
||||
os: 'Metro',
|
||||
name: metroDevice.title,
|
||||
});
|
||||
|
||||
metroDevice.loadDevicePlugins(store.getState().plugins.devicePlugins);
|
||||
store.dispatch({
|
||||
type: 'REGISTER_DEVICE',
|
||||
payload: metroDevice,
|
||||
serial: METRO_URL,
|
||||
});
|
||||
|
||||
registerDeviceCallbackOnPlugins(
|
||||
store,
|
||||
store.getState().plugins.devicePlugins,
|
||||
store.getState().plugins.clientPlugins,
|
||||
metroDevice,
|
||||
);
|
||||
}
|
||||
|
||||
async function unregisterDevices(store: Store, logger: Logger) {
|
||||
logger.track('usage', 'unregister-device', {
|
||||
os: 'Metro',
|
||||
serial: METRO_URL,
|
||||
});
|
||||
|
||||
let archivedDevice: ArchivedDevice | undefined = undefined;
|
||||
const device = store
|
||||
.getState()
|
||||
.connections.devices.find(device => device.serial === METRO_URL);
|
||||
if (device && !device.isArchived) {
|
||||
archivedDevice = device.archive();
|
||||
}
|
||||
|
||||
store.dispatch({
|
||||
type: 'UNREGISTER_DEVICES',
|
||||
payload: new Set([METRO_URL]),
|
||||
});
|
||||
|
||||
if (archivedDevice) {
|
||||
archivedDevice.loadDevicePlugins(store.getState().plugins.devicePlugins);
|
||||
store.dispatch({
|
||||
type: 'REGISTER_DEVICE',
|
||||
payload: archivedDevice,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default (store: Store, logger: Logger) => {
|
||||
let timeoutHandle: NodeJS.Timeout;
|
||||
let ws: WebSocket | undefined;
|
||||
let unregistered = false;
|
||||
|
||||
async function tryConnectToMetro() {
|
||||
if (ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isMetroRunning()) {
|
||||
const _ws = new WebSocket(METRO_LOGS_ENDPOINT);
|
||||
|
||||
_ws.onopen = () => {
|
||||
clearTimeout(guard);
|
||||
ws = _ws;
|
||||
registerDevice(ws, store, logger);
|
||||
};
|
||||
|
||||
_ws.onclose = _ws.onerror = () => {
|
||||
if (!unregistered) {
|
||||
unregistered = true;
|
||||
clearTimeout(guard);
|
||||
ws = undefined;
|
||||
unregisterDevices(store, logger);
|
||||
scheduleNext();
|
||||
}
|
||||
};
|
||||
|
||||
const guard = setTimeout(() => {
|
||||
// Metro is running, but didn't respond to /events endpoint
|
||||
store.dispatch({
|
||||
type: 'SERVER_ERROR',
|
||||
payload: {
|
||||
message:
|
||||
"Found a running Metro instance, but couldn't connect to the logs. Probably your React Native version is too old to support Flipper.",
|
||||
details: `Failed to get a connection to ${METRO_LOGS_ENDPOINT} in a timely fashion`,
|
||||
urgent: true,
|
||||
},
|
||||
});
|
||||
registerDevice(undefined, store, logger);
|
||||
// Note: no scheduleNext, we won't retry until restart
|
||||
}, 5000);
|
||||
} else {
|
||||
scheduleNext();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
timeoutHandle = setTimeout(tryConnectToMetro, QUERY_INTERVAL);
|
||||
}
|
||||
|
||||
tryConnectToMetro();
|
||||
|
||||
// cleanup method
|
||||
return () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
if (timeoutHandle) {
|
||||
clearInterval(timeoutHandle);
|
||||
}
|
||||
};
|
||||
};
|
||||
193
desktop/app/src/dispatcher/notifications.tsx
Normal file
193
desktop/app/src/dispatcher/notifications.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Store} from '../reducers/index';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import {PluginNotification} from '../reducers/notifications';
|
||||
import {FlipperPlugin, FlipperDevicePlugin} from '../plugin';
|
||||
import isHeadless from '../utils/isHeadless';
|
||||
import {setStaticView, setDeeplinkPayload} from '../reducers/connections';
|
||||
import {ipcRenderer, IpcRendererEvent} from 'electron';
|
||||
import {
|
||||
setActiveNotifications,
|
||||
updatePluginBlacklist,
|
||||
updateCategoryBlacklist,
|
||||
} from '../reducers/notifications';
|
||||
import {textContent} from '../utils/index';
|
||||
import GK from '../fb-stubs/GK';
|
||||
import {deconstructPluginKey} from '../utils/clientUtils';
|
||||
import NotificationScreen from '../chrome/NotificationScreen';
|
||||
import {getPluginTitle} from '../utils/pluginUtils';
|
||||
|
||||
type NotificationEvents = 'show' | 'click' | 'close' | 'reply' | 'action';
|
||||
const NOTIFICATION_THROTTLE = 5 * 1000; // in milliseconds
|
||||
|
||||
export default (store: Store, logger: Logger) => {
|
||||
if (GK.get('flipper_disable_notifications')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const knownNotifications: Set<string> = new Set();
|
||||
const knownPluginStates: Map<string, Object> = new Map();
|
||||
const lastNotificationTime: Map<string, number> = new Map();
|
||||
|
||||
ipcRenderer.on(
|
||||
'notificationEvent',
|
||||
(
|
||||
_event: IpcRendererEvent,
|
||||
eventName: NotificationEvents,
|
||||
pluginNotification: PluginNotification,
|
||||
arg: null | string | number,
|
||||
) => {
|
||||
if (eventName === 'click' || (eventName === 'action' && arg === 0)) {
|
||||
store.dispatch(
|
||||
setDeeplinkPayload(pluginNotification.notification.action ?? null),
|
||||
);
|
||||
store.dispatch(setStaticView(NotificationScreen));
|
||||
} else if (eventName === 'action') {
|
||||
if (arg === 1 && pluginNotification.notification.category) {
|
||||
// Hide similar (category)
|
||||
logger.track(
|
||||
'usage',
|
||||
'notification-hide-category',
|
||||
pluginNotification,
|
||||
);
|
||||
|
||||
const {category} = pluginNotification.notification;
|
||||
const {blacklistedCategories} = store.getState().notifications;
|
||||
if (category && blacklistedCategories.indexOf(category) === -1) {
|
||||
store.dispatch(
|
||||
updateCategoryBlacklist([...blacklistedCategories, category]),
|
||||
);
|
||||
}
|
||||
} else if (arg === 2) {
|
||||
// Hide plugin
|
||||
logger.track('usage', 'notification-hide-plugin', pluginNotification);
|
||||
|
||||
const {blacklistedPlugins} = store.getState().notifications;
|
||||
if (blacklistedPlugins.indexOf(pluginNotification.pluginId) === -1) {
|
||||
store.dispatch(
|
||||
updatePluginBlacklist([
|
||||
...blacklistedPlugins,
|
||||
pluginNotification.pluginId,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
store.subscribe(() => {
|
||||
const {notifications, pluginStates} = store.getState();
|
||||
|
||||
const clientPlugins: Map<string, typeof FlipperPlugin> = store.getState()
|
||||
.plugins.clientPlugins;
|
||||
|
||||
const devicePlugins: Map<
|
||||
string,
|
||||
typeof FlipperDevicePlugin
|
||||
> = store.getState().plugins.devicePlugins;
|
||||
|
||||
const pluginMap: Map<
|
||||
string,
|
||||
typeof FlipperPlugin | typeof FlipperDevicePlugin
|
||||
> = new Map<string, typeof FlipperDevicePlugin | typeof FlipperPlugin>([
|
||||
...clientPlugins,
|
||||
...devicePlugins,
|
||||
]);
|
||||
|
||||
Object.keys(pluginStates).forEach(key => {
|
||||
if (knownPluginStates.get(key) !== pluginStates[key]) {
|
||||
knownPluginStates.set(key, pluginStates[key]);
|
||||
const plugin = deconstructPluginKey(key);
|
||||
const pluginName = plugin.pluginName;
|
||||
const client = plugin.client;
|
||||
|
||||
if (!pluginName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const persistingPlugin:
|
||||
| undefined
|
||||
| typeof FlipperPlugin
|
||||
| typeof FlipperDevicePlugin = pluginMap.get(pluginName);
|
||||
if (persistingPlugin && persistingPlugin.getActiveNotifications) {
|
||||
store.dispatch(
|
||||
setActiveNotifications({
|
||||
notifications: persistingPlugin.getActiveNotifications(
|
||||
pluginStates[key],
|
||||
),
|
||||
client,
|
||||
pluginId: pluginName,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
activeNotifications,
|
||||
blacklistedPlugins,
|
||||
blacklistedCategories,
|
||||
} = notifications;
|
||||
|
||||
activeNotifications.forEach((n: PluginNotification) => {
|
||||
if (
|
||||
!isHeadless() &&
|
||||
store.getState().connections.selectedPlugin !== 'notifications' &&
|
||||
!knownNotifications.has(n.notification.id) &&
|
||||
blacklistedPlugins.indexOf(n.pluginId) === -1 &&
|
||||
(!n.notification.category ||
|
||||
blacklistedCategories.indexOf(n.notification.category) === -1)
|
||||
) {
|
||||
const prevNotificationTime: number =
|
||||
lastNotificationTime.get(n.pluginId) || 0;
|
||||
lastNotificationTime.set(n.pluginId, new Date().getTime());
|
||||
knownNotifications.add(n.notification.id);
|
||||
|
||||
if (
|
||||
new Date().getTime() - prevNotificationTime <
|
||||
NOTIFICATION_THROTTLE
|
||||
) {
|
||||
// Don't send a notification if the plugin has sent a notification
|
||||
// within the NOTIFICATION_THROTTLE.
|
||||
return;
|
||||
}
|
||||
const plugin = pluginMap.get(n.pluginId);
|
||||
ipcRenderer.send('sendNotification', {
|
||||
payload: {
|
||||
title: n.notification.title,
|
||||
body: textContent(n.notification.message),
|
||||
actions: [
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Show',
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Hide similar',
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: `Hide all ${
|
||||
plugin != null ? getPluginTitle(plugin) : ''
|
||||
}`,
|
||||
},
|
||||
],
|
||||
closeButtonText: 'Hide',
|
||||
},
|
||||
closeAfter: 10000,
|
||||
pluginNotification: n,
|
||||
});
|
||||
logger.track('usage', 'native-notification', n.notification);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
26
desktop/app/src/dispatcher/pluginManager.tsx
Normal file
26
desktop/app/src/dispatcher/pluginManager.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Store} from '../reducers/index';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import {registerInstalledPlugins} from '../reducers/pluginManager';
|
||||
import {readInstalledPlugins} from '../utils/pluginManager';
|
||||
|
||||
function refreshInstalledPlugins(store: Store) {
|
||||
readInstalledPlugins().then(plugins =>
|
||||
store.dispatch(registerInstalledPlugins(plugins)),
|
||||
);
|
||||
}
|
||||
|
||||
export default (store: Store, _logger: Logger) => {
|
||||
// This needn't happen immediately and is (light) I/O work.
|
||||
window.requestIdleCallback(() => {
|
||||
refreshInstalledPlugins(store);
|
||||
});
|
||||
};
|
||||
187
desktop/app/src/dispatcher/plugins.tsx
Normal file
187
desktop/app/src/dispatcher/plugins.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Store} from '../reducers/index';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import {FlipperPlugin, FlipperDevicePlugin} from '../plugin';
|
||||
import {State} from '../reducers/plugins';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import adbkit from 'adbkit';
|
||||
import * as Flipper from '../index';
|
||||
import {
|
||||
registerPlugins,
|
||||
addGatekeepedPlugins,
|
||||
addDisabledPlugins,
|
||||
addFailedPlugins,
|
||||
} from '../reducers/plugins';
|
||||
import {ipcRenderer} from 'electron';
|
||||
import GK from '../fb-stubs/GK';
|
||||
import {FlipperBasePlugin} from '../plugin';
|
||||
import {setupMenuBar} from '../MenuBar';
|
||||
import path from 'path';
|
||||
import {default as config} from '../utils/processConfig';
|
||||
import isProduction from '../utils/isProduction';
|
||||
import {notNull} from '../utils/typeUtils';
|
||||
|
||||
export type PluginDefinition = {
|
||||
id?: string;
|
||||
name: string;
|
||||
out?: string;
|
||||
gatekeeper?: string;
|
||||
entry?: string;
|
||||
};
|
||||
|
||||
export default (store: Store, _logger: Logger) => {
|
||||
// expose Flipper and exact globally for dynamically loaded plugins
|
||||
const globalObject: any = typeof window === 'undefined' ? global : window;
|
||||
globalObject.React = React;
|
||||
globalObject.ReactDOM = ReactDOM;
|
||||
globalObject.Flipper = Flipper;
|
||||
globalObject.adbkit = adbkit;
|
||||
|
||||
const gatekeepedPlugins: Array<PluginDefinition> = [];
|
||||
const disabledPlugins: Array<PluginDefinition> = [];
|
||||
const failedPlugins: Array<[PluginDefinition, string]> = [];
|
||||
|
||||
const initialPlugins: Array<
|
||||
typeof FlipperPlugin | typeof FlipperDevicePlugin
|
||||
> = [...getBundledPlugins(), ...getDynamicPlugins()]
|
||||
.filter(checkDisabled(disabledPlugins))
|
||||
.filter(checkGK(gatekeepedPlugins))
|
||||
.map(requirePlugin(failedPlugins))
|
||||
.filter(notNull);
|
||||
|
||||
store.dispatch(addGatekeepedPlugins(gatekeepedPlugins));
|
||||
store.dispatch(addDisabledPlugins(disabledPlugins));
|
||||
store.dispatch(addFailedPlugins(failedPlugins));
|
||||
store.dispatch(registerPlugins(initialPlugins));
|
||||
|
||||
let state: State | null = null;
|
||||
store.subscribe(() => {
|
||||
const newState = store.getState().plugins;
|
||||
if (state !== newState) {
|
||||
setupMenuBar(
|
||||
[
|
||||
...newState.devicePlugins.values(),
|
||||
...newState.clientPlugins.values(),
|
||||
],
|
||||
store,
|
||||
);
|
||||
}
|
||||
state = newState;
|
||||
});
|
||||
};
|
||||
|
||||
function getBundledPlugins(): Array<PluginDefinition> {
|
||||
if (!isProduction() || process.env.FLIPPER_NO_EMBEDDED_PLUGINS) {
|
||||
// Plugins are only bundled in production builds
|
||||
return [];
|
||||
}
|
||||
|
||||
// DefaultPlugins that are included in the bundle.
|
||||
// List of defaultPlugins is written at build time
|
||||
const pluginPath =
|
||||
process.env.BUNDLED_PLUGIN_PATH || path.join(__dirname, 'defaultPlugins');
|
||||
|
||||
let bundledPlugins: Array<PluginDefinition> = [];
|
||||
try {
|
||||
bundledPlugins = global.electronRequire(
|
||||
path.join(pluginPath, 'index.json'),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return bundledPlugins
|
||||
.filter(plugin => notNull(plugin.out))
|
||||
.map(plugin => ({
|
||||
...plugin,
|
||||
out: path.join(pluginPath, plugin.out!),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getDynamicPlugins() {
|
||||
let dynamicPlugins: Array<PluginDefinition> = [];
|
||||
try {
|
||||
dynamicPlugins = ipcRenderer.sendSync('get-dynamic-plugins');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return dynamicPlugins;
|
||||
}
|
||||
|
||||
export const checkGK = (gatekeepedPlugins: Array<PluginDefinition>) => (
|
||||
plugin: PluginDefinition,
|
||||
): boolean => {
|
||||
if (!plugin.gatekeeper) {
|
||||
return true;
|
||||
}
|
||||
const result = GK.get(plugin.gatekeeper);
|
||||
if (!result) {
|
||||
gatekeepedPlugins.push(plugin);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const checkDisabled = (disabledPlugins: Array<PluginDefinition>) => (
|
||||
plugin: PluginDefinition,
|
||||
): boolean => {
|
||||
let disabledList: Set<string> = new Set();
|
||||
try {
|
||||
disabledList = config().disabledPlugins;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if (disabledList.has(plugin.name)) {
|
||||
disabledPlugins.push(plugin);
|
||||
}
|
||||
|
||||
return !disabledList.has(plugin.name);
|
||||
};
|
||||
|
||||
export const requirePlugin = (
|
||||
failedPlugins: Array<[PluginDefinition, string]>,
|
||||
reqFn: Function = global.electronRequire,
|
||||
) => {
|
||||
return (
|
||||
pluginDefinition: PluginDefinition,
|
||||
): typeof FlipperPlugin | typeof FlipperDevicePlugin | null => {
|
||||
try {
|
||||
let plugin = reqFn(pluginDefinition.out);
|
||||
if (plugin.default) {
|
||||
plugin = plugin.default;
|
||||
}
|
||||
if (!(plugin.prototype instanceof FlipperBasePlugin)) {
|
||||
throw new Error(`Plugin ${plugin.name} is not a FlipperBasePlugin`);
|
||||
}
|
||||
|
||||
// set values from package.json as static variables on class
|
||||
Object.keys(pluginDefinition).forEach(key => {
|
||||
if (key === 'name') {
|
||||
plugin.id = plugin.id || pluginDefinition.name;
|
||||
} else if (key === 'id') {
|
||||
throw new Error(
|
||||
'Field "id" not allowed in package.json. The plugin\'s name will be used as ID"',
|
||||
);
|
||||
} else {
|
||||
plugin[key] =
|
||||
plugin[key] || pluginDefinition[key as keyof PluginDefinition];
|
||||
}
|
||||
});
|
||||
|
||||
return plugin;
|
||||
} catch (e) {
|
||||
failedPlugins.push([pluginDefinition, e.message]);
|
||||
console.error(pluginDefinition, e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
};
|
||||
53
desktop/app/src/dispatcher/reactNative.tsx
Normal file
53
desktop/app/src/dispatcher/reactNative.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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 {remote} from 'electron';
|
||||
import {MetroDevice} from 'flipper';
|
||||
import {Store} from 'app/src/reducers';
|
||||
|
||||
type ShortcutEventCommand =
|
||||
| {
|
||||
shortcut: string;
|
||||
command: string;
|
||||
}
|
||||
| '';
|
||||
|
||||
export default (store: Store) => {
|
||||
const settings = store.getState().settingsState.reactNative;
|
||||
|
||||
if (!settings.shortcuts.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcuts: ShortcutEventCommand[] = [
|
||||
settings.shortcuts.reload && {
|
||||
shortcut: settings.shortcuts.reload,
|
||||
command: 'reload',
|
||||
},
|
||||
settings.shortcuts.openDevMenu && {
|
||||
shortcut: settings.shortcuts.openDevMenu,
|
||||
command: 'devMenu',
|
||||
},
|
||||
];
|
||||
|
||||
shortcuts.forEach(
|
||||
(shortcut: ShortcutEventCommand) =>
|
||||
shortcut &&
|
||||
shortcut.shortcut &&
|
||||
remote.globalShortcut.register(shortcut.shortcut, () => {
|
||||
const devices = store
|
||||
.getState()
|
||||
.connections.devices.filter(
|
||||
device => device.os === 'Metro' && !device.isArchived,
|
||||
) as MetroDevice[];
|
||||
|
||||
devices.forEach(device => device.sendCommand(shortcut.command));
|
||||
}),
|
||||
);
|
||||
};
|
||||
91
desktop/app/src/dispatcher/server.tsx
Normal file
91
desktop/app/src/dispatcher/server.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 Server from '../server';
|
||||
|
||||
import {Store} from '../reducers/index';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import Client from '../Client';
|
||||
import {UninitializedClient} from '../UninitializedClient';
|
||||
|
||||
export default (store: Store, logger: Logger) => {
|
||||
const server = new Server(logger, store);
|
||||
server.init();
|
||||
|
||||
server.addListener('new-client', (client: Client) => {
|
||||
store.dispatch({
|
||||
type: 'NEW_CLIENT',
|
||||
payload: client,
|
||||
});
|
||||
});
|
||||
|
||||
server.addListener('removed-client', (id: string) => {
|
||||
store.dispatch({
|
||||
type: 'CLIENT_REMOVED',
|
||||
payload: id,
|
||||
});
|
||||
store.dispatch({
|
||||
type: 'CLEAR_PLUGIN_STATE',
|
||||
payload: {
|
||||
clientId: id,
|
||||
devicePlugins: new Set([
|
||||
...store.getState().plugins.devicePlugins.keys(),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
server.addListener('error', err => {
|
||||
const message: string =
|
||||
err.code === 'EADDRINUSE'
|
||||
? "Couldn't start websocket server. Looks like you have multiple copies of Flipper running."
|
||||
: err.message || 'Unknown error';
|
||||
const urgent = err.code === 'EADDRINUSE';
|
||||
|
||||
store.dispatch({
|
||||
type: 'SERVER_ERROR',
|
||||
payload: {message},
|
||||
urgent,
|
||||
});
|
||||
});
|
||||
|
||||
server.addListener('start-client-setup', (client: UninitializedClient) => {
|
||||
store.dispatch({
|
||||
type: 'START_CLIENT_SETUP',
|
||||
payload: client,
|
||||
});
|
||||
});
|
||||
|
||||
server.addListener(
|
||||
'finish-client-setup',
|
||||
(payload: {client: UninitializedClient; deviceId: string}) => {
|
||||
store.dispatch({
|
||||
type: 'FINISH_CLIENT_SETUP',
|
||||
payload: payload,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.addListener(
|
||||
'client-setup-error',
|
||||
(payload: {client: UninitializedClient; error: Error}) => {
|
||||
store.dispatch({
|
||||
type: 'CLIENT_SETUP_ERROR',
|
||||
payload: payload,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
return server.close;
|
||||
};
|
||||
260
desktop/app/src/dispatcher/tracking.tsx
Normal file
260
desktop/app/src/dispatcher/tracking.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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 {ipcRenderer} from 'electron';
|
||||
import {performance} from 'perf_hooks';
|
||||
import {EventEmitter} from 'events';
|
||||
|
||||
import {Store} from '../reducers/index';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import Client from '../Client';
|
||||
import {
|
||||
getPluginBackgroundStats,
|
||||
resetPluginBackgroundStatsDelta,
|
||||
} from '../utils/messageQueue';
|
||||
import {
|
||||
clearTimeline,
|
||||
TrackingEvent,
|
||||
State as UsageTrackingState,
|
||||
} from '../reducers/usageTracking';
|
||||
import produce from 'immer';
|
||||
import {BaseDevice} from 'flipper';
|
||||
import {deconstructClientId} from '../utils/clientUtils';
|
||||
|
||||
const TIME_SPENT_EVENT = 'time-spent';
|
||||
|
||||
type UsageInterval = {
|
||||
plugin: string | null;
|
||||
length: number;
|
||||
focused: boolean;
|
||||
};
|
||||
|
||||
export type UsageSummary = {
|
||||
total: {focusedTime: number; unfocusedTime: number};
|
||||
[pluginName: string]: {focusedTime: number; unfocusedTime: number};
|
||||
};
|
||||
|
||||
export const fpsEmitter = new EventEmitter();
|
||||
|
||||
export default (store: Store, logger: Logger) => {
|
||||
let droppedFrames: number = 0;
|
||||
let largeFrameDrops: number = 0;
|
||||
|
||||
const oldExitData = loadExitData();
|
||||
if (oldExitData) {
|
||||
const timeSinceLastStartup =
|
||||
Date.now() - parseInt(oldExitData.lastSeen, 10);
|
||||
logger.track('usage', 'restart', {
|
||||
...oldExitData,
|
||||
timeSinceLastStartup,
|
||||
});
|
||||
}
|
||||
|
||||
function droppedFrameDetection(
|
||||
past: DOMHighResTimeStamp,
|
||||
isWindowFocused: () => boolean,
|
||||
) {
|
||||
const now = performance.now();
|
||||
requestAnimationFrame(() => droppedFrameDetection(now, isWindowFocused));
|
||||
const delta = now - past;
|
||||
const dropped = Math.round(delta / (1000 / 60) - 1);
|
||||
fpsEmitter.emit('fps', delta > 1000 ? 0 : Math.round(1000 / (now - past)));
|
||||
if (!isWindowFocused() || dropped < 1) {
|
||||
return;
|
||||
}
|
||||
droppedFrames += dropped;
|
||||
if (dropped > 3) {
|
||||
largeFrameDrops++;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
droppedFrameDetection(
|
||||
performance.now(),
|
||||
() => store.getState().application.windowIsFocused,
|
||||
);
|
||||
}
|
||||
|
||||
ipcRenderer.on('trackUsage', () => {
|
||||
const state = store.getState();
|
||||
const {
|
||||
selectedDevice,
|
||||
selectedPlugin,
|
||||
selectedApp,
|
||||
clients,
|
||||
} = state.connections;
|
||||
|
||||
persistExitData({selectedDevice, selectedPlugin, selectedApp});
|
||||
|
||||
const currentTime = Date.now();
|
||||
const usageSummary = computeUsageSummary(state.usageTracking, currentTime);
|
||||
|
||||
store.dispatch(clearTimeline(currentTime));
|
||||
|
||||
logger.track('usage', TIME_SPENT_EVENT, usageSummary.total);
|
||||
for (const key of Object.keys(usageSummary)) {
|
||||
logger.track('usage', TIME_SPENT_EVENT, usageSummary[key], key);
|
||||
}
|
||||
|
||||
Object.entries(state.connections.userStarredPlugins).forEach(
|
||||
([app, plugins]) =>
|
||||
logger.track('usage', 'starred-plugins', {
|
||||
app: app,
|
||||
starredPlugins: plugins,
|
||||
}),
|
||||
);
|
||||
|
||||
logger.track('usage', 'plugin-stats', getPluginBackgroundStats());
|
||||
resetPluginBackgroundStatsDelta();
|
||||
|
||||
if (
|
||||
!state.application.windowIsFocused ||
|
||||
!selectedDevice ||
|
||||
!selectedPlugin
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let app: string | null = null;
|
||||
let sdkVersion: number | null = null;
|
||||
|
||||
if (selectedApp) {
|
||||
const client = clients.find((c: Client) => c.id === selectedApp);
|
||||
if (client) {
|
||||
app = client.query.app;
|
||||
sdkVersion = client.query.sdk_version || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const info = {
|
||||
droppedFrames,
|
||||
largeFrameDrops,
|
||||
os: selectedDevice.os,
|
||||
device: selectedDevice.title,
|
||||
plugin: selectedPlugin,
|
||||
app,
|
||||
sdkVersion,
|
||||
isForeground: state.application.windowIsFocused,
|
||||
usedJSHeapSize: (window.performance as any).memory.usedJSHeapSize,
|
||||
};
|
||||
|
||||
// reset dropped frames counter
|
||||
droppedFrames = 0;
|
||||
largeFrameDrops = 0;
|
||||
|
||||
logger.track('usage', 'ping', info);
|
||||
});
|
||||
};
|
||||
|
||||
export function computeUsageSummary(
|
||||
state: UsageTrackingState,
|
||||
currentTime: number,
|
||||
) {
|
||||
const intervals: UsageInterval[] = [];
|
||||
let intervalStart = 0;
|
||||
let isFocused = false;
|
||||
let selectedPlugin: string | null = null;
|
||||
|
||||
function startInterval(event: TrackingEvent) {
|
||||
intervalStart = event.time;
|
||||
if (
|
||||
event.type === 'TIMELINE_START' ||
|
||||
event.type === 'WINDOW_FOCUS_CHANGE'
|
||||
) {
|
||||
isFocused = event.isFocused;
|
||||
}
|
||||
if (event.type === 'PLUGIN_SELECTED') {
|
||||
selectedPlugin = event.plugin;
|
||||
}
|
||||
}
|
||||
function endInterval(time: number) {
|
||||
const length = time - intervalStart;
|
||||
intervals.push({length, plugin: selectedPlugin, focused: isFocused});
|
||||
}
|
||||
|
||||
for (const event of state.timeline) {
|
||||
if (
|
||||
event.type === 'TIMELINE_START' ||
|
||||
event.type === 'WINDOW_FOCUS_CHANGE' ||
|
||||
event.type === 'PLUGIN_SELECTED'
|
||||
) {
|
||||
if (event.type !== 'TIMELINE_START') {
|
||||
endInterval(event.time);
|
||||
}
|
||||
startInterval(event);
|
||||
}
|
||||
}
|
||||
endInterval(currentTime);
|
||||
|
||||
return intervals.reduce<UsageSummary>(
|
||||
(acc: UsageSummary, x: UsageInterval) =>
|
||||
produce(acc, draft => {
|
||||
draft.total.focusedTime += x.focused ? x.length : 0;
|
||||
draft.total.unfocusedTime += x.focused ? 0 : x.length;
|
||||
const pluginName = x.plugin ?? 'none';
|
||||
draft[pluginName] = draft[pluginName] ?? {
|
||||
focusedTime: 0,
|
||||
unfocusedTime: 0,
|
||||
};
|
||||
draft[pluginName].focusedTime += x.focused ? x.length : 0;
|
||||
draft[pluginName].unfocusedTime += x.focused ? 0 : x.length;
|
||||
}),
|
||||
{
|
||||
total: {focusedTime: 0, unfocusedTime: 0},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const flipperExitDataKey = 'FlipperExitData';
|
||||
|
||||
interface ExitData {
|
||||
lastSeen: string;
|
||||
deviceOs: string;
|
||||
deviceType: string;
|
||||
deviceTitle: string;
|
||||
plugin: string;
|
||||
app: string;
|
||||
}
|
||||
|
||||
function loadExitData(): ExitData | undefined {
|
||||
if (!window.localStorage) {
|
||||
return undefined;
|
||||
}
|
||||
const data = window.localStorage.getItem(flipperExitDataKey);
|
||||
if (data) {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse flipperExitData', e);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function persistExitData(state: {
|
||||
selectedDevice: BaseDevice | null;
|
||||
selectedPlugin: string | null;
|
||||
selectedApp: string | null;
|
||||
}) {
|
||||
if (!window.localStorage) {
|
||||
return;
|
||||
}
|
||||
const exitData: ExitData = {
|
||||
lastSeen: '' + Date.now(),
|
||||
deviceOs: state.selectedDevice ? state.selectedDevice.os : '',
|
||||
deviceType: state.selectedDevice ? state.selectedDevice.deviceType : '',
|
||||
deviceTitle: state.selectedDevice ? state.selectedDevice.title : '',
|
||||
plugin: state.selectedPlugin || '',
|
||||
app: state.selectedApp ? deconstructClientId(state.selectedApp).app : '',
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
flipperExitDataKey,
|
||||
JSON.stringify(exitData, null, 2),
|
||||
);
|
||||
}
|
||||
16
desktop/app/src/dispatcher/types.tsx
Normal file
16
desktop/app/src/dispatcher/types.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Store} from '../reducers/index';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
|
||||
export type Dispatcher = (
|
||||
store: Store,
|
||||
logger: Logger,
|
||||
) => (() => Promise<void>) | null | void;
|
||||
32
desktop/app/src/dispatcher/user.tsx
Normal file
32
desktop/app/src/dispatcher/user.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 {Store} from '../reducers/index';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import {login, logout} from '../reducers/user';
|
||||
import {getUser, logoutUser} from '../fb-stubs/user';
|
||||
|
||||
export default (store: Store, _logger: Logger) => {
|
||||
getUser()
|
||||
.then(user => {
|
||||
store.dispatch(login(user));
|
||||
})
|
||||
.catch(e => {
|
||||
store.dispatch(logout());
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
let prevUserName = store.getState().user.name;
|
||||
store.subscribe(() => {
|
||||
if (prevUserName && !store.getState().user.name) {
|
||||
logoutUser();
|
||||
}
|
||||
prevUserName = store.getState().user.name;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user