UNPKG

weer

Version:

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

455 lines (347 loc) 11 kB
'use strict'; function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var Utils = _interopDefault(require('./utils')); /* # 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)); }; }; var Debug = window.debug || ((/* Logger ID */) => (/* Log message */) => { /* Ignore all */ }); const debug = 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('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; } module.exports = GetNotifiersSingleton;