weer
Version:
Web Extensions Error Reporter catches global errors, shows notifications and opens error reporter in one click
455 lines (347 loc) • 11 kB
JavaScript
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;
;