diff --git a/spec/__mocks__/electron.ts b/spec/__mocks__/electron.ts index 2c3042ab..ce1f878e 100644 --- a/spec/__mocks__/electron.ts +++ b/spec/__mocks__/electron.ts @@ -153,6 +153,7 @@ export const Menu = { export const crashReporter = { start: jest.fn(), + getLastCrashReport: jest.fn(), }; const getCurrentWindow = jest.fn(() => { diff --git a/src/app/analytics-handler.ts b/src/app/analytics-handler.ts index 210652e2..58e2bafc 100644 --- a/src/app/analytics-handler.ts +++ b/src/app/analytics-handler.ts @@ -1,9 +1,16 @@ export interface IAnalyticsData { element: AnalyticsElements; - action_type: MenuActionTypes | ScreenSnippetActionTypes; + action_type?: MenuActionTypes | ScreenSnippetActionTypes; action_result?: AnalyticsActions; } +export interface ICrashData extends IAnalyticsData { + process: SDACrashProcess; + crashCause: string; + windowName: string; + miniDump?: string; +} + export enum MenuActionTypes { AUTO_LAUNCH_ON_START_UP = 'auto_launch_on_start_up', ALWAYS_ON_TOP = 'always_on_top', @@ -30,6 +37,13 @@ export enum AnalyticsActions { export enum AnalyticsElements { MENU = 'Menu', SCREEN_CAPTURE_ANNOTATE = 'screen_capture_annotate', + SDA_CRASH = 'sda_crash', +} + +export enum SDACrashProcess { + MAIN = 'main', + RENDERER = 'renderer', + GPU = 'gpu', } const MAX_EVENT_QUEUE_LENGTH = 50; @@ -51,9 +65,12 @@ class Analytics { return; } if (this.analyticsEventQueue && this.analyticsEventQueue.length > 0) { - this.analyticsEventQueue.forEach((events) => { + this.analyticsEventQueue.forEach((eventData) => { if (this.preloadWindow && !this.preloadWindow.isDestroyed()) { - this.preloadWindow.send(analyticsCallback, events); + if (eventData.element === AnalyticsElements.SDA_CRASH) { + eventData = eventData as ICrashData; + } + this.preloadWindow.send(analyticsCallback, eventData); } }); this.resetAnalytics(); @@ -66,12 +83,15 @@ class Analytics { * @param eventData {IAnalyticsData} */ public track(eventData: IAnalyticsData): void { + if (eventData.element === AnalyticsElements.SDA_CRASH) { + eventData = eventData as ICrashData; + } if (this.preloadWindow && !this.preloadWindow.isDestroyed()) { this.preloadWindow.send(analyticsCallback, eventData); return; } this.analyticsEventQueue.push(eventData); - // don't store more than 50 msgs. keep most recent log msgs. + // don't store more than specified limit. keep most recent events. if (this.analyticsEventQueue.length > MAX_EVENT_QUEUE_LENGTH) { this.analyticsEventQueue.shift(); } diff --git a/src/app/child-window-handler.ts b/src/app/child-window-handler.ts index 32c289af..37282a4f 100644 --- a/src/app/child-window-handler.ts +++ b/src/app/child-window-handler.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, WebContents } from 'electron'; +import { BrowserWindow, crashReporter, WebContents } from 'electron'; import { parse as parseQuerystring } from 'querystring'; import { format, parse, Url } from 'url'; @@ -8,6 +8,7 @@ import { logger } from '../common/logger'; import { getGuid } from '../common/utils'; import { whitelistHandler } from '../common/whitelist-handler'; import { config } from './config-handler'; +import crashHandler from './crash-handler'; import { handlePermissionRequests, monitorWindowActions, @@ -262,6 +263,14 @@ export const handleChildWindow = (webContents: WebContents): void => { removeWindowEventListener(browserWin); }); + crashReporter.start({ + submitURL: '', + uploadToServer: false, + ignoreSystemCrashHandler: false, + }); + + crashHandler.handleRendererCrash(browserWin); + if (browserWin.webContents) { // validate link and create a child window or open in browser handleChildWindow(browserWin.webContents); diff --git a/src/app/crash-handler.ts b/src/app/crash-handler.ts new file mode 100644 index 00000000..d9d2f36b --- /dev/null +++ b/src/app/crash-handler.ts @@ -0,0 +1,110 @@ +import { app, crashReporter, Details, dialog } from 'electron'; +import { i18n } from '../common/i18n'; +import { logger } from '../common/logger'; +import { + analytics, + AnalyticsElements, + ICrashData, + SDACrashProcess, +} from './analytics-handler'; +import { ICustomBrowserWindow } from './window-handler'; +import { windowExists } from './window-utils'; + +class CrashHandler { + /** + * Shows a message to the user to take further action + * @param browserWindow Browser Window to show the dialog on + * @private + */ + private static async showMessageToUser(browserWindow: ICustomBrowserWindow) { + if (!browserWindow || !windowExists(browserWindow)) { + return; + } + const { response } = await dialog.showMessageBox({ + type: 'error', + title: i18n.t('Renderer Process Crashed')(), + message: i18n.t( + 'Oops! Looks like we have had a crash. Please reload or close this window.', + )(), + buttons: ['Reload', 'Close'], + }); + response === 0 ? browserWindow.reload() : browserWindow.close(); + } + + /** + * Handles a GPU crash event + * @private + */ + private static handleGpuCrash() { + app.on('gpu-process-crashed', (_event: Event, _killed: boolean) => { + logger.info(`crash-handler: GPU process crashed.`); + const eventData: ICrashData = { + element: AnalyticsElements.SDA_CRASH, + process: SDACrashProcess.GPU, + windowName: 'main', + crashCause: _killed ? 'killed' : 'crashed', + }; + analytics.track(eventData); + }); + } + + /** + * Handles a main process crash event + * @private + */ + private static handleMainProcessCrash() { + const lastCrash = crashReporter.getLastCrashReport(); + if (!lastCrash) { + logger.info(`crash-handler: No crashes found for main process`); + return; + } + const eventData: ICrashData = { + element: AnalyticsElements.SDA_CRASH, + process: SDACrashProcess.MAIN, + windowName: 'main', + crashCause: lastCrash.id, + }; + analytics.track(eventData); + } + + constructor() { + CrashHandler.handleMainProcessCrash(); + CrashHandler.handleGpuCrash(); + } + + /** + * Handles a crash event for a browser window + * @param browserWindow Browser Window on which the crash should be handled + */ + public handleRendererCrash(browserWindow: ICustomBrowserWindow) { + browserWindow.webContents.on( + 'render-process-gone', + async (_event: Event, details: Details) => { + logger.info(`crash-handler: Renderer process for ${browserWindow.winName} crashed. + Reason is ${details.reason}`); + const eventData: ICrashData = { + element: AnalyticsElements.SDA_CRASH, + process: SDACrashProcess.RENDERER, + windowName: browserWindow.winName, + crashCause: details.reason, + }; + switch (details.reason) { + case 'abnormal-exit': + case 'crashed': + case 'integrity-failure': + case 'launch-failed': + case 'oom': + await CrashHandler.showMessageToUser(browserWindow); + analytics.track(eventData); + break; + default: + break; + } + }, + ); + } +} + +const crashHandler = new CrashHandler(); + +export default crashHandler; diff --git a/src/app/init.ts b/src/app/init.ts index d05dd20b..c5084532 100644 --- a/src/app/init.ts +++ b/src/app/init.ts @@ -1,4 +1,4 @@ -import { app } from 'electron'; +import { app, crashReporter } from 'electron'; import * as path from 'path'; import { isDevEnv, isNodeEnv } from '../common/env'; @@ -39,6 +39,14 @@ if (userDataPath) { logger.info(`init: Fetch user data path`, app.getPath('userData')); +logger.info(`Crashes directory: ${app.getPath('crashDumps')}`); +crashReporter.start({ + submitURL: '', + uploadToServer: false, + ignoreSystemCrashHandler: false, +}); +logger.info(`Crash Reporter started`); + // Log app statistics appStats.logStats(); diff --git a/src/app/reports-handler.ts b/src/app/reports-handler.ts index 61da0810..9f57e2d5 100644 --- a/src/app/reports-handler.ts +++ b/src/app/reports-handler.ts @@ -1,5 +1,5 @@ import * as archiver from 'archiver'; -import { app, BrowserWindow, crashReporter, dialog, shell } from 'electron'; +import { app, BrowserWindow, dialog, shell } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; @@ -184,7 +184,7 @@ export const exportLogs = (): void => { */ export const exportCrashDumps = (): void => { const FILE_EXTENSIONS = isMac ? ['.dmp'] : ['.dmp', '.txt']; - const crashesDirectory = (crashReporter as any).getCrashesDirectory(); + const crashesDirectory = app.getPath('crashDumps'); const source = isMac ? crashesDirectory + '/completed' : crashesDirectory; const focusedWindow = BrowserWindow.getFocusedWindow(); diff --git a/src/app/window-handler.ts b/src/app/window-handler.ts index 776e1118..afa9a316 100644 --- a/src/app/window-handler.ts +++ b/src/app/window-handler.ts @@ -40,6 +40,7 @@ import { IConfig, IGlobalConfig, } from './config-handler'; +import crashHandler from './crash-handler'; import { SpellChecker } from './spell-check-handler'; import { checkIfBuildExpired } from './ttl-handler'; import { versionHandler } from './version-handler'; @@ -232,23 +233,6 @@ export class WindowHandler { app.getLocale()) as LocaleType; i18n.setLocale(locale); - try { - const extra = { - podUrl: this.userConfig.url - ? this.userConfig.url - : this.globalConfig.url, - process: 'main', - }; - const defaultOpts = { - uploadToServer: false, - companyName: 'Symphony', - submitURL: '', - }; - crashReporter.start({ ...defaultOpts, extra }); - } catch (e) { - throw new Error('failed to init crash report'); - } - this.listenForLoad(); } @@ -625,6 +609,14 @@ export class WindowHandler { this.destroyAllWindows(); }); + crashReporter.start({ + submitURL: '', + uploadToServer: false, + ignoreSystemCrashHandler: false, + }); + + crashHandler.handleRendererCrash(this.mainWindow); + // Reloads the Symphony ipcMain.on('reload-symphony', () => { this.reloadSymphony();