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:
Anton Nikolaev
2020-03-20 13:31:37 -07:00
committed by Facebook GitHub Bot
parent 676d7bbd24
commit 863f89351e
340 changed files with 1635 additions and 294 deletions

View 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();
});

View File

@@ -0,0 +1,33 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {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([]);
});

View 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);
});

View 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,
};
},
});

View 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();
});
};

View 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".`,
);
}
}
};

View 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,
});
};

View 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(),
);
};

View File

@@ -0,0 +1,56 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {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(() => {});
};
}

View 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);
}
};
};

View 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);
}
});
});
};

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {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);
});
};

View 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;
}
};
};

View 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));
}),
);
};

View 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;
};

View 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),
);
}

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger';
export type Dispatcher = (
store: Store,
logger: Logger,
) => (() => Promise<void>) | null | void;

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {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;
});
};