diff --git a/gulpfile.js b/gulpfile.js index b395610..3dc2dbb 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -4,7 +4,6 @@ * https://opensource.org/licenses/BSD-3-Clause * https://github.com/opus1269/screensaver/blob/master/LICENSE.md */ -/* tslint:disable */ 'use strict'; /* eslint no-console: 0 */ /* eslint require-jsdoc: 0 */ @@ -14,17 +13,15 @@ const base = { app: 'chrome-ext-utils', src: 'src/', docs: 'docs/', + dest: './', }; const path = { scripts: `${base.src}`, }; const files = { - scripts: `${path.scripts}**/*.js`, - scripts_ts: `${path.scripts}**/*.ts`, + ts: `${path.scripts}**/*.ts`, + lintdevjs: ['./gulpfile.js'], }; -files.tmpJs = [files.scripts]; -files.ts = [files.scripts_ts]; -files.lintdevjs = ['./gulpfile.js']; // command options const watchOpts = { @@ -34,8 +31,6 @@ const watchOpts = { // flag for watching let isWatch = false; -// flag for production release build -let isProd = false; const gulp = require('gulp'); const runSequence = require('run-sequence'); @@ -43,9 +38,9 @@ const runSequence = require('run-sequence'); const noop = require('gulp-noop'); const watch = require('gulp-watch'); const plumber = require('gulp-plumber'); -const replace = require('gulp-replace'); const eslint = require('gulp-eslint'); -// const debug = require('gulp-debug'); // eslint-disable-line no-unused-vars +// noinspection JSUnusedLocalSymbols +const debug = require('gulp-debug'); // eslint-disable-line no-unused-vars // TypeScript const ts = require('gulp-typescript'); @@ -53,10 +48,6 @@ const tsProject = ts.createProject('tsconfig.json'); const tslint = require('gulp-tslint'); const typedoc = require('gulp-typedoc'); -// code replacement -const SRCH_DEBUG = 'const _DEBUG = false'; -const REP_DEBUG = 'const _DEBUG = true'; - // to get the current task name let currentTaskName = ''; gulp.Gulp.prototype.__runTask = gulp.Gulp.prototype._runTask; @@ -70,7 +61,6 @@ gulp.task('default', ['incrementalBuild']); // Incremental Development build gulp.task('incrementalBuild', (cb) => { - isProd = false; isWatch = true; runSequence([ @@ -80,27 +70,15 @@ gulp.task('incrementalBuild', (cb) => { ], cb); }); -// Development build -gulp.task('buildDev', (cb) => { - isProd = false; - isWatch = false; - - runSequence('_lint', '_build_js', cb); -}); - - // Production build -gulp.task('buildProd', (cb) => { - isProd = true; +gulp.task('build', (cb) => { isWatch = false; - base.dist = '../build/prod/app'; - runSequence('_build_js', 'docs', cb); + runSequence('_lint', '_build_js', 'docs', cb); }); // Generate Typedoc gulp.task('docs', () => { - const input = files.ts; return gulp.src(input).pipe(typedoc({ mode: 'modules', @@ -143,8 +121,7 @@ gulp.task('_ts_dev', () => { return gulp.src(input, {base: '.'}). pipe(tsProject(ts.reporter.longReporter())). on('error', () => {/* Ignore compiler errors */}). - pipe((!isProd ? replace(SRCH_DEBUG, REP_DEBUG) : noop())). - pipe(gulp.dest(base.dev)); + pipe(gulp.dest(base.dest)); }); // Watch for changes to TypeScript files @@ -160,6 +137,5 @@ gulp.task('_build_js', () => { const input = files.ts; return gulp.src(input, {base: '.'}). pipe(tsProject(ts.reporter.longReporter())).js. - pipe((!isProd ? replace(SRCH_DEBUG, REP_DEBUG) : noop())). - pipe(gulp.dest(base.src), noop()); + pipe(gulp.dest(base.dest), noop()); }); diff --git a/package-lock.json b/package-lock.json index 41203bb..a86931f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -534,12 +534,6 @@ "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", "dev": true }, - "binaryextensions": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-2.1.2.tgz", - "integrity": "sha512-xVNN69YGDghOqCCtA6FI7avYrr02mTJjOgB0/f1VPD3pJC8QEvjTKWc4epDx8AqxxA75NI0QpVM2gPJXUbE4Tg==", - "dev": true - }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1100,12 +1094,6 @@ } } }, - "editions": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/editions/-/editions-1.3.4.tgz", - "integrity": "sha512-gzao+mxnYDzIysXKMQi/+M1mjy/rjestjg6OPoYTtI+3Izp23oiGZitsl9lPDPiTGXbcSIk1iJWhliSaglxnUg==", - "dev": true - }, "end-of-stream": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-0.1.5.tgz", @@ -2858,17 +2846,6 @@ } } }, - "gulp-replace": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/gulp-replace/-/gulp-replace-1.0.0.tgz", - "integrity": "sha512-lgdmrFSI1SdhNMXZQbrC75MOl1UjYWlOWNbNRnz+F/KHmgxt3l6XstBoAYIdadwETFyG/6i+vWUSCawdC3pqOw==", - "dev": true, - "requires": { - "istextorbinary": "2.2.1", - "readable-stream": "^2.0.1", - "replacestream": "^4.0.0" - } - }, "gulp-tslint": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/gulp-tslint/-/gulp-tslint-8.1.4.tgz", @@ -3679,17 +3656,6 @@ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", "dev": true }, - "istextorbinary": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-2.2.1.tgz", - "integrity": "sha512-TS+hoFl8Z5FAFMK38nhBkdLt44CclNRgDHWeMgsV8ko3nDlr/9UI2Sf839sW7enijf8oKsZYXRvM8g0it9Zmcw==", - "dev": true, - "requires": { - "binaryextensions": "2", - "editions": "^1.3.3", - "textextensions": "2" - } - }, "js-tokens": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", @@ -4692,17 +4658,6 @@ "integrity": "sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ=", "dev": true }, - "replacestream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/replacestream/-/replacestream-4.0.3.tgz", - "integrity": "sha512-AC0FiLS352pBBiZhd4VXB1Ab/lh0lEgpP+GGvZqbQh8a5cmXVoTe5EX/YeTFArnp4SRGTHh1qCHu9lGs1qG8sA==", - "dev": true, - "requires": { - "escape-string-regexp": "^1.0.3", - "object-assign": "^4.0.1", - "readable-stream": "^2.0.2" - } - }, "require-uncached": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", @@ -5254,12 +5209,6 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, - "textextensions": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-2.4.0.tgz", - "integrity": "sha512-qftQXnX1DzpSV8EddtHIT0eDDEiBF8ywhFYR2lI9xrGtxqKN+CvLXhACeCIGbCpQfxxERbrkZEFb8cZcDKbVZA==", - "dev": true - }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/package.json b/package.json index baccac8..505effe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chrome-ext-utils", - "version": "0.1.0", + "version": "1.0.0", "description": "Chrome Extension utilities", "repository": { "type": "git", @@ -15,7 +15,7 @@ "url": "https://github.com/opus1269/chrome-ext-utils/issues" }, "homepage": "https://github.com/opus1269/chrome-ext-utils", - "private": false, + "private": true, "devDependencies": { "eslint": "^4.10.0", "eslint-config-google": "^0.9.1", @@ -27,7 +27,6 @@ "gulp-load-plugins": "^1.5.0", "gulp-noop": "^1.0.0", "gulp-plumber": "^1.1.0", - "gulp-replace": "^1.0.0", "gulp-tslint": "^8.1.4", "gulp-typedoc": "^2.2.2", "gulp-typescript": "^5.0.1", diff --git a/src/analytics.js b/src/analytics.js new file mode 100644 index 0000000..31697e3 --- /dev/null +++ b/src/analytics.js @@ -0,0 +1,258 @@ +/** + * Google Analytics tracking + * + * @module chrome/analytics + */ +/** */ +/* + * Copyright (c) 2015-2019, Michael A. Updike All rights reserved. + * Licensed under the BSD-3-Clause + * https://opensource.org/licenses/BSD-3-Clause + * https://github.com/opus1269/chrome-ext-utils/blob/master/LICENSE + */ +import * as ChromeJSON from './json.js'; +import * as ChromeUtils from './utils.js'; +/** Event types */ +export const EVENT = { + /** Extension installed */ + INSTALLED: { + eventCategory: 'extension', + eventAction: 'installed', + eventLabel: '', + }, + /** Extension updated */ + UPDATED: { + eventCategory: 'extension', + eventAction: 'updated', + eventLabel: '', + }, + /** Cached OAuth2 token refreshed */ + REFRESHED_AUTH_TOKEN: { + eventCategory: 'user', + eventAction: 'refreshedAuthToken', + eventLabel: '', + }, + /** Chrome alarm triggered */ + ALARM: { + eventCategory: 'alarm', + eventAction: 'triggered', + eventLabel: '', + }, + /** Menu item selected */ + MENU: { + eventCategory: 'ui', + eventAction: 'menuSelect', + eventLabel: '', + }, + /** Toggle state changed */ + TOGGLE: { + eventCategory: 'ui', + eventAction: 'toggle', + eventLabel: '', + }, + /** Url link clicked */ + LINK: { + eventCategory: 'ui', + eventAction: 'linkSelect', + eventLabel: '', + }, + /** Text changed */ + TEXT: { + eventCategory: 'ui', + eventAction: 'textChanged', + eventLabel: '', + }, + /** Slider value changed */ + SLIDER_VALUE: { + eventCategory: 'ui', + eventAction: 'sliderValueChanged', + eventLabel: '', + }, + /** Slider unit changed */ + SLIDER_UNITS: { + eventCategory: 'ui', + eventAction: 'sliderUnitsChanged', + eventLabel: '', + }, + /** Button clicked */ + BUTTON: { + eventCategory: 'ui', + eventAction: 'buttonClicked', + eventLabel: '', + }, + /** Radio button clicked */ + RADIO_BUTTON: { + eventCategory: 'ui', + eventAction: 'radioButtonClicked', + eventLabel: '', + }, + /** Toolbar icon clicked */ + ICON: { + eventCategory: 'ui', + eventAction: 'toolbarIconClicked', + eventLabel: '', + }, + /** Item clicked */ + CLICK: { + eventCategory: 'ui', + eventAction: 'click', + eventLabel: '', + }, + /** Checkbox clicked */ + CHECK: { + eventCategory: 'ui', + eventAction: 'checkBoxClicked', + eventLabel: '', + }, + /** Image button clicked */ + IMAGE_BUTTON: { + eventCategory: 'ui', + eventAction: 'imageButtonClicked', + eventLabel: '', + }, + /** Fab button clicked */ + FAB_BUTTON: { + eventCategory: 'ui', + eventAction: 'fabButtonClicked', + eventLabel: '', + }, + /** Keyboard shortcut entered */ + KEY_COMMAND: { + eventCategory: 'ui', + eventAction: 'keyCommand', + eventLabel: '', + }, +}; +/** + * Initialize analytics + * + * @param trackingId - tracking id + * @param appName - extension name + * @param appId - extension Id + * @param appVersion - extension version + */ +export function initialize(trackingId, appName, appId, appVersion) { + // Standard Google Universal Analytics code + // @ts-ignore + (function (i, s, o, g, r, a, m) { + // @ts-ignore + i['GoogleAnalyticsObject'] = r; // tslint:disable-line no-string-literal + // @ts-ignore + // noinspection CommaExpressionJS + i[r] = i[r] || function () { + // @ts-ignore + (i[r].q = i[r].q || []).push(arguments); + // @ts-ignore + }, i[r].l = 1 * new Date(); + // @ts-ignore + // noinspection CommaExpressionJS + a = s.createElement(o), + // @ts-ignore + m = s.getElementsByTagName(o)[0]; + // @ts-ignore + a.async = 1; + // @ts-ignore + a.src = g; + // @ts-ignore + m.parentNode.insertBefore(a, m); + })(window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga'); + ga('create', trackingId, 'auto'); + // see: http://stackoverflow.com/a/22152353/1958200 + ga('set', 'checkProtocolTask', function () { + }); + ga('set', 'appName', appName); + ga('set', 'appId', appId); + ga('set', 'appVersion', appVersion); + ga('require', 'displayfeatures'); +} +/** + * Send a page + * + * @param url - page path + */ +export function page(url) { + if (url) { + if (!ChromeUtils.DEBUG) { + ga('send', 'pageview', url); + } + } +} +/** + * Send an event + * + * @param theEvent - the event type + * @param label - override label + * @param action - override action + */ +export function event(theEvent, label, action) { + if (theEvent) { + const ev = ChromeJSON.shallowCopy(theEvent); + ev.hitType = 'event'; + ev.eventLabel = label ? label : ev.eventLabel; + ev.eventAction = action ? action : ev.eventAction; + if (!ChromeUtils.DEBUG) { + ga('send', ev); + } + else { + console.log(ev); // tslint:disable-line no-console + } + } +} +/** + * Send an error + * + * @param label - override label + * @param method - override method + */ +export function error(label = 'unknown', method = 'unknownMethod') { + const ev = { + hitType: 'event', + eventCategory: 'error', + eventAction: method, + eventLabel: `Err: ${label}`, + }; + if (!ChromeUtils.DEBUG) { + ga('send', ev); + } + else { + console.error(ev); // tslint:disable-line no-console + } +} +/** + * Send an exception + * + * @param err - the error + * @param msg - the error message + * @param fatal - true if fatal + */ +export function exception(err, msg, fatal) { + try { + const theFatal = (fatal === undefined) ? false : fatal; + let theMsg = 'Unknown'; + if (msg) { + theMsg = msg; + } + else if (err && err.message) { + theMsg = err.message; + } + if (err && err.stack) { + theMsg += `\n\n${err.stack}`; + } + const ex = { + hitType: 'exception', + exDescription: theMsg, + exFatal: theFatal, + }; + if (!ChromeUtils.DEBUG) { + ga('send', ex); + } + else { + console.error(ex); // tslint:disable-line no-console + } + } + catch (err) { + if (ChromeUtils.DEBUG) { + console.error(err); // tslint:disable-line no-console + } + } +} diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..1b80e35 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,93 @@ +/** + * Google Oauth2.0 utilities + * {@link https://developer.chrome.com/apps/identity} + * + * @module chrome/auth + */ +/** */ +/* + * Copyright (c) 2015-2019, Michael A. Updike All rights reserved. + * Licensed under the BSD-3-Clause + * https://opensource.org/licenses/BSD-3-Clause + * https://github.com/opus1269/chrome-ext-utils/blob/master/LICENSE + */ +import * as ChromeUtils from './utils.js'; +const chromep = new ChromePromise(); +/** + * Get an OAuth2.0 token + * + * @remarks + * + * Note: Every time you use a different scopes array, you will get a new + * token the first time, so you need to always get it with those scopes + * and remove the cached one with the scopes. + * + * @param interactive - if true may block + * @param scopes - optional scopes to use, overrides those in the manifest + * @throws An error if we failed to get token + * @returns An access token + */ +export async function getToken(interactive = false, scopes) { + const request = { + interactive: interactive, + }; + if (scopes && scopes.length) { + request.scopes = scopes; + } + return await chromep.identity.getAuthToken(request); +} +/** + * Remove a cached OAuth2.0 token + * + * @param interactive - if true may block + * @param curToken token to remove + * @param scopes - optional scopes to use, overrides those in the manifest + * @throws An error if we failed to remove token + * @returns The old token + */ +export async function removeCachedToken(interactive = false, curToken = '', scopes) { + let oldToken = curToken; + if (ChromeUtils.isWhiteSpace(oldToken)) { + oldToken = await getToken(interactive, scopes); + } + if (oldToken) { + await chromep.identity.removeCachedAuthToken({ token: oldToken }); + } + return oldToken; +} +/** + * Is a user signed in to Chrome + * + * @returns true if signed in + */ +export async function isSignedIn() { + let ret = true; + // try to get a token and check failure message + try { + await chromep.identity.getAuthToken({ interactive: false }); + } + catch (err) { + if (err.message.match(/not signed in/)) { + ret = false; + } + } + return ret; +} +/** + * Has our authorization been revoked (or not granted) for the default scopes + * + * @returns true if no valid token + */ +export async function isRevoked() { + let ret = false; + // try to get a token and check failure message + try { + await chromep.identity.getAuthToken({ interactive: false }); + } + catch (err) { + if (err.message.match(/OAuth2 not granted or revoked/)) { + ret = true; + } + } + return ret; +} diff --git a/src/ex_handler.js b/src/ex_handler.js new file mode 100644 index 0000000..ee12814 --- /dev/null +++ b/src/ex_handler.js @@ -0,0 +1,35 @@ +/** + * Global exception handlers + * + * @module chrome/ex_handler + */ +/** */ +/* + * Copyright (c) 2015-2019, Michael A. Updike All rights reserved. + * Licensed under the BSD-3-Clause + * https://opensource.org/licenses/BSD-3-Clause + * https://github.com/opus1269/chrome-ext-utils/blob/master/LICENSE + */ +import * as ChromeLog from './log.js'; +// Listen for uncaught promises for logging purposes. - Chrome only +window.addEventListener('unhandledrejection', function (ev) { + if (ChromeLog && ev) { + const reason = ev.reason; + if (reason && (reason instanceof Error)) { + ChromeLog.exception(reason, null, true); + } + else { + const msg = (reason && reason.message) ? reason.message : 'Uncaught promise rejection'; + ChromeLog.exception(null, msg, true); + } + } +}); +// Replace global error handler for logging purposes. +if (typeof window.onerror === 'object') { + // global error handler + window.onerror = function (message, url, line, col, errObject) { + if (ChromeLog && errObject) { + ChromeLog.exception(errObject, null, true); + } + }; +} diff --git a/src/http.js b/src/http.js new file mode 100644 index 0000000..151884a --- /dev/null +++ b/src/http.js @@ -0,0 +1,255 @@ +/** + * Fetch with optional authentication and exponential back-off + * + * @module chrome/http + */ +/** */ +/* + * Copyright (c) 2015-2019, Michael A. Updike All rights reserved. + * Licensed under the BSD-3-Clause + * https://opensource.org/licenses/BSD-3-Clause + * https://github.com/opus1269/chrome-ext-utils/blob/master/LICENSE + */ +import * as ChromeGA from './analytics.js'; +import * as ChromeAuth from './auth.js'; +import * as ChromeLocale from './locales.js'; +import * as ChromeUtils from './utils.js'; +/** + * Authorization header + */ +const AUTH_HEADER = 'Authorization'; +/** + * Bearer parameter for authorized call + */ +const BEARER = 'Bearer'; +/** + * Max retries on 500 errors + */ +const MAX_RETRIES = 4; +/** + * Delay multiplier for exponential back-off + */ +const DELAY = 1000; +/** + * Configuration object + */ +export const CONFIG = { + checkConnection: true, + isAuth: false, + retryToken: false, + interactive: false, + token: null, + backoff: true, + maxRetries: MAX_RETRIES, + body: null, + json: true, +}; +/** + * Perform GET request + * + * @param url - server request + * @param conf - configuration + * @throws An error if GET fails + * @returns response from server + */ +export async function doGet(url, conf = CONFIG) { + const opts = { method: 'GET', headers: new Headers({}) }; + return await doIt(url, opts, conf); +} +/** + * Perform POST request + * + * @param url - server request + * @param conf - configuration + * @throws An error if POST fails + * @returns response from server + */ +export async function doPost(url, conf = CONFIG) { + const opts = { method: 'POST', headers: new Headers({}) }; + return await doIt(url, opts, conf); +} +/** + * Get Error message + * + * @param response - server response + * @returns Error + */ +export function getError(response) { + let msg = 'Unknown error.'; + if (response && response.status && (typeof (response.statusText) !== 'undefined')) { + const statusMsg = ChromeLocale.localize('err_status', 'Status'); + msg = `${statusMsg}: ${response.status}`; + msg += `\n${response.statusText}`; + } + return new Error(msg); +} +/** + * Check response and act accordingly, including retrying + * + * @param response - server response + * @param url - server + * @param opts - fetch options + * @param conf - configuration + * @param attempt - the retry attempt we are on + * @throws An error if fetch ultimately fails + * @returns response from server + */ +async function processResponse(response, url, opts, conf, attempt) { + if (response.ok) { + // request succeeded - woo hoo! + if (conf.json) { + return await response.json(); + } + else { + return response; + } + } + if (attempt >= conf.maxRetries) { + // request still failed after maxRetries + if (conf.json) { + throw getError(response); + } + else { + return response; + } + } + const status = response.status; + if (conf.backoff && (status >= 500) && (status < 600)) { + // temporary server error, maybe. Retry with backoff + return await retry(url, opts, conf, attempt); + } + if (conf.isAuth && conf.token && conf.retryToken && (status === 401)) { + // could be expired token. Remove cached one and try again + return await retryToken(url, opts, conf, attempt); + } + if (conf.isAuth && conf.interactive && conf.token && conf.retryToken && (status === 403)) { + // user may have revoked access to extension at some point + // If interactive, retry so they can authorize again + return await retryToken(url, opts, conf, attempt); + } + // request failed + if (conf.json) { + throw getError(response); + } + else { + return response; + } +} +/** + * Get authorization token + * + * @param isAuth - if true, authorization required + * @param interactive - if true, user initiated + * @throws An error if we failed to get token + * @returns auth token + */ +async function getAuthToken(isAuth, interactive) { + if (isAuth) { + try { + return await ChromeAuth.getToken(interactive); + } + catch (err) { + if (interactive && (err.message.includes('revoked') || + err.message.includes('Authorization page could not be loaded'))) { + // try one more time non-interactively + // Always returns Authorization page error + // when first registering, Not sure why + // Other message is if user revoked access to extension + return await ChromeAuth.getToken(false); + } + else { + throw err; + } + } + } + else { + // non-authorization branch + return; + } +} +/** + * Retry authorized fetch with exponential back-off + * + * @param url - server request + * @param opts - fetch options + * @param conf - configuration + * @param attempt - the retry attempt we are on + * @throws An error if fetch failed + * @returns response from server + */ +async function retry(url, opts, conf, attempt) { + attempt++; + const delay = (Math.pow(2, attempt) - 1) * DELAY; + await ChromeUtils.wait(delay); + ChromeGA.error(`Retry fetch, attempt: ${attempt}`, 'ChromeHttp.retry'); + return await doFetch(url, opts, conf, attempt); +} +/** + * Retry fetch after removing cached auth token + * + * @param url - server request + * @param opts - fetch options + * @param conf - configuration + * @param attempt - the retry attempt we are on + * @throws An error if fetch failed + * @returns response from server + */ +async function retryToken(url, opts, conf, attempt) { + ChromeGA.event(ChromeGA.EVENT.REFRESHED_AUTH_TOKEN); + await ChromeAuth.removeCachedToken(conf.interactive, conf.token); + conf.token = null; + conf.retryToken = false; + return await doFetch(url, opts, conf, attempt); +} +/** + * Perform fetch, optionally using authorization and exponential back-off + * + * @param url - server request + * @param opts - fetch options + * @param conf - configuration + * @param attempt - the retry attempt we are on + * @throws An error if fetch failed + * @returns response from server + */ +async function doFetch(url, opts, conf, attempt) { + try { + const authToken = await getAuthToken(conf.isAuth, conf.interactive); + if (conf.isAuth) { + conf.token = authToken; + opts.headers.set(AUTH_HEADER, `${BEARER} ${conf.token}`); + } + if (conf.body) { + opts.body = JSON.stringify(conf.body); + } + // do the actual fetch + const response = await fetch(url, opts); + // process and possibly retry + return await processResponse(response, url, opts, conf, attempt); + } + catch (err) { + let msg = err.message; + if (msg === 'Failed to fetch') { + msg = ChromeLocale.localize('err_network', 'Network error'); + } + throw new Error(msg); + } +} +/** + * Do a server request + * + * @param url - server request + * @param opts - fetch options + * @param conf - configuration + * @throws An error if request failed + * @returns response from server + */ +async function doIt(url, opts, conf) { + conf = conf || CONFIG; + if (conf.checkConnection) { + ChromeUtils.checkNetworkConnection(); + } + if (conf.isAuth) { + opts.headers.set(AUTH_HEADER, `${BEARER} unknown`); + } + return await doFetch(url, opts, conf, 0); +} diff --git a/src/json.js b/src/json.js new file mode 100644 index 0000000..8823d48 --- /dev/null +++ b/src/json.js @@ -0,0 +1,59 @@ +/** + * JSON utilities + * + * @module chrome/json + */ +/** */ +/* + * Copyright (c) 2015-2019, Michael A. Updike All rights reserved. + * Licensed under the BSD-3-Clause + * https://opensource.org/licenses/BSD-3-Clause + * https://github.com/opus1269/chrome-ext-utils/blob/master/LICENSE + */ +import * as ChromeGA from './analytics.js'; +/** + * Parse json, with exception handling + * + * @param jsonString - string to parse + * @returns json object, null on error + */ +export function parse(jsonString) { + let ret = null; + try { + ret = JSON.parse(jsonString); + } + catch (err) { + ChromeGA.error(err.message, 'ChromeJSON.parse'); + } + return ret; +} +/** + * Stringify json, with exception handling + * + * @param jsonifiable - object to stringify + * @returns string, null on error + */ +export function stringify(jsonifiable) { + let ret = null; + try { + ret = JSON.stringify(jsonifiable); + } + catch (err) { + ChromeGA.error(err.message, 'ChromeJSON.stringify'); + } + return ret; +} +/** + * Create a shallow copy of an object + * + * @param jsonifiable - object to copy + * @returns shallow copy of input, null on error + */ +export function shallowCopy(jsonifiable) { + let ret = null; + const jsonString = stringify(jsonifiable); + if (jsonString !== null) { + ret = parse(jsonString); + } + return ret; +} diff --git a/src/last_error.js b/src/last_error.js new file mode 100644 index 0000000..45fab95 --- /dev/null +++ b/src/last_error.js @@ -0,0 +1,77 @@ +/** + * Custom error + * + * @module chrome/last_error + */ +const chromep = new ChromePromise(); +/** + * A custom error that can be persisted + * + * @remarks + * + * Usage: + * ```ts + * const err = new ChromeLastError(title, message); + * ``` + */ +export class ChromeLastError extends Error { + /** + * Get the LastError from chrome.storage.local + * + * @throws If we failed to get the error + * @returns A ChromeLastError + */ + static async load() { + const value = await chromep.storage.local.get('lastError'); + const details = value.lastError; + if (details) { + const lastError = new ChromeLastError(details.title, details.message); + lastError.stack = details.stack; + return lastError; + } + return new ChromeLastError(); + } + /** + * Save the LastError to chrome.storage.local + * + * @throws If the error failed to save + */ + static async save(lastError) { + const value = { + title: lastError.title || '', + message: lastError.message || '', + stack: lastError.stack || '', + }; + // persist + return await chromep.storage.local.set({ lastError: value }); + } + /** + * Set the LastError to an empty message in chrome.storage.local + * + * @throws If the error failed to clear + */ + static async reset() { + // Save it using the Chrome storage API. + return await chromep.storage.local.set({ lastError: new ChromeLastError() }); + } + /** + * Create a new LastError + * + * @param title='' - optional title + * @param params - Error parameters + */ + constructor(title = 'An error occurred', ...params) { + // Pass remaining arguments (including vendor specific ones) + // to parent constructor + super(...params); + // Maintains proper stack trace for where our error was thrown + // (only available on V8) + // @ts-ignore + if (Error.captureStackTrace) { + // @ts-ignore + Error.captureStackTrace(this, ChromeLastError); + } + // Custom information + this.title = title; + } +} diff --git a/src/locales.js b/src/locales.js new file mode 100644 index 0000000..1148540 --- /dev/null +++ b/src/locales.js @@ -0,0 +1,36 @@ +/** + * Internationalization methods + * {@link https://developer.chrome.com/extensions/i18n} + * + * @module chrome/locales + */ +/** */ +/* + * Copyright (c) 2015-2019, Michael A. Updike All rights reserved. + * Licensed under the BSD-3-Clause + * https://opensource.org/licenses/BSD-3-Clause + * https://github.com/opus1269/chrome-ext-utils/blob/master/LICENSE + */ +/** + * Get the i18n string + * + * @param key - key in messages.json + * @param def - default if no locales + * @returns internationalized string + */ +export function localize(key, def) { + let msg = chrome.i18n.getMessage(key); + if ((msg === undefined) || (msg === '')) { + // in case localize is missing + msg = def || ''; + } + return msg; +} +/** + * Get the current locale + * + * @returns current locale e.g. en_US + */ +export function getLocale() { + return chrome.i18n.getMessage('@@ui_locale'); +} diff --git a/src/log.js b/src/log.js new file mode 100644 index 0000000..298880b --- /dev/null +++ b/src/log.js @@ -0,0 +1,59 @@ +/** + * Log a message. Will also store the LastError to chrome storage + * + * @module chrome/log + */ +/** */ +/* + * Copyright (c) 2015-2019, Michael A. Updike All rights reserved. + * Licensed under the BSD-3-Clause + * https://opensource.org/licenses/BSD-3-Clause + * https://github.com/opus1269/chrome-ext-utils/blob/master/LICENSE + */ +import * as ChromeGA from './analytics.js'; +import { ChromeLastError } from './last_error.js'; +import * as ChromeLocale from './locales.js'; +import * as ChromeUtils from './utils.js'; +/** + * Log an error + * + * @param msg - override label + * @param method - override action + * @param title - a title for the error + * @param extra - extra info. for analytics + */ +export function error(msg, method, title, extra) { + msg = msg || ChromeLocale.localize('err_unknown', 'unknown'); + method = method || ChromeLocale.localize('err_unknownMethod', 'unknownMethod'); + title = title || ChromeLocale.localize('err_error', 'An error occurred'); + const gaMsg = extra ? `${msg} ${extra}` : msg; + ChromeLastError.save(new ChromeLastError(title, msg)).catch(() => { }); + ChromeGA.error(gaMsg, method); +} +/** + * Log an exception + * + * @param err - the exception + * @param msg - the error message + * @param fatal - true if fatal + * @param title - a title for the exception + */ +export function exception(err, msg, fatal, title) { + try { + let errMsg = msg; + if (!errMsg && err && err.message) { + errMsg = err.message; + } + else { + errMsg = 'Unknown exception'; + } + title = title || ChromeLocale.localize('err_exception', 'An exception occurred'); + ChromeLastError.save(new ChromeLastError(title, errMsg)).catch(() => { }); + ChromeGA.exception(err, msg, fatal); + } + catch (err) { + if (ChromeUtils.DEBUG) { + console.error(err); // tslint:disable-line no-console + } + } +} diff --git a/src/msg.js b/src/msg.js new file mode 100644 index 0000000..a059951 --- /dev/null +++ b/src/msg.js @@ -0,0 +1,74 @@ +/** + * Wrapper for chrome messages + * + * {@link https://developer.chrome.com/extensions/messaging} + * + * @module chrome/msg + */ +/** */ +/* + * Copyright (c) 2015-2019, Michael A. Updike All rights reserved. + * Licensed under the BSD-3-Clause + * https://opensource.org/licenses/BSD-3-Clause + * https://github.com/opus1269/chrome-ext-utils/blob/master/LICENSE + */ +import * as ChromeGA from './analytics.js'; +/** + * Chrome Messages + */ +export const TYPE = { + /** highlight the options tab */ + HIGHLIGHT: { + message: 'highlightTab', + }, + /** restore default settings for app */ + RESTORE_DEFAULTS: { + message: 'restoreDefaults', + }, + /** save to some storage source failed because it would exceed capacity */ + STORAGE_EXCEEDED: { + message: 'storageExceeded', + }, + /** save value to local storage */ + STORE: { + message: 'store', + key: '', + value: '', + }, +}; +/** + * Send a chrome message + * + * @param type - type of message + * @throws An error if we failed to connect to the extension + * @returns Something that is json + */ +export async function send(type) { + const chromep = new ChromePromise(); + try { + return await chromep.runtime.sendMessage(type); + } + catch (err) { + if (err.message && !err.message.includes('port closed') && !err.message.includes('Receiving end does not exist')) { + const msg = `type: ${type.message}, ${err.message}`; + ChromeGA.error(msg, 'Msg.send'); + } + throw err; + } +} +/** + * Add a listener for chrome messages + * + * @param listener - function to receive messages + */ +export function addListener(listener) { + chrome.runtime.onMessage.addListener(listener); +} +/** + * Remove a listener for chrome messages + * + * @param listener - function to receive messages + */ +export function removeListener(listener) { + chrome.runtime.onMessage.removeListener(listener); +} diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..0799d1a --- /dev/null +++ b/src/storage.js @@ -0,0 +1,196 @@ +/** + * Manage items in storage + * + * @module chrome/storage + */ +/** */ +/* + * Copyright (c) 2015-2019, Michael A. Updike All rights reserved. + * Licensed under the BSD-3-Clause + * https://opensource.org/licenses/BSD-3-Clause + * https://github.com/opus1269/chrome-ext-utils/blob/master/LICENSE + */ +import * as ChromeGA from './analytics.js'; +import * as ChromeJSON from './json.js'; +import * as ChromeMsg from './msg.js'; +/** + * Get a json parsed value from localStorage + * + * @param key - key to get value for + * @param def - optional default value if key not found + * @returns json object or string, null if key does not exist + */ +export function get(key, def) { + let value = null; + const item = localStorage.getItem(key); + if (item !== null) { + value = ChromeJSON.parse(item); + } + else if (def !== undefined) { + value = def; + } + return value; +} +/** + * Get integer value from localStorage + * + * @param key - key to get value for + * @param def - optional value to return, if key not found or value is NaN + * @returns value as integer, NaN on error + */ +export function getInt(key, def) { + let value = Number.NaN; + const item = localStorage.getItem(key); + if (item != null) { + value = parseInt(item, 10); + if (Number.isNaN(value)) { + if (def !== undefined) { + value = def; + } + else { + ChromeGA.error(`NaN value for: ${key} equals ${item}`, 'ChromeStorage.getInt'); + } + } + } + else if (def !== undefined) { + value = def; + } + return value; +} +/** + * Get boolean value from localStorage + * + * @param key - key to get value for + * @param def - optional value if key not found + * @returns value as boolean, null if key does not exist + */ +export function getBool(key, def) { + return get(key, def); +} +/** + * JSON stringify and save a value to localStorage + * + * @param key - key to set value for + * @param value - new value, if null remove item + */ +export function set(key, value = null) { + if (value === null) { + localStorage.removeItem(key); + } + else { + const val = JSON.stringify(value); + localStorage.setItem(key, val); + } +} +/** + * Save a value to localStorage only if there is enough room + * + * @param key - localStorage Key + * @param value - value to save + * @param keyBool - optional key to a boolean value that is true if the primary key has non-empty value + * @returns true if value was set successfully + */ +export function safeSet(key, value, keyBool) { + let ret = true; + const oldValue = get(key); + try { + set(key, value); + } + catch (e) { + ret = false; + if (oldValue) { + // revert to old value + set(key, oldValue); + } + if (keyBool) { + // revert to old value + if (oldValue && oldValue.length) { + set(keyBool, true); + } + else { + set(keyBool, false); + } + } + // notify listeners + ChromeMsg.send(ChromeMsg.TYPE.STORAGE_EXCEEDED).catch(() => { }); + } + return ret; +} +/** + * Get a value from chrome.storage.local + * + * {@link https://developer.chrome.com/apps/storage} + * + * @param key - data key + * @param def - optional default value if not found + * @returns Object or Array from storage, def or null if not found + */ +export async function asyncGet(key, def) { + let value = null; + const chromep = new ChromePromise(); + try { + const res = await chromep.storage.local.get([key]); + value = res[key]; + } + catch (err) { + ChromeGA.error(err.message, 'ChromeStorage.asyncGet'); + if (def !== undefined) { + value = def; + } + } + if (value === undefined) { + // probably not in storage + if (def !== undefined) { + value = def; + } + } + return value; +} +/** + * Save a value to chrome.storage.local only if there is enough room + * + * {@link https://developer.chrome.com/apps/storage} + * + * @param key - data key + * @param value - data value + * @param keyBool - optional key to a boolean value that is true if the primary key has non-empty value + * @returns true if value was set successfully + */ +export async function asyncSet(key, value, keyBool) { + // TODO what about keyBool? + let ret = true; + const chromep = new ChromePromise(); + const obj = { + [key]: value, + }; + try { + await chromep.storage.local.set(obj); + } + catch (err) { + // notify listeners save failed + ChromeMsg.send(ChromeMsg.TYPE.STORAGE_EXCEEDED).catch(() => { }); + ret = false; + } + return ret; +} +// const oldValue = get(key); +// try { +// set(key, value); +// } catch (e) { +// ret = false; +// if (oldValue) { +// // revert to old value +// set(key, oldValue); +// } +// if (keyBool) { +// // revert to old value +// if (oldValue && oldValue.length) { +// set(keyBool, true); +// } else { +// set(keyBool, false); +// } +// } +// // notify listeners +// ChromeMsg.send(ChromeMsg.TYPE.STORAGE_EXCEEDED).catch(() => {}); +// } +// return ret; diff --git a/src/time.js b/src/time.js new file mode 100644 index 0000000..ec18fa6 --- /dev/null +++ b/src/time.js @@ -0,0 +1,188 @@ +/** + * Time utilities + * + * @module chrome/time + */ +/** */ +/* + * Copyright (c) 2015-2019, Michael A. Updike All rights reserved. + * Licensed under the BSD-3-Clause + * https://opensource.org/licenses/BSD-3-Clause + * https://github.com/opus1269/chrome-ext-utils/blob/master/LICENSE + */ +/** Default time */ +export const DEF_TIME = '00:00'; +/** Time Class */ +export class ChromeTime { + /** Milliseconds in minute */ + static get MSEC_IN_MIN() { + return 60 * 1000; + } + /** Minutes in hour */ + static get MIN_IN_HOUR() { + return 60; + } + /** Milliseconds in hour */ + static get MSEC_IN_HOUR() { + return ChromeTime.MIN_IN_HOUR * 60 * 1000; + } + /** Minutes in day */ + static get MIN_IN_DAY() { + return 60 * 24; + } + /** Milliseconds in day */ + static get MSEC_IN_DAY() { + return ChromeTime.MIN_IN_DAY * 60 * 1000; + } + /** + * Convert string to current time + * + * @param timeString - in '00:00' format + * @returns time in milliSeconds from epoch + */ + static getTime(timeString) { + const time = new ChromeTime(timeString); + const date = new Date(); + date.setHours(time._hr); + date.setMinutes(time._min); + date.setSeconds(0); + date.setMilliseconds(0); + return date.getTime(); + } + /** + * Calculate time delta from now on a 24hr basis + * + * @param timeString - in '00:00' format + * @returns time delta in minutes + */ + static getTimeDelta(timeString) { + const curTime = Date.now(); + const time = ChromeTime.getTime(timeString); + let delayMin = (time - curTime) / 1000 / 60; + if (delayMin < 0) { + delayMin = ChromeTime.MIN_IN_DAY + delayMin; + } + return delayMin; + } + /** + * Determine if current time is between start and stop, inclusive + * + * @param start - in '00:00' format + * @param stop - in '00:00' format + * @returns true if in the given range + */ + static isInRange(start, stop) { + const curTime = Date.now(); + const startTime = ChromeTime.getTime(start); + const stopTime = ChromeTime.getTime(stop); + let ret = false; + if (start === stop) { + ret = true; + } + else if (stopTime > startTime) { + if ((curTime >= startTime) && (curTime <= stopTime)) { + ret = true; + } + } + else { + if ((curTime >= startTime) || (curTime <= stopTime)) { + ret = true; + } + } + return ret; + } + /** + * Get time as string suitable for display, including AM/PM if 12hr + * + * @param timeString - in '00:00' format + * @param format - time format + * @returns display string + */ + static getStringFull(timeString, format) { + const time = new ChromeTime(timeString); + return time.toString(format); + } + /** + * Get current time suitable for display w/o AM/PM if 12hr + * + * @param format = time format + * @returns display string + */ + static getStringShort(format) { + const time = new ChromeTime(); + let timeString = time.toString(format); + // strip off all non-digits but : + timeString = timeString.replace(/[^\d:]/g, ''); + // strip off everything after 'xx:xx' + timeString = timeString.replace(/(.*?:.*?):.*/g, '$1'); + return timeString; + } + /** + * Determine if user wants 24 hr time + * + * @param format - time format + * @returns true for 24 hour time + */ + static is24Hr(format) { + let ret = false; + if (format === 2 /* HR_24 */) { + ret = true; + } + return ret; + } + /** + * Create a new Time + * + * @param timeString - optional in '00:00' format, otherwise use current time + */ + constructor(timeString) { + this.parse(timeString); + } + /** + * Get string representation of Time + * + * @param format - time format + * @returns As string + */ + toString(format) { + const date = new Date(); + date.setHours(this._hr, this._min); + date.setSeconds(0); + date.setMilliseconds(0); + // fallback in case toLocaleTimeString fails - it does sometimes + let ret = date.toTimeString(); + const languages = []; + if (navigator.language) { + languages.push(navigator.language); + } + languages.push('en-US'); + const opts = { + hour: 'numeric', + minute: '2-digit', + hour12: !ChromeTime.is24Hr(format), + }; + try { + ret = date.toLocaleTimeString(languages, opts); + } + catch (err) { + // ignore; + } + return ret; + } + /** + * Parse time string + * + * @param timeString - in '00:00' format + */ + parse(timeString) { + if (!timeString) { + const date = new Date(); + this._hr = date.getHours(); + this._min = date.getMinutes(); + } + else { + this._hr = parseInt(timeString.substr(0, 2), 10); + this._min = parseInt(timeString.substr(3, 2), 10); + } + } +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..af1ce2e --- /dev/null +++ b/src/utils.js @@ -0,0 +1,223 @@ +/** + * Utility methods + * + * @remarks + * If you want to log events, errors, etc. locally during development, + * save a true boolean key named isDevelopmentBuild to local storage + * + * @module chrome/utils + */ +/** */ +/* + * Copyright (c) 2015-2019, Michael A. Updike All rights reserved. + * Licensed under the BSD-3-Clause + * https://opensource.org/licenses/BSD-3-Clause + * https://github.com/opus1269/chrome-ext-utils/blob/master/LICENSE + */ +import * as ChromeLocale from './locales.js'; +import * as ChromeStorage from './storage.js'; +const chromep = new ChromePromise(); +/** True if development build */ +export const DEBUG = ChromeStorage.getBool('isDevelopmentBuild', false); +/** Get the extension's name + * + * @returns Extension name + */ +export function getExtensionName() { + return `chrome-extension://${chrome.runtime.id}`; +} +/** + * Get the Extension version + * + * @returns Extension version + */ +export function getVersion() { + const manifest = chrome.runtime.getManifest(); + return manifest.version; +} +/** + * Get the Chrome version + * {@link http://stackoverflow.com/a/4900484/4468645} + * + * @returns Chrome major version + */ +export function getChromeVersion() { + const raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); + return raw ? parseInt(raw[2], 10) : 0; +} +/** + * Get the full Chrome version + * {@link https://goo.gl/2ITMNO} + * + * @returns Chrome version + */ +export function getFullChromeVersion() { + const raw = navigator.userAgent; + return raw ? raw : 'Unknown'; +} +/** + * Get the OS as a human readable string + * + * @returns OS name + */ +export async function getPlatformOS() { + let output = 'Unknown'; + try { + const info = await chromep.runtime.getPlatformInfo(); + const os = info.os; + switch (os) { + case 'win': + output = 'MS Windows'; + break; + case 'mac': + output = 'Mac'; + break; + case 'android': + output = 'Android'; + break; + case 'cros': + output = 'Chrome OS'; + break; + case 'linux': + output = 'Linux'; + break; + case 'openbsd': + output = 'OpenBSD'; + break; + default: + break; + } + } + catch (e) { + // something went wrong - linux seems to fail this call sometimes + } + return output; +} +/** + * Determine if we are MS windows + * + * @returns true if MS Windows + */ +export function isWindows() { + return isOS('win'); +} +/** + * Determine if we are Chrome OS + * + * @returns true if ChromeOS + */ +export function isChromeOS() { + return isOS('cros'); +} +/** + * Determine if we are a Mac + * + * @returns true if Mac + */ +export function isMac() { + return isOS('mac'); +} +/** No operation */ +export function noop() { } +/** + * Determine if a String is null or whitespace only + * + * @param str - string to check + * @returns true is str is whitespace or null + */ +export function isWhiteSpace(str) { + return (!str || str.length === 0 || /^\s*$/.test(str)); +} +/** + * Get a random string of the given length + * + * @param len - length of generated string + * @returns A pseudo-random string + */ +export function getRandomString(len = 8) { + // noinspection SpellCheckingInspection + const POSS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let text = ''; + for (let i = 0; i < len; i++) { + text += POSS.charAt(Math.floor(Math.random() * POSS.length)); + } + return text; +} +/** + * Returns a random integer between min and max inclusive + * + * @param min - min value + * @param max - max value + * @returns A pseudo-random int + */ +export function getRandomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} +/** + * Returns a random float between min and max inclusive min, exclusive max + * + * @param min - min value + * @param max - max value + * @returns A pseudo-random float + */ +export function getRandomFloat(min, max) { + return Math.random() * (max - min) + min; +} +/** + * Randomly sort an Array in place + * + * @remarks + * + * Fisher-Yates shuffle algorithm. + * + * @param array - Array to sort + */ +export function shuffleArray(array) { + const len = array ? array.length : 0; + for (let i = len - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } +} +/** + * Check for internet connection + * + * @remarks + * + * This will at least ensure the LAN is connected. + * May get false positives for other errors. + * + * @throws An error if no internet connection + */ +export function checkNetworkConnection() { + if (!navigator.onLine) { + throw new Error(ChromeLocale.localize('err_no_internet', 'Internet disconnected')); + } +} +/** + * Wait for the specified time + * + * @param time - wait time in milliSecs + */ +export async function wait(time) { + const waiter = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + return await waiter(time); +} +/** + * Determine if we are a given operating system + * + * @param os - os short name + * @returns true if the given os + */ +async function isOS(os) { + try { + const info = await chromep.runtime.getPlatformInfo(); + return (info.os === os); + } + catch (e) { + // something went wrong - linux seems to fail this call sometimes + return false; + } +}