diff --git a/desktop/app/src/Client.tsx b/desktop/app/src/Client.tsx index 93ef7cb8d..5e806f89d 100644 --- a/desktop/app/src/Client.tsx +++ b/desktop/app/src/Client.tsx @@ -65,26 +65,17 @@ type Params = { type RequestMetadata = {method: string; id: number; params: Params | undefined}; const handleError = (store: Store, device: BaseDevice, error: ErrorType) => { - if (isProduction()) { + if (store.getState().settingsState.suppressPluginErrors) { return; } - const crashReporterPlugin: typeof FlipperDevicePlugin = store - .getState() - .plugins.devicePlugins.get('CrashReporter') as any; + const crashReporterPlugin = device.sandyPluginStates.get('CrashReporter'); if (!crashReporterPlugin) { return; } - if (!crashReporterPlugin.persistedStateReducer) { - console.error('CrashReporterPlugin persistedStateReducer broken'); // Make sure we update this code if we ever convert it to Sandy + if (!crashReporterPlugin.instanceApi.reportCrash) { + console.error('CrashReporterPlugin persistedStateReducer broken'); return; } - - const pluginKey = getPluginKey(null, device, 'CrashReporter'); - - const persistedState = { - ...crashReporterPlugin.defaultPersistedState, - ...store.getState().pluginStates[pluginKey], - }; const isCrashReport: boolean = Boolean(error.name || error.message); const payload = isCrashReport ? { @@ -96,23 +87,7 @@ const handleError = (store: Store, device: BaseDevice, error: ErrorType) => { name: 'Plugin Error', reason: JSON.stringify(error), }; - - const newPluginState = - crashReporterPlugin.persistedStateReducer == null - ? persistedState - : crashReporterPlugin.persistedStateReducer( - persistedState, - 'flipper-crash-report', - payload, - ); - if (persistedState !== newPluginState) { - store.dispatch( - setPluginState({ - pluginKey, - state: newPluginState, - }), - ); - } + crashReporterPlugin.instanceApi.reportCrash(payload); }; export interface FlipperClientConnection { diff --git a/desktop/app/src/chrome/SettingsSheet.tsx b/desktop/app/src/chrome/SettingsSheet.tsx index f5be7eb9d..1ea0cfc05 100644 --- a/desktop/app/src/chrome/SettingsSheet.tsx +++ b/desktop/app/src/chrome/SettingsSheet.tsx @@ -112,6 +112,7 @@ class SettingsSheet extends Component { idbPath, reactNative, darkMode, + suppressPluginErrors, } = this.state.updatedSettings; const settingsPristine = @@ -232,6 +233,18 @@ class SettingsSheet extends Component { }); }} /> + { + this.setState((prevState) => ({ + updatedSettings: { + ...prevState.updatedSettings, + suppressPluginErrors: enabled, + }, + })); + }} + /> { } `); }); + +test('notifications from plugins arrive in the notifications reducer', async () => { + const TestPlugin = TestUtils.createTestPlugin({ + plugin(client: PluginClient) { + client.onUnhandledMessage(() => { + client.showNotification({ + id: 'test', + message: 'test message', + severity: 'error', + title: 'hi', + action: 'dosomething', + }); + }); + return {}; + }, + }); + + const {store, client, sendMessage} = await createMockFlipperWithPlugin( + TestPlugin, + ); + sendMessage('testMessage', {}); + client.flushMessageBuffer(); + expect(store.getState().notifications).toMatchInlineSnapshot(` + Object { + "activeNotifications": Array [ + Object { + "client": "TestApp#Android#MockAndroidDevice#serial", + "notification": Object { + "action": "dosomething", + "id": "test", + "message": "test message", + "severity": "error", + "title": "hi", + }, + "pluginId": "TestPlugin", + }, + ], + "blocklistedCategories": Array [], + "blocklistedPlugins": Array [], + "clearedNotifications": Set {}, + "invalidatedNotifications": Array [], + } + `); +}); + +test('notifications from a device plugin arrive in the notifications reducer', async () => { + let trigger: any; + const TestPlugin = TestUtils.createTestDevicePlugin({ + devicePlugin(client: DevicePluginClient) { + trigger = () => { + client.showNotification({ + id: 'test', + message: 'test message', + severity: 'error', + title: 'hi', + action: 'dosomething', + }); + }; + return {}; + }, + }); + + const {store} = await createMockFlipperWithPlugin(TestPlugin); + trigger(); + expect(store.getState().notifications).toMatchInlineSnapshot(` + Object { + "activeNotifications": Array [ + Object { + "client": "serial", + "notification": Object { + "action": "dosomething", + "id": "test", + "message": "test message", + "severity": "error", + "title": "hi", + }, + "pluginId": "TestPlugin", + }, + ], + "blocklistedCategories": Array [], + "blocklistedPlugins": Array [], + "clearedNotifications": Set {}, + "invalidatedNotifications": Array [], + } + `); +}); + +test('errors end up as notifications if crash reporter is active', async () => { + const TestPlugin = TestUtils.createTestPlugin({ + plugin() { + return {}; + }, + }); + + // eslint-disable-next-line + const CrashReporterImpl = require('../../../../plugins/crash_reporter/index'); + const CrashPlugin = TestUtils.createTestDevicePlugin(CrashReporterImpl, { + id: 'CrashReporter', + }); + + const {store, client, sendError} = await createMockFlipperWithPlugin( + TestPlugin, + { + additionalPlugins: [CrashPlugin], + }, + ); + sendError('gone wrong'); + client.flushMessageBuffer(); + expect(store.getState().notifications).toMatchInlineSnapshot(` + Object { + "activeNotifications": Array [ + Object { + "client": "serial", + "notification": Object { + "action": "0", + "category": "\\"gone wrong\\"", + "id": "0", + "message": "Callstack: No callstack available", + "severity": "error", + "title": "CRASH: Plugin ErrorReason: \\"gone wrong\\"", + }, + "pluginId": "CrashReporter", + }, + ], + "blocklistedCategories": Array [], + "blocklistedPlugins": Array [], + "clearedNotifications": Set {}, + "invalidatedNotifications": Array [], + } + `); +}); + +test('errors end NOT up as notifications if crash reporter is active but suppressPluginErrors is disabled', async () => { + const TestPlugin = TestUtils.createTestPlugin({ + plugin() { + return {}; + }, + }); + + // eslint-disable-next-line + const CrashReporterImpl = require('../../../../plugins/crash_reporter/index'); + const CrashPlugin = TestUtils.createTestDevicePlugin(CrashReporterImpl, { + id: 'CrashReporter', + }); + + const {store, client, sendError} = await createMockFlipperWithPlugin( + TestPlugin, + { + additionalPlugins: [CrashPlugin], + }, + ); + store.dispatch({ + type: 'UPDATE_SETTINGS', + payload: { + ...store.getState().settingsState, + suppressPluginErrors: true, + }, + }); + sendError('gone wrong'); + client.flushMessageBuffer(); + expect(store.getState().notifications).toMatchInlineSnapshot(` + Object { + "activeNotifications": Array [], + "blocklistedCategories": Array [], + "blocklistedPlugins": Array [], + "clearedNotifications": Set {}, + "invalidatedNotifications": Array [], + } + `); +}); diff --git a/desktop/app/src/reducers/settings.tsx b/desktop/app/src/reducers/settings.tsx index d727a339b..af816ab52 100644 --- a/desktop/app/src/reducers/settings.tsx +++ b/desktop/app/src/reducers/settings.tsx @@ -45,6 +45,7 @@ export type Settings = { }; darkMode: boolean; showWelcomeAtStartup: boolean; + suppressPluginErrors: boolean; }; export type Action = @@ -79,6 +80,7 @@ const initialState: Settings = { }, darkMode: false, showWelcomeAtStartup: true, + suppressPluginErrors: false, }; export default function reducer( diff --git a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx index 4499a8ba0..8826a2188 100644 --- a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx +++ b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx @@ -38,6 +38,7 @@ export type MockFlipperResult = { device: BaseDevice; store: Store; pluginKey: string; + sendError(error: any, client?: Client): void; sendMessage(method: string, params: any, client?: Client): void; createDevice(serial: string): BaseDevice; createClient( @@ -146,6 +147,13 @@ export async function createMockFlipperWithPlugin( client, device: device as any, store, + sendError(error: any, actualClient = client) { + actualClient.onMessage( + JSON.stringify({ + error, + }), + ); + }, sendMessage(method, params, actualClient = client) { actualClient.onMessage( JSON.stringify({ diff --git a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx index 9c95df3e7..d12f8bd19 100644 --- a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx +++ b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx @@ -39,6 +39,7 @@ export interface Device { readonly isArchived: boolean; readonly isConnected: boolean; readonly os: string; + readonly serial: string; readonly deviceType: DeviceType; onLogEntry(cb: DeviceLogListener): () => void; } diff --git a/desktop/flipper-plugin/src/plugin/PluginBase.tsx b/desktop/flipper-plugin/src/plugin/PluginBase.tsx index 906c08d7d..182c6ccf6 100644 --- a/desktop/flipper-plugin/src/plugin/PluginBase.tsx +++ b/desktop/flipper-plugin/src/plugin/PluginBase.tsx @@ -175,6 +175,7 @@ export abstract class BasePluginInstance { realDevice, // TODO: temporarily, clean up T70688226 // N.B. we model OS as string, not as enum, to make custom device types possible in the future os: realDevice.os, + serial: realDevice.serial, get isArchived() { return realDevice.isArchived; }, diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index 0e0d3cd1f..03a8a95e4 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -20,6 +20,7 @@ import { RealFlipperClient, SandyPluginInstance, PluginClient, + PluginFactory, } from '../plugin/Plugin'; import { SandyPluginDefinition, @@ -418,6 +419,47 @@ export function createMockPluginDetails( }; } +export function createTestPlugin>( + implementation: Pick, 'plugin'> & + Partial>, + details?: Partial, +) { + return new SandyPluginDefinition( + createMockPluginDetails({ + pluginType: 'client', + ...details, + }), + { + Component() { + return null; + }, + ...implementation, + }, + ); +} + +export function createTestDevicePlugin( + implementation: Pick & + Partial, + details?: Partial, +) { + return new SandyPluginDefinition( + createMockPluginDetails({ + pluginType: 'device', + ...details, + }), + { + supportsDevice() { + return true; + }, + Component() { + return null; + }, + ...implementation, + }, + ); +} + export function createMockBundledPluginDetails( details?: Partial, ): BundledPluginDetails { diff --git a/desktop/plugins/crash_reporter/__tests__/testCrashReporterPlugin.node.tsx b/desktop/plugins/crash_reporter/__tests__/testCrashReporterPlugin.node.tsx index 671f5b717..a9b200fe3 100644 --- a/desktop/plugins/crash_reporter/__tests__/testCrashReporterPlugin.node.tsx +++ b/desktop/plugins/crash_reporter/__tests__/testCrashReporterPlugin.node.tsx @@ -8,27 +8,11 @@ */ import {BaseDevice} from 'flipper'; -import CrashReporterPlugin from '../index'; -import type {PersistedState, Crash} from '../index'; -import { - parseCrashLog, - getNewPersistedStateFromCrashLog, - parsePath, - shouldShowCrashNotification, -} from '../index'; -import {getPluginKey, getPersistedState} from 'flipper'; - -function setDefaultPersistedState(defaultState: PersistedState) { - CrashReporterPlugin.defaultPersistedState = defaultState; -} - -function setNotificationID(notificationID: number) { - CrashReporterPlugin.notificationID = notificationID; -} - -function setCrashReporterPluginID(id: string) { - CrashReporterPlugin.id = id; -} +import {Crash, shouldShowiOSCrashNotification} from '../index'; +import {parseCrashLog, parsePath} from '../index'; +import {TestUtils} from 'flipper-plugin'; +import {getPluginKey} from 'flipper'; +import * as CrashReporterPlugin from '../index'; function getCrash( id: number, @@ -54,19 +38,6 @@ function assertCrash(crash: Crash, expectedCrash: Crash) { 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'; @@ -166,125 +137,35 @@ test('test the getter of pluginKey with defined selected app and defined base de expect(pluginKey).toEqual('selectedApp#CrashReporter'); }); test('test defaultPersistedState of CrashReporterPlugin', () => { - expect(CrashReporterPlugin.defaultPersistedState).toEqual({crashes: []}); + expect( + TestUtils.startDevicePlugin(CrashReporterPlugin).exportState(), + ).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 persistedState = getPersistedState( - getPluginKey(null, null, CrashReporterPlugin.id), - CrashReporterPlugin, - pluginStates, - ); - expect(persistedState).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 persistedState = getPersistedState( - pluginKey, - CrashReporterPlugin, - pluginStates, - ); - expect(persistedState).toEqual({crashes: [pluginStateCrash]}); + const plugin = TestUtils.startDevicePlugin(CrashReporterPlugin); + plugin.instance.reportCrash(crash); + expect(plugin.exportState()).toEqual({crashes: [crash]}); }); test('test getNewPersistedStateFromCrashLog 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 plugin = TestUtils.startDevicePlugin(CrashReporterPlugin); + plugin.instance.reportCrash(crash); const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1'); - const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}}; - const persistedState = getPersistedState( - pluginKey, - CrashReporterPlugin, - pluginStates, - ); - const content = - 'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV \n Blaa Blaa \n Blaa Blaa'; - expect(persistedState).toBeDefined(); - const definedState = persistedState as PersistedState; - const {crashes} = definedState; - expect(crashes).toBeDefined(); - expect(crashes.length).toEqual(1); - expect(crashes[0]).toEqual(pluginStateCrash); - const newPersistedState = getNewPersistedStateFromCrashLog( - definedState, - CrashReporterPlugin, - content, - 'iOS', - null, - ); - expect(newPersistedState).toBeDefined(); - const newDefinedState = newPersistedState as PersistedState; - const newPersistedStateCrashes = newDefinedState.crashes; - expect(newPersistedStateCrashes).toBeDefined(); - expect(newPersistedStateCrashes.length).toEqual(2); - assertCrash(newPersistedStateCrashes[0], pluginStateCrash); - assertCrash( - newPersistedStateCrashes[1], - getCrash(1, content, 'SIGSEGV', 'SIGSEGV'), - ); -}); -test('test getNewPersistedStateFromCrashLog 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 persistedState = getPersistedState( - pluginKey, - CrashReporterPlugin, - pluginStates, - ); - const content = 'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV'; - expect(persistedState).toEqual({crashes: [crash]}); - const newPersistedState = getNewPersistedStateFromCrashLog( - persistedState as PersistedState, - CrashReporterPlugin, - content, - 'iOS', - null, - ); - expect(newPersistedState).toBeDefined(); - const {crashes} = newPersistedState as PersistedState; + plugin.instance.reportCrash(pluginStateCrash); + const crashes = plugin.instance.crashes.get(); expect(crashes).toBeDefined(); expect(crashes.length).toEqual(2); - assertCrash(crashes[0], crash); - assertCrash(crashes[1], getCrash(1, content, 'SIGSEGV', 'SIGSEGV')); + expect(crashes[1]).toEqual(pluginStateCrash); }); + test('test getNewPersistedStateFromCrashLog 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 plugin = TestUtils.startDevicePlugin(CrashReporterPlugin); + const pluginStateCrash = getCrash(0, 'callstack', 'crash1', 'crash1'); + plugin.instance.reportCrash(pluginStateCrash); const content = 'Blaa Blaaa \n Blaa Blaaa'; - expect(perisistedState).toEqual({crashes: [pluginStateCrash]}); - const newPersistedState = getNewPersistedStateFromCrashLog( - perisistedState as PersistedState, - CrashReporterPlugin, - content, - 'iOS', - null, - ); - expect(newPersistedState).toBeDefined(); - const {crashes} = newPersistedState as PersistedState; - expect(crashes).toBeDefined(); + plugin.instance.reportCrash(parseCrashLog(content, 'iOS', null)); + const crashes = plugin.instance.crashes.get(); expect(crashes.length).toEqual(2); assertCrash(crashes[0], pluginStateCrash); assertCrash( @@ -297,28 +178,17 @@ test('test getNewPersistedStateFromCrashLog for non-empty defaultPersistedState ), ); }); + test('test getNewPersistedStateFromCrashLog 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 persistedState = getPersistedState( - pluginKey, - CrashReporterPlugin, - pluginStates, - ); + const plugin = TestUtils.startDevicePlugin(CrashReporterPlugin); const content = 'Blaa Blaaa \n Blaa Blaaa'; - const newPersistedState = getNewPersistedStateFromCrashLog( - persistedState as PersistedState, - CrashReporterPlugin, - content, - undefined, - null, - ); - expect(newPersistedState).toEqual(null); + expect(() => { + plugin.instance.reportCrash(parseCrashLog(content, undefined as any, null)); + }).toThrowErrorMatchingInlineSnapshot(`"Unsupported OS"`); + const crashes = plugin.instance.crashes.get(); + expect(crashes.length).toEqual(0); }); + 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'; @@ -350,6 +220,7 @@ test('test parsing of path when a regex is not present', () => { const id = parsePath(content); expect(id).toEqual(null); }); + test('test shouldShowCrashNotification function for all correct inputs', () => { const device = new BaseDevice( 'TH1S-15DEV1CE-1D', @@ -359,14 +230,13 @@ test('test shouldShowCrashNotification function for all correct inputs', () => { ); 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, + const shouldShowNotification = shouldShowiOSCrashNotification( + device.serial, content, - 'iOS', ); expect(shouldShowNotification).toEqual(true); }); -test('test shouldShowCrashNotification function for all correct inputs but incorrect id', () => { +test('test shouldShowiOSCrashNotification function for all correct inputs but incorrect id', () => { const device = new BaseDevice( 'TH1S-15DEV1CE-1D', 'emulator', @@ -375,20 +245,39 @@ test('test shouldShowCrashNotification function for all correct inputs but incor ); 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, + const shouldShowNotification = shouldShowiOSCrashNotification( + device.serial, content, - 'iOS', ); expect(shouldShowNotification).toEqual(false); }); -test('test shouldShowCrashNotification function for undefined device', () => { +test('test shouldShowiOSCrashNotification 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, + const shouldShowNotification = shouldShowiOSCrashNotification( + null as any, content, - 'iOS', ); expect(shouldShowNotification).toEqual(false); }); + +test('only crashes from the correct device are picked up', () => { + const serial = 'AC9482A2-26A4-404F-A179-A9FB60B077F6'; + const crash = `Process: Sample [87361] + Path: /Users/USER/Library/Developer/CoreSimulator/Devices/AC9482A2-26A4-404F-A179-A9FB60B077F6/data/Containers/Bundle/Application/9BF91EF9-F915-4745-BE91-EBA397451850/Sample.app/Sample + Identifier: Sample + Version: 1.0 (1) + Code Type: X86-64 (Native) + Parent Process: launchd_sim [70150] + Responsible: SimulatorTrampoline [1246] + User ID: 501`; + + expect(shouldShowiOSCrashNotification(serial, crash)).toBe(true); + // wrong serial + expect( + shouldShowiOSCrashNotification( + 'XC9482A2-26A4-404F-A179-A9FB60B077F6', + crash, + ), + ).toBe(false); +}); diff --git a/desktop/plugins/crash_reporter/index.tsx b/desktop/plugins/crash_reporter/index.tsx index 7be38b039..df4837260 100644 --- a/desktop/plugins/crash_reporter/index.tsx +++ b/desktop/plugins/crash_reporter/index.tsx @@ -8,9 +8,6 @@ */ import { - FlipperBasePlugin, - FlipperDevicePlugin, - Device, View, styled, FlexColumn, @@ -18,9 +15,6 @@ import { ContextMenu, clipboard, Button, - getPluginKey, - getPersistedState, - BaseDevice, shouldParseAndroidLog, Text, colors, @@ -31,13 +25,17 @@ import { 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 type {DeviceLogEntry} from 'flipper'; import React from 'react'; -import {Component} from 'react'; +import { + createState, + DevicePluginClient, + usePlugin, + useValue, +} from 'flipper-plugin'; +import type {FSWatcher} from 'fs'; type Maybe = T | null | undefined; @@ -74,14 +72,6 @@ export type CrashLog = { date: Maybe; }; -export type PersistedState = { - crashes: Array; -}; - -type State = { - crash?: Crash; -}; - const Padder = styled.div<{ paddingLeft?: number; paddingRight?: number; @@ -188,102 +178,9 @@ const StackTraceContainer = styled(FlexColumn)({ const UNKNOWN_CRASH_REASON = 'Cannot figure out the cause'; -export function getNewPersistedStateFromCrashLog( - persistedState: Maybe, - persistingPlugin: typeof FlipperBasePlugin, - content: string, - os: Maybe, - logDate: Maybe, -): Maybe { - 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, - ) => void, - logDate: Maybe, -) { - 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, - content: string, - os: Maybe, -): 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, + os: string, logDate: Maybe, ): CrashLog { const fallbackReason = UNKNOWN_CRASH_REASON; @@ -369,18 +266,16 @@ export function parsePath(content: string): Maybe { } function addFileWatcherForiOSCrashLogs( - store: Store, - setPersistedState: ( - pluginKey: string, - newPluginState: Maybe, - ) => void, + deviceOs: string, + serial: string, + reportCrash: (payload: CrashLog | Crash) => void, ) { const dir = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports'); if (!fs.existsSync(dir)) { // Directory doesn't exist return; } - fs.watch(dir, (_eventType, filename) => { + return fs.watch(dir, (_eventType, filename) => { // We just parse the crash logs with extension `.crash` const checkFileExtension = /.crash$/.exec(filename); if (!filename || !checkFileExtension) { @@ -392,26 +287,18 @@ function addFileWatcherForiOSCrashLogs( 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, - ); + if (shouldShowiOSCrashNotification(serial, data)) + reportCrash(parseCrashLog(data, deviceOs, null)); }); }); }); } -class CrashSelector extends Component { +class CrashSelector extends React.Component { render() { const {crashes, selectedCrashID, orderedIDs, onCrashChange} = this.props; return ( @@ -471,7 +358,7 @@ class CrashSelector extends Component { } } -class CrashReporterBar extends Component { +class CrashReporterBar extends React.Component { render() { const {openLogsCallback, crashSelector} = this.props; return ( @@ -488,7 +375,7 @@ class CrashReporterBar extends Component { } } -class HeaderRow extends Component { +class HeaderRow extends React.Component { render() { const {title, value} = this.props; return ( @@ -519,7 +406,7 @@ type StackTraceComponentProps = { stacktrace: string; }; -class StackTraceComponent extends Component { +class StackTraceComponent extends React.Component { render() { const {stacktrace} = this.props; return ( @@ -533,280 +420,232 @@ class StackTraceComponent extends Component { } } -export default class CrashReporterPlugin extends FlipperDevicePlugin< - State, - any, - PersistedState -> { - static defaultPersistedState: PersistedState = { - crashes: [], - }; +export function devicePlugin(client: DevicePluginClient) { + let notificationID = -1; + let watcher: FSWatcher | undefined; - static supportsDevice(device: Device) { - return ( - (device.os === 'iOS' && device.deviceType !== 'physical') || - device.os === 'Android' - ); + const crashes = createState([], {persist: 'crashes'}); + const selectedCrash = createState(); + + client.onDeepLink((crashId) => { + selectedCrash.set(crashId as string); + }); + + function reportCrash(payload: CrashLog | Crash) { + notificationID++; + + const crash = { + notificationID: notificationID.toString(), + callstack: payload.callstack, + name: payload.name, + reason: payload.reason, + date: payload.date || new Date(), + }; + + crashes.update((draft) => { + draft.push(crash); + }); + + // show notification? + 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; + } + + let title: string = 'CRASH: ' + truncate(crash.name || crash.reason, 50); + title = `${ + crash.name == crash.reason + ? title + : title + 'Reason: ' + truncate(crash.reason, 50) + }`; + const callstack = crash.callstack + ? trimCallStackIfPossible(crash.callstack) + : 'No callstack available'; + const msg = `Callstack: ${truncate(callstack, 200)}`; + client.showNotification({ + id: crash.notificationID, + message: msg, + severity: 'error', + title: title, + action: crash.notificationID, + category: crash.reason || 'Unknown reason', + }); } - 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 => { - 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, - ) => void, - ): void => { - if (baseDevice.os.includes('iOS')) { - addFileWatcherForiOSCrashLogs(store, setPersistedState); + // Startup logic to establish log monitoring + if (client.device.isConnected) { + if (client.device.os.includes('iOS')) { + watcher = addFileWatcherForiOSCrashLogs( + client.device.os, + client.device.serial, + reportCrash, + ); } else { const referenceDate = new Date(); - (function ( - store: Store, - _date: Date, - setPersistedState: ( - pluginKey: string, - newPluginState: Maybe, - ) => void, - ) { - let androidLog: string = ''; - let androidLogUnderProcess = false; - let timer: Maybe = 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; + let androidLog: string = ''; + let androidLogUnderProcess = false; + let timer: Maybe = null; + client.device.onLogEntry((entry: DeviceLogEntry) => { + if (shouldParseAndroidLog(entry, referenceDate)) { + if (androidLogUnderProcess) { + androidLog += '\n' + entry.message; + androidLog = androidLog.trim(); + if (timer) { + clearTimeout(timer); } - timer = setTimeout(() => { - if (androidLog.length > 0) { - parseCrashLogAndUpdateState( - store, - androidLog, - setPersistedState, - entry.date, - ); - } - androidLogUnderProcess = false; - androidLog = ''; - }, 50); + } else { + androidLog = entry.message; + androidLogUnderProcess = true; } - }); - })(store, referenceDate, setPersistedState); - } - }; - openInLogs = (callstack: string) => { - this.props.selectPlugin('DeviceLogs', callstack); - }; - - constructor(props: Props) { - // 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; + timer = setTimeout(() => { + if (androidLog.length > 0) { + reportCrash( + parseCrashLog(androidLog, client.device.os, entry.date), + ); + } + androidLogUnderProcess = false; + androidLog = ''; + }, 50); + } }); - 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; + client.onDestroy(() => { + watcher?.close(); + }); - 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; - }, - {}, - ); + return { + reportCrash, + crashes, + selectedCrash, + openInLogs(callstack: string) { + client.selectPlugin('DeviceLogs', callstack); + }, + os: client.device.os, + copyCrashToClipboard(callstack: string) { + client.writeTextToClipboard(callstack); + }, + }; +} - const orderedIDs = crashes.map( - (persistedCrash) => persistedCrash.notificationID, - ); - const selectedCrashID = crash.notificationID; - const onCrashChange = (id: Maybe) => { - const newSelectedCrash = crashes.find( - (element) => element.notificationID === id, - ); - this.setState({crash: newSelectedCrash}); - }; +export function Component() { + const plugin = usePlugin(devicePlugin); + const selectedCrash = useValue(plugin.selectedCrash); + const crashes = useValue(plugin.crashes); + const crash = + crashes.find((c) => c.notificationID === selectedCrash) ?? + crashes[crashes.length - 1] ?? + undefined; - 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 ( - - {this.device.os == 'Android' ? ( - { - if (crash.callstack) { - this.openInLogs(crash.callstack); - } - }} - /> - ) : ( - - )} - - - {showReason ? ( - - ) : null} - - Stacktrace - - { - clipboard.writeText(callstackString); - }, - }, - ]}> - - {children.map((child, index) => { - return ( - - ); - })} - - - - ); - } - const crashSelector = { - crashes: undefined, - orderedIDs: undefined, - selectedCrashID: undefined, - onCrashChange: () => void {}, + if (crash) { + 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) => { + if (id) { + plugin.selectedCrash.set(id); + } }; + + 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 ( - - - - - No Crashes Logged + + {plugin.os == 'Android' ? ( + { + if (crash.callstack) { + plugin.openInLogs(crash.callstack); + } + }} + /> + ) : ( + + )} + + + {showReason ? ( + + ) : null} + + Stacktrace - - + { + plugin.copyCrashToClipboard(callstackString); + }, + }, + ]}> + + {children.map((child, index) => { + return ( + + ); + })} + + + ); } + const crashSelector = { + crashes: undefined, + orderedIDs: undefined, + selectedCrashID: undefined, + onCrashChange: () => void {}, + }; + return ( + + + + + No Crashes Logged + + + + ); +} + +function trimCallStackIfPossible(callstack: string): string { + const regex = /Application Specific Information:/; + const query = regex.exec(callstack); + return query ? callstack.substring(0, query.index) : callstack; +} + +export function shouldShowiOSCrashNotification( + serial: string, + content: string, +): boolean { + const appPath = parsePath(content); + if (!appPath || !appPath.includes(serial)) { + // Do not show notifications for the app which are not running on this device + return false; + } + return true; } diff --git a/desktop/plugins/crash_reporter/package.json b/desktop/plugins/crash_reporter/package.json index 032dc4622..a8beff645 100644 --- a/desktop/plugins/crash_reporter/package.json +++ b/desktop/plugins/crash_reporter/package.json @@ -4,9 +4,18 @@ "id": "CrashReporter", "pluginType": "device", "supportedDevices": [ - {"os": "Android", "type": "emulator"}, - {"os": "Android", "type": "physical"}, - {"os": "iOS", "type": "emulator"} + { + "os": "Android", + "type": "emulator" + }, + { + "os": "Android", + "type": "physical" + }, + { + "os": "iOS", + "type": "emulator" + } ], "version": "0.0.0", "description": "A plugin which will display a crash", @@ -24,5 +33,8 @@ }, "dependencies": { "unicode-substring": "^1.0.0" + }, + "peerDependencies": { + "flipper-plugin": "0.0.0" } }