diff --git a/src-main/index.js b/src-main/index.js index e5503b8d..5daae7ac 100644 --- a/src-main/index.js +++ b/src-main/index.js @@ -23,6 +23,9 @@ app.enableSandbox(); // https://github.com/LLK/scratch-desktop/blob/4b462212a8e406b15bcf549f8523645602b46064/src/main/index.js#L45 app.commandLine.appendSwitch('host-resolver-rules', 'MAP device-manager.scratch.mit.edu 127.0.0.1'); +// Needed for collectJavaScriptCallStack() in Electron 34. +app.commandLine.appendSwitch('enable-features', 'DocumentPolicyIncludeJSCallStacksInCrashReports'); + app.on('session-created', (session) => { // Permission requests are delegated to AbstractWindow diff --git a/src-main/l10n/en.json b/src-main/l10n/en.json index a16b8ea1..13cd7c63 100644 --- a/src-main/l10n/en.json +++ b/src-main/l10n/en.json @@ -486,5 +486,13 @@ "extension-documentation.title": { "string": "{APP_NAME} Extension Documentation", "developer_comment": "Title of in-app window for viewing extension documentation." + }, + "unresponsive.message": { + "string": "This window is unresponsive. It will continue to run in the background.", + "developer_comment": "Alert that appears when a window stops responding (stuck in infinite loop)" + }, + "unresponsive.checkbox": { + "string": "Ignore future warnings", + "developer_comment": "Option in unresponsive window alert to ignore additional alerts." } } diff --git a/src-main/protocols.js b/src-main/protocols.js index e95e464e..7fc96b86 100644 --- a/src-main/protocols.js +++ b/src-main/protocols.js @@ -172,7 +172,10 @@ const errorPageHeaders = { const getBaseProtocolHeaders = metadata => { const result = { // Make sure Chromium always trusts our content-type and doesn't try anything clever - 'x-content-type-options': 'nosniff' + 'x-content-type-options': 'nosniff', + + // Needed for collectJavaScriptCallStack() in Electron 34. + 'document-policy': 'include-js-call-stacks-in-crash-reports=?1' }; // Optional Content-Security-Policy diff --git a/src-main/windows/abstract.js b/src-main/windows/abstract.js index d16743bc..da90bf19 100644 --- a/src-main/windows/abstract.js +++ b/src-main/windows/abstract.js @@ -1,7 +1,9 @@ -const { BrowserWindow, screen, session } = require('electron'); +const {BrowserWindow, screen, session, dialog} = require('electron'); const path = require('path'); const openExternal = require('../open-external'); const settings = require('../settings'); +const {APP_NAME} = require('../brand'); +const {translate} = require('../l10n'); /** @type {Map} */ const windowsByClass = new Map(); @@ -22,6 +24,13 @@ class AbstractWindow { this.window.webContents.on('before-input-event', this.handleInput.bind(this)); this.applySettings(); + this.window.webContents.on('unresponsive', this.handleUnresponsive.bind(this)); + this.window.webContents.on('responsive', this.handleResponsive.bind(this)); + /** @type {AbortController|null} */ + this._unresponsiveController = null; + /** @type {boolean} */ + this._unresponsiveWarningsDisabled = false; + if (!options.existingWindow) { // getCursorScreenPoint() segfaults on Linux in Wayland if called before a BrowserWindow is created, so // we can't compute this in getWindowOptions(). @@ -290,6 +299,80 @@ class AbstractWindow { } } + async handleUnresponsive () { + if (this._unresponsiveWarningsDisabled) { + return; + } + + if (this._unresponsiveController) { + this._unresponsiveController.abort(); + } + + const controller = new AbortController(); + this._unresponsiveController = controller; + + let detail = `URL: ${this.window.webContents.mainFrame.url}\n`; + + // collectJavaScriptCallStack() was added in Electron 34, so not support in legacy + // builds for Windows 7, 8, 8.1 or macOS 10.13, 10.14, 10.15 + if (typeof this.window.webContents.mainFrame.collectJavaScriptCallStack === 'function') { + let stack; + try { + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + + // collectJavaScriptCallStack will never resolve if somehow no JavaScript is running, so + // add a timer so we never wait too long. + stack = await Promise.race([ + this.window.webContents.mainFrame.collectJavaScriptCallStack(), + sleep(1000).then(() => null) + ]).catch(() => null); + } catch (e) { + console.error(e); + stack = '' + e; + } + + // collectJavaScriptCallStack returns non-string if it couldn't get details for any reason. + if (typeof stack === 'string') { + detail += `Stack: ${stack}\n`; + } else { + detail += 'Stack: internal error\n'; + } + } else { + detail += 'Stack: API not available\n'; + } + + // Operations above are async; window may have become responsive during that time. + if (!controller.signal.aborted) { + dialog.showMessageBox(this.window, { + type: 'error', + title: APP_NAME, + signal: controller.signal, + message: translate('unresponsive.message'), + checkboxChecked: false, + checkboxLabel: translate('unresponsive.checkbox'), + noLink: true, + detail + }).then((result) => { + // TODO: only consider checkboxes when user clicks explicitly and not when its checked + // then the window becomes responsive? + if (result.checkboxChecked) { + this._unresponsiveWarningsDisabled = true; + } + + if (this._unresponsiveController === controller) { + this._unresponsiveController = null + } + }); + } + } + + handleResponsive () { + if (this._unresponsiveController) { + this._unresponsiveController.abort(); + this._unresponsiveController = null; + } + } + /** * @param {Electron.WillNavigateEvent} event * @param {string} url