UNPKG

weer

Version:

Web Extensions Error Reporter catches global errors, shows notifications and opens error reporter in one click

887 lines (678 loc) 23.1 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global.Weer = factory()); }(this, (function () { 'use strict'; function createCommonjsModule(fn, module) { return module = { exports: {} }, fn(module, module.exports), module.exports; } var errio = createCommonjsModule(function (module, exports) { // Default options for all serializations. var defaultOptions = { recursive: true, // Recursively serialize and deserialize nested errors inherited: true, // Include inherited properties stack: false, // Include stack property private: false, // Include properties with leading or trailing underscores exclude: [], // Property names to exclude (low priority) include: [] // Property names to include (high priority) }; // Overwrite global default options. exports.setDefaults = function(options) { for (var key in options) defaultOptions[key] = options[key]; }; // Object containing registered error constructors and their options. var errors = {}; // Register an error constructor for serialization and deserialization with // option overrides. Name can be specified in options, otherwise it will be // taken from the prototype's name property (if it is not set to Error), the // constructor's name property, or the name property of an instance of the // constructor. exports.register = function(constructor, options) { options = options || {}; var prototypeName = constructor.prototype.name !== 'Error' ? constructor.prototype.name : null; var name = options.name || prototypeName || constructor.name || new constructor().name; errors[name] = { constructor: constructor, options: options }; }; // Register an array of error constructors all with the same option overrides. exports.registerAll = function(constructors, options) { constructors.forEach(function(constructor) { exports.register(constructor, options); }); }; // Shallow clone a plain object. function cloneObject(object) { var clone = {}; for (var key in object) { if (object.hasOwnProperty(key)) clone[key] = object[key]; } return clone; } // Register a plain object of constructor names mapped to constructors with // common option overrides. exports.registerObject = function(constructors, commonOptions) { for (var name in constructors) { if (!constructors.hasOwnProperty(name)) continue; var constructor = constructors[name]; var options = cloneObject(commonOptions); options.name = name; exports.register(constructor, options); } }; // Register the built-in error constructors. exports.registerAll([ Error, EvalError, RangeError, ReferenceError, SyntaxError, TypeError, URIError ]); // Serialize an error instance to a plain object with option overrides, applied // on top of the global defaults and the registered option overrides. If the // constructor of the error instance has not been registered yet, register it // with the provided options. exports.toObject = function(error, callOptions) { callOptions = callOptions || {}; if (!errors[error.name]) { // Make sure we register with the name of this instance. callOptions.name = error.name; exports.register(error.constructor, callOptions); } var errorOptions = errors[error.name].options; var options = {}; for (var key in defaultOptions) { if (callOptions.hasOwnProperty(key)) options[key] = callOptions[key]; else if (errorOptions.hasOwnProperty(key)) options[key] = errorOptions[key]; else options[key] = defaultOptions[key]; } // Always explicitly include essential error properties. var object = { name: error.name, message: error.message }; // Explicitly include stack since it is not always an enumerable property. if (options.stack) object.stack = error.stack; for (var prop in error) { // Skip exclusion checks if property is in include list. if (options.include.indexOf(prop) === -1) { if (typeof error[prop] === 'function') continue; if (options.exclude.indexOf(prop) !== -1) continue; if (!options.inherited) if (!error.hasOwnProperty(prop)) continue; if (!options.stack) if (prop === 'stack') continue; if (!options.private) if (prop[0] === '_' || prop[prop.length - 1] === '_') continue; } var value = error[prop]; // Recurse if nested object has name and message properties. if (typeof value === 'object' && value && value.name && value.message) { if (options.recursive) { object[prop] = exports.toObject(value, callOptions); } continue; } object[prop] = value; } return object; }; // Deserialize a plain object to an instance of a registered error constructor // with option overrides. If the specific constructor is not registered, // return a generic Error instance. If stack was not serialized, capture a new // stack trace. exports.fromObject = function(object, callOptions) { callOptions = callOptions || {}; var registration = errors[object.name]; if (!registration) registration = errors.Error; var constructor = registration.constructor; var errorOptions = registration.options; var options = {}; for (var key in defaultOptions) { if (callOptions.hasOwnProperty(key)) options[key] = callOptions[key]; else if (errorOptions.hasOwnProperty(key)) options[key] = errorOptions[key]; else options[key] = defaultOptions[key]; } // Instantiate the error without actually calling the constructor. var error = Object.create(constructor.prototype); for (var prop in object) { // Recurse if nested object has name and message properties. if (options.recursive && typeof object[prop] === 'object') { var nested = object[prop]; if (nested && nested.name && nested.message) { error[prop] = exports.fromObject(nested, callOptions); continue; } } error[prop] = object[prop]; } // Capture a new stack trace such that the first trace line is the caller of // fromObject. if (!error.stack && Error.captureStackTrace) { Error.captureStackTrace(error, exports.fromObject); } return error; }; // Serialize an error instance to a JSON string with option overrides. exports.stringify = function(error, callOptions) { return JSON.stringify(exports.toObject(error, callOptions)); }; // Deserialize a JSON string to an instance of a registered error constructor. exports.parse = function(string, callOptions) { return exports.fromObject(JSON.parse(string), callOptions); }; }); var errio_1 = errio.setDefaults; var errio_2 = errio.register; var errio_3 = errio.registerAll; var errio_4 = errio.registerObject; var errio_5 = errio.toObject; var errio_6 = errio.fromObject; var errio_7 = errio.stringify; var errio_8 = errio.parse; /* # Purpose 1. `timeouted` wrapper that makes error catching possible. 2. Convert error-first callbacks for use by chrome API: `chromified`. 3. Add utils for safer coding: `mandatory`, `throwIfError`. */ const Utils = { mandatory() { throw new TypeError('Missing required argument. Be explicit if you swallow errors.'); }, throwIfError(err) { if (err) { throw err; } }, checkChromeError() { // Chrome API calls your cb in a context different from the point of API // method invokation. const err = chrome.runtime.lastError || chrome.extension.lastError; if (!err) { return; } /* Example of lastError: `chrome.runtime.openOptionsPage(() => console.log(chrome.runtime.lastError))` {message: "Could not create an options page."} */ return new Error(err.message); // Add stack. }, timeouted(cb = Utils.mandatory) { // setTimeout fixes error context, see https://crbug.com/357568 return (...args) => { setTimeout(() => cb(...args), 0); }; }, chromified(cb = Utils.mandatory()) { // Take error first callback and convert it to chrome API callback. return function wrapper(...args) { const err = Utils.checkChromeError(); Utils.timeouted(cb)(err, ...args); }; }, getOrDie(cb = Utils.mandatory()) { return Utils.chromified((err, ...args) => { if (err) { throw err; } cb(...args); }); }, assert(value, message) { if (!value) { throw new Error(message || `Assertion failed, value: ${value}`); } }, errorToPlainObject(error) { return errio.toObject(error, { stack: true, private: true }); }, errorEventToPlainObject(errorEvent) { const plainObj = [ 'message', 'filename', 'lineno', 'colno', 'type', 'path', ].reduce((acc, prop) => { acc[prop] = errorEvent[prop]; return acc; }, {}); if (plainObj.path) { const pathStr = plainObj.path.map((o) => { let res = ''; if (o.tagName) { res += `<${o.tagName.toLowerCase()}`; if (o.attributes) { res += Array.from(o.attributes).map((atr) => ` ${atr.name}="${atr.value}"`).join(''); } res += '>'; } if (!res) { res += `${o}`; } return res; }).join(', '); plainObj.path = `[${pathStr}]`; } if (errorEvent.error && typeof errorEvent === 'object') { plainObj.error = this.errorToPlainObject(errorEvent.error); } else { plainObj.error = errorEvent.error; } return plainObj; }, }; var Debug = window.debug || ((/* Logger ID */) => (/* Log message */) => { /* Ignore all */ }); const debug = Debug('weer:catcher'); const bgName = 'BG'; var ErrorCatchers = { installListenersOn({ hostWindow = window, nameForDebug = bgName, handleErrorMessage, } = {}, cb) { const ifInBg = hostWindow === window; const bgIsBg = `Background window can't have name other than "${bgName}". ` + `Default value is "${bgName}".`; if (ifInBg) { Utils.assert( typeof handleErrorMessage === 'function', 'Messaging from BG window to itself is not allowed,' + ' provide message handler for such cases.', ); Utils.assert(nameForDebug === 'BG', bgIsBg); } else { Utils.assert(nameForDebug !== 'BG', bgIsBg); } const listener = (errorEvent) => { debug(nameForDebug, errorEvent); const plainObj = Utils.errorEventToPlainObject(errorEvent); const msg = { to: 'error-reporter', payload: plainObj, }; if (ifInBg) { // Because self messaging is not allowed. handleErrorMessage(msg); } else { hostWindow.chrome.runtime.sendMessage(msg); } }; const ifUseCapture = true; hostWindow.addEventListener('error', listener, ifUseCapture); const rejHandler = (event) => { event.preventDefault(); debug(nameForDebug, 'rethrowing promise...'); throw event.reason; }; hostWindow.addEventListener('unhandledrejection', rejHandler, ifUseCapture); if (cb) { Utils.timeouted(cb)(); } return function uninstallListeners() { hostWindow.removeEventListener('error', listener, ifUseCapture); hostWindow.removeEventListener('unhandledrejection', rejHandler, ifUseCapture); }; }, }; /* # Documentation 1. https://developer.chrome.com/extensions/manifest/version 2. https://www.chromium.org/developers/version-numbers # Purpose 1. Compare versions. 2. Shorthands for getting info about version (full version, build). */ const versionLimit = 2 ** 16; const versionToArray = (vStr) => [...vStr.split('.'), 0, 0, 0].slice(0, 4); const versionToInteger = (vStr) => versionToArray(vStr) .reverse() .reduce( (acc, value, i) => acc + (parseInt(value, 10) * (versionLimit ** i)), 0, ); const currentVersion = chrome.runtime.getManifest().version; const Versions = { current: currentVersion, currentBuild: versionToArray(currentVersion).slice(-2).join('.'), compare: (a, b) => versionToInteger(a) - versionToInteger(b), }; const getSettingsAsync = () => new Promise((resolve) => chrome.proxy.settings.get( {}, Utils.getOrDie(resolve), ), ); const ProxySettings = { /* * Possible values for levelOfControl: * * 1. "not_controllable" * 2. "controlled_by_other_extensions" * 3. "controllable_by_this_extension" * 4. "controlled_by_this_extension" * * See: https://developer.chrome.com/extensions/proxy * */ async areControllableAsync(details_) { const details = details_ || await getSettingsAsync(); return details.levelOfControl.endsWith('this_extension'); }, async areControlledAsync(details_) { const details = details_ || await getSettingsAsync(); return details.levelOfControl.startsWith('controlled_by_this'); }, messages: { searchSettingsForAsUrl(niddle) { // `niddle` may be: 'proxy'. const localedNiddle = chrome.i18n.getMessage(niddle) || niddle; return `chrome://settings/search#${localedNiddle}`; }, whichExtensionAsHtml() { // Example: "Other extension controls proxy! <a...>Which?</a>" const otherMsg = chrome.i18n.getMessage('errreporter_noControl') || 'Other extension controls proxy!'; return ` ${otherMsg} <a href="${this.searchSettingsForAsUrl('proxy')}"> ${chrome.i18n.getMessage('errreporter_which') || 'Which?'} </a>`; }, }, }; const CreateLocalStorage = function CreateLocalStorage(prefix) { return function state(originalKey, value) { const key = prefix + originalKey; if (value === null) { return window.localStorage.removeItem(key); } if (value === undefined) { const item = window.localStorage.getItem(key); return item && JSON.parse(item); } if (value instanceof Date) { throw new TypeError('Converting Date format to JSON is not supported.'); } window.localStorage.setItem(key, JSON.stringify(value)); }; }; const debug$1 = Debug('weer:notifier'); /* Loads icon by url or generates icon from text when offline. Returns blob url. */ const loadIconAsBlobUrlAsync = function loadIconAsBlobUrlAsync(iconUrl = Utils.mandatory()) { const img = new Image(); img.crossOrigin = 'anonymous'; const size = 128; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); return new Promise((resolve) => { const dumpCanvas = () => canvas.toBlob( (blob) => resolve(URL.createObjectURL(blob)), ); img.onload = () => { ctx.drawImage(img, 0, 0, size, size); dumpCanvas(); }; img.onerror = () => { // I did my best centering it. ctx.fillStyle = 'red'; ctx.fillRect(0, 0, size, size); ctx.font = '50px arial'; ctx.fillStyle = 'white'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; const half = size / 2; ctx.fillText('error', half, half, size); dumpCanvas(); }; img.src = iconUrl; }); }; const notyPrefix = 'reporter-'; const ifPrefix = 'if-on-'; const extName = chrome.runtime.getManifest().name; const extBuild = Versions.currentBuild; const defaultClickHandler = function defaultClickHandler( { toEmail = Utils.mandatory(), reportLangs = Utils.mandatory(), }, message, report, ) { const json = JSON.stringify(report); const url = `${ 'https://error-reporter.github.io/v0/error/view/?title={{message}}&json={{json}}&reportLangs={{reportLangs}}' .replace('{{message}}', encodeURIComponent(message)) .replace('{{json}}', encodeURIComponent(json)) .replace('{{reportLangs}}', encodeURIComponent(reportLangs.join(','))) }#toEmail=${encodeURIComponent(toEmail)}`; chrome.tabs.create( { url }, (tab) => chrome.windows.update(tab.windowId, { focused: true }), ); }; const createErrorNotifiers = ( { sendReports: { toEmail = undefined, inLanguages = ['en'], }, onNotificationClick = defaultClickHandler, // Icons: extErrorIconUrl = 'https://error-reporter.github.io/v0/icons/ext-error-128.png', pacErrorIconUrl = 'https://error-reporter.github.io/v0/icons/pac-error-128.png', maskIconUrl = false, } = {}, ) => { let onNotyClick; { const ifDefault = onNotificationClick === defaultClickHandler; Utils.assert( !ifDefault || (toEmail && inLanguages && inLanguages.length), 'Default click handler requires { sendReports: { toEmail: \'foo@example.com\', inLanguages: [\'en\'] } } config to be set.', ); onNotyClick = ifDefault ? (...args) => onNotificationClick( { toEmail, reportLangs: [...new Set(inLanguages)].map( (lang) => lang.toLowerCase(), ), }, ...args, ) : onNotificationClick; } const errorTypeToIconUrl = { 'ext-error': extErrorIconUrl, 'pac-error': pacErrorIconUrl, }; const errorNotifiers = { state: CreateLocalStorage('error-handlers-'), // ErrorLike is `{message: ...}`. E.g. ErrorEvent. typeToErrorLike: {}, viewError(errorType) { const payload = this.typeToErrorLike[errorType] || this.typeToErrorLike; const report = Object.assign({}, { payload, errorType, extName, version: Versions.current, userAgent: navigator.userAgent, platform: navigator.platform, }); const err = payload.error || payload; const msg = (err && err.message) || 'I Found a Bug'; onNotyClick(msg, report); }, isOn(eventName = Utils.mandatory()) { Utils.assert(['ext-error', 'pac-error'].includes(eventName)); // 'On' by default. return this.state(ifPrefix + eventName) !== 'off'; }, async mayNotify( errorType, title, errorLikeOrMessage = Utils.mandatory(), { context = `${extName} ${extBuild}`, ifSticky = true, } = {}, ) { if (!this.isOn(errorType)) { return Promise.resolve(false); } this.typeToErrorLike[errorType] = errorLikeOrMessage; const message = errorLikeOrMessage.message || errorLikeOrMessage.toString(); const iconUrl = await loadIconAsBlobUrlAsync( errorTypeToIconUrl[errorType], ); const opts = { title, message, contextMessage: context, type: 'basic', iconUrl, isClickable: true, }; if (maskIconUrl) { const url = await loadIconAsBlobUrlAsync(maskIconUrl); Object.assign(opts, { appIconMaskUrl: url, }); } if (!/Firefox/.test(navigator.userAgent)) { Object.assign(opts, { requireInteraction: ifSticky, }); } return new Promise( (resolve) => chrome.notifications.create( `${notyPrefix}${errorType}`, opts, () => resolve(true), ), ); }, install() { /* You can't send message from bg to itself, call this function instead when caught error in BG window. See: https://stackoverflow.com/questions/17899769 */ const handleErrorMessage = (messageObj) => { const errLike = messageObj.payload; return this.mayNotify('ext-error', 'Extension error', errLike); }; chrome.runtime.onMessage.addListener((messageObj) => { debug$1('Received:', messageObj); if (messageObj.to !== 'error-reporter') { return; } handleErrorMessage(messageObj); }); chrome.notifications.onClicked.addListener(Utils.timeouted((notyId) => { if (!notyId.startsWith(notyPrefix)) { return; } chrome.notifications.clear(notyId); const errorType = notyId.substr(notyPrefix.length); errorNotifiers.viewError(errorType); })); if (chrome.proxy) { chrome.proxy.onProxyError.addListener(Utils.timeouted(async (details) => { const ifControlled = await ProxySettings.areControlledAsync(); if (!ifControlled) { return; } /* Example: details: "line: 7: Uncaught Error: This is error, man.", error: "net::ERR_PAC_SCRIPT_FAILED", fatal: false, */ const ifConFail = details.error === 'net::ERR_PROXY_CONNECTION_FAILED'; if (ifConFail) { // Happens if you return neither prixies nor "DIRECT". // Ignore it. return; } // TOOD: add "view pac script at this line" button. errorNotifiers.mayNotify( 'pac-error', 'PAC Error!', `${details.error}\n${details.details}`, ); })); } return handleErrorMessage; }, }; return errorNotifiers; }; let singleton = false; function GetNotifiersSingleton(configs) { if (singleton) { return singleton; } const notifiers = createErrorNotifiers(configs); const handleErrorMessage = notifiers.install(); singleton = { // Public API. handleErrorMessage, getErrorTypeToLabelMap() { return new Map([ ['pac-error', 'PAC script error'], ['ext-error', 'extension error'], ]); }, switch(onOffStr = Utils.mandatory(), eventName) { Utils.assert( ['on', 'off'].includes(onOffStr), 'First argument bust be "on" or "off".', ); const eventNames = eventName ? [eventName] : [...this.getErrorTypeToLabelMap().keys()]; eventNames.forEach( (name) => notifiers.state( ifPrefix + name, onOffStr === 'on' ? 'on' : 'off', ), ); }, isOn(eventName) { return notifiers.isOn(eventName); }, }; return singleton; } const install = (configs) => { const { handleErrorMessage } = GetNotifiersSingleton(configs); const uninstall = ErrorCatchers.installListenersOn({ handleErrorMessage }); return uninstall; }; var index = { Utils, ErrorCatchers, GetNotifiersSingleton, install, }; return index; })));