Files
flipper/desktop/plugins/crash_reporter/index.tsx
Michel Weststrate ecf4cff7cf Fix most lint warnings
Summary:
I noticed that after the typescript upgrade, I got several weird positives from ESLint (like unused parameters in a type definition, which are obviously always unused, e.g. `type onClick = (e: Event) => void`). After some investigation, it turned out these warnings are generated by eslint, but that those rules should be performaned by typescript/eslint instead. For future reference to which rules this applies:

https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/README.md#extension-rules

Updated the config, and while at it, fixed all warnings in our codebase, except for `react-hooks/exhaustive-deps` warnings, since those require semantic changes.

This reduces the amount of eslint warnings from 86 to 39.

Reviewed By: passy

Differential Revision: D23905630

fbshipit-source-id: 0557708fd9ec6b17840a3c191e7d3baf225bdf23
2020-09-28 01:42:48 -07:00

823 lines
22 KiB
TypeScript

/**
* 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 {
FlipperBasePlugin,
FlipperDevicePlugin,
Device,
View,
styled,
FlexColumn,
FlexRow,
ContextMenu,
clipboard,
Button,
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 'flipper';
import type {Store, DeviceLogEntry, OS, Props} from 'flipper';
import React from 'react';
import {Component} from 'react';
type Maybe<T> = T | null | undefined;
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: (name: Maybe<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: Maybe<Date>;
};
export type PersistedState = {
crashes: Array<Crash>;
};
type State = {
crash?: Crash;
};
const Padder = styled.div<{
paddingLeft?: number;
paddingRight?: number;
paddingBottom?: number;
paddingTop?: number;
}>(({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 getNewPersistedStateFromCrashLog(
persistedState: Maybe<PersistedState>,
persistingPlugin: typeof FlipperBasePlugin,
content: string,
os: Maybe<OS>,
logDate: Maybe<Date>,
): Maybe<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: Maybe<PersistedState>,
) => void,
logDate: Maybe<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:
| typeof FlipperBasePlugin
| undefined = store
.getState()
.plugins.devicePlugins.get(CrashReporterPlugin.id) as any;
if (!persistingPlugin) {
return;
}
if (!persistingPlugin.persistedStateReducer) {
console.error('CrashReporterPlugin is incompatible');
return;
}
const pluginStates = store.getState().pluginStates;
const persistedState = getPersistedState(
pluginKey,
persistingPlugin,
pluginStates,
);
if (!persistedState) {
return;
}
const newPluginState = getNewPersistedStateFromCrashLog(
persistedState as PersistedState,
persistingPlugin,
content,
os,
logDate,
);
setPersistedState(pluginKey, newPluginState);
}
export function shouldShowCrashNotification(
baseDevice: Maybe<BaseDevice>,
content: string,
os: Maybe<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: Maybe<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: Maybe<string> =
tmp1 && tmp1[0].length ? tmp1[0] : null;
date = extractedDateString ? new Date(extractedDateString) : logDate;
}
const crash: CrashLog = {
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 reasonText =
remainingString.length > 0
? remainingString.split('\n').pop()
: fallbackReason;
const reason = reasonText ? reasonText : fallbackReason;
if (name[name.length - 1] === '\n') {
name = name.slice(0, -1);
}
const crash: CrashLog = {
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): Maybe<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: Maybe<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,
null,
);
});
});
});
}
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 && selectedCrashID) {
const index = orderedIDs.indexOf(selectedCrashID as string);
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 && selectedCrashID) {
const index = orderedIDs.indexOf(selectedCrashID as string);
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,
any,
PersistedState
> {
static defaultPersistedState: PersistedState = {
crashes: [],
};
static supportsDevice(device: Device) {
return (
(device.os === 'iOS' && device.deviceType !== 'physical') ||
device.os === 'Android'
);
}
static notificationID: number = 0;
/*
* Reducer to process incoming "send" messages from the mobile counterpart.
*/
static persistedStateReducer = (
persistedState: PersistedState,
method: string,
payload: CrashLog | Crash,
): 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: Maybe<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: Maybe<PersistedState>,
) => void,
) {
let androidLog: string = '';
let androidLogUnderProcess = false;
let timer: Maybe<NodeJS.Timeout> = 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 | undefined = undefined;
if (
this.props.persistedState.crashes &&
this.props.persistedState.crashes.length > 0
) {
crash = this.props.persistedState.crashes[
this.props.persistedState.crashes.length - 1
];
}
let deeplinkedCrash: Crash | undefined = undefined;
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: Maybe<string>) => {
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: undefined,
orderedIDs: undefined,
selectedCrashID: undefined,
onCrashChange: () => void {},
};
return (
<StyledFlexGrowColumn>
<CrashReporterBar crashSelector={crashSelector} />
<StyledFlexColumn>
<Padder paddingBottom={8}>
<Title>No Crashes Logged</Title>
</Padder>
</StyledFlexColumn>
</StyledFlexGrowColumn>
);
}
}