diff --git a/src/dispatcher/iOSDevice.js b/src/dispatcher/iOSDevice.js index 27c5938c1..471adc4ea 100644 --- a/src/dispatcher/iOSDevice.js +++ b/src/dispatcher/iOSDevice.js @@ -9,7 +9,8 @@ import type {ChildProcess} from 'child_process'; import type {Store} from '../reducers/index.js'; import type Logger from '../fb-stubs/Logger.js'; import type {DeviceType} from '../devices/BaseDevice'; - +import BaseDevice from '../devices/BaseDevice'; +import type {PersistedState} from '../plugins/crash_reporter'; import {RecurringError} from '../utils/errors'; import {promisify} from 'util'; import path from 'path'; @@ -19,6 +20,12 @@ import IOSDevice from '../devices/IOSDevice'; import iosUtil from '../fb-stubs/iOSContainerUtility'; import isProduction from '../utils/isProduction.js'; import GK from '../fb-stubs/GK'; +import fs from 'fs'; +import os from 'os'; +import util from 'util'; +import {setPluginState} from '../reducers/pluginStates.js'; +import {FlipperDevicePlugin, FlipperPlugin} from '../plugin.js'; +import type {State as PluginStatesState} from '../reducers/pluginStates.js'; type iOSSimulatorDevice = {| state: 'Booted' | 'Shutdown' | 'Shutting Down', @@ -28,6 +35,12 @@ type iOSSimulatorDevice = {| udid: string, |}; +type Crash = {| + callstack: string, + reason: string, + name: string, +|}; + type IOSDeviceParams = {udid: string, type: DeviceType, name: string}; const portforwardingClient = isProduction() @@ -51,6 +64,120 @@ window.addEventListener('beforeunload', () => { portForwarders.forEach(process => process.kill()); }); +export function parseCrashLog(content: string): Crash { + const regex = /Exception Type: *[aA-zZ0-9]*/; + const arr = regex.exec(content); + const exceptionString = arr ? arr[0] : ''; + const exceptionRegex = /[aA-zZ0-9]*$/; + const tmp = exceptionRegex.exec(exceptionString); + const exception = + tmp && tmp[0].length ? tmp[0] : 'Cannot figure out the cause'; + const crash = { + callstack: content, + name: exception, + reason: exception, + }; + return crash; +} + +export function getPersistedState( + pluginKey: string, + persistingPlugin: ?Class | FlipperDevicePlugin<>>, + pluginStates: PluginStatesState, +): ?PersistedState { + if (!persistingPlugin) { + return null; + } + const persistedState = { + ...persistingPlugin.defaultPersistedState, + ...pluginStates[pluginKey], + }; + return persistedState; +} + +export function getPluginKey( + selectedDevice: ?BaseDevice, + pluginID: string, +): string { + return `${selectedDevice?.serial || 'unknown'}#${pluginID}`; +} + +export function getNewPersisitedStateFromCrashLog( + persistedState: ?PersistedState, + persistingPlugin: Class | FlipperPlugin<>>, + content: string, +): ?PersistedState { + const crash = parseCrashLog(content); + if (!persistingPlugin.persistedStateReducer) { + return null; + } + const newPluginState = persistingPlugin.persistedStateReducer( + persistedState, + 'crash-report', + crash, + ); + return newPluginState; +} + +function parseCrashLogAndUpdateState(store: Store, content: string) { + const pluginID = 'CrashReporter'; + const pluginKey = getPluginKey( + store.getState().connections.selectedDevice, + pluginID, + ); + const persistingPlugin: ?Class< + FlipperDevicePlugin<> | FlipperPlugin<>, + > = store.getState().plugins.devicePlugins.get('CrashReporter'); + if (!persistingPlugin) { + return; + } + const pluginStates = store.getState().pluginStates; + const persistedState = getPersistedState( + pluginKey, + persistingPlugin, + pluginStates, + ); + const newPluginState = getNewPersisitedStateFromCrashLog( + persistedState, + persistingPlugin, + content, + ); + if (newPluginState && persistedState !== newPluginState) { + store.dispatch( + setPluginState({ + pluginKey, + state: newPluginState, + }), + ); + } +} + +function addFileWatcherForiOSCrashLogs(store: Store, logger: Logger) { + 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; + } + fs.readFile(path.join(dir, filename), '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)); + }); + }); +} + function queryDevices(store: Store, logger: Logger): Promise { const {connections} = store.getState(); const currentDeviceIDs: Set = new Set( @@ -132,6 +259,7 @@ export default (store: Store, logger: Logger) => { if (process.platform !== 'darwin') { return; } + addFileWatcherForiOSCrashLogs(store, logger); queryDevices(store, logger) .then(() => { const simulatorUpdateInterval = setInterval(() => { diff --git a/src/plugins/crash_reporter/__tests__/testCrashReporterPlugin.electron.js b/src/plugins/crash_reporter/__tests__/testCrashReporterPlugin.electron.js new file mode 100644 index 000000000..55c8b6e26 --- /dev/null +++ b/src/plugins/crash_reporter/__tests__/testCrashReporterPlugin.electron.js @@ -0,0 +1,201 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import { + parseCrashLog, + getPluginKey, + getPersistedState, + getNewPersisitedStateFromCrashLog, +} from '../../../dispatcher/iOSDevice.js'; +import BaseDevice from '../../../devices/BaseDevice'; +import CrashReporterPlugin from '../../crash_reporter'; +import type {PersistedState, Crash} from '../../crash_reporter'; + +function setDefaultPersistedState(defaultState: PersistedState) { + CrashReporterPlugin.defaultPersistedState = defaultState; +} + +function setNotificationID(notificationID: number) { + CrashReporterPlugin.notificationID = notificationID; +} + +function getCrash( + id: number, + callstack: string, + name: string, + reason: string, +): Crash { + return { + notificationID: id.toString(), + callstack: callstack, + reason: reason, + name: name, + }; +} +beforeEach(() => { + setNotificationID(0); // Resets notificationID to 0 + setDefaultPersistedState({crashes: []}); // Resets defaultpersistedstate +}); +test('test the parsing of the reason for crash when log matches the predefined regex', () => { + const log = 'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV'; + const crash = parseCrashLog(log); + expect(crash.callstack).toEqual(log); + expect(crash.reason).toEqual('SIGSEGV'); + expect(crash.name).toEqual('SIGSEGV'); +}); +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); + 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: 🍕🐬'; + const crash = parseCrashLog(log); + 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 is empty', () => { + const log = ''; + const crash = parseCrashLog(log); + 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(device, 'CrashReporter'); + expect(pluginKey).toEqual('serial#CrashReporter'); +}); +test('test the getter of pluginKey with undefined input', () => { + const pluginKey = getPluginKey(undefined, 'CrashReporter'); + expect(pluginKey).toEqual('unknown#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, 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, CrashReporterPlugin.id); + setDefaultPersistedState({crashes: [crash]}); + const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1'); + //$FlowFixMe + const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}}; + const perisistedState = getPersistedState( + pluginKey, + CrashReporterPlugin, + pluginStates, + ); + expect(perisistedState).toEqual({crashes: [pluginStateCrash]}); +}); +test('test getPersistedState for non-empty defaultPersistedState and defined pluginState', () => { + const crash = getCrash(0, 'callstack', 'crash0', 'crash0'); + const pluginKey = getPluginKey(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, 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'; + expect(perisistedState).toEqual({crashes: [pluginStateCrash]}); + const newPersistedState = getNewPersisitedStateFromCrashLog( + perisistedState, + CrashReporterPlugin, + content, + ); + expect(newPersistedState).toEqual({ + crashes: [pluginStateCrash, 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, 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, + ); + expect(newPersistedState).toEqual({ + crashes: [crash, 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, 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, + ); + expect(newPersistedState).toEqual({ + crashes: [ + pluginStateCrash, + getCrash( + 1, + content, + 'Cannot figure out the cause', + 'Cannot figure out the cause', + ), + ], + }); +}); diff --git a/src/plugins/crash_reporter/index.js b/src/plugins/crash_reporter/index.js index ba7a89bde..ee1721370 100644 --- a/src/plugins/crash_reporter/index.js +++ b/src/plugins/crash_reporter/index.js @@ -19,13 +19,14 @@ import { } from 'flipper'; import type {Notification} from '../../plugin'; -type Crash = {| - notificationID: number, - callStack: string, +export type Crash = {| + notificationID: string, + callstack: string, reason: string, name: string, |}; -type PersistedState = {| + +export type PersistedState = {| crashes: Array, |}; @@ -70,6 +71,8 @@ export default class CrashReporterPlugin extends FlipperDevicePlugin { 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. */ @@ -79,21 +82,27 @@ export default class CrashReporterPlugin extends FlipperDevicePlugin { payload: Object, ): PersistedState => { if (method === 'crash-report') { - return { - ...persistedState, + CrashReporterPlugin.notificationID++; + const mergedState: PersistedState = { crashes: persistedState.crashes.concat([ { - notificationID: Math.random(), // All notifications are unique - callStack: payload.callstack, + notificationID: CrashReporterPlugin.notificationID.toString(), // All notifications are unique + callstack: payload.callstack, name: payload.name, reason: payload.reason, }, ]), }; + return mergedState; } return persistedState; }; + static trimCallStackIfPossible = (callstack: string): string => { + let regex = /Application Specific Information:/; + const query = regex.exec(callstack); + return query ? callstack.substring(0, query.index) : callstack; + }; /* * Callback to provide the currently active notifications. */ @@ -101,10 +110,10 @@ export default class CrashReporterPlugin extends FlipperDevicePlugin { persistedState: PersistedState, ): Array => { return persistedState.crashes.map((crash: Crash) => { - const id = 'crash-notification:' + crash.notificationID; + const id = crash.notificationID; return { id, - message: crash.callStack, + message: CrashReporterPlugin.trimCallStackIfPossible(crash.callstack), severity: 'error', title: 'CRASH: ' + crash.name + ' ' + crash.reason, action: id, @@ -124,7 +133,7 @@ export default class CrashReporterPlugin extends FlipperDevicePlugin { ]; if (crash) { - const callStackString = crash.callStack; + const callstackString = crash.callstack; return ( @@ -144,16 +153,16 @@ export default class CrashReporterPlugin extends FlipperDevicePlugin { { label: 'copy', click: () => { - clipboard.writeText(callStackString); + clipboard.writeText(callstackString); }, }, ]}> - {callStackString} + {callstackString} {this.device.os == 'Android' && ( -