// utils.js is the first file required by our background scripts and content scripts. When running tests, the // environment is nodejs, which has a `global` object that defines the global scope. In the browser, that // global scope is `window`. The rest of our code attaches exported functions into this global scope. // We don't use ES modules because they don't work in Chrome extension content scripts. if (typeof(global) == "undefined") global = window; // Only pass events to the handler if they are marked as trusted by the browser. // This is kept in the global namespace for brevity and ease of use. if (window.forTrusted == null) { window.forTrusted = handler => (function(event) { if (event && event.isTrusted) { return handler.apply(this, arguments); } else { return true; } }); } // TODO(philc): The comment below about its usage doesn't make sense to me. Can we replace this with // window.navigator? let browserInfo = null; if (window.browser && browser.runtime && browser.runtime.getBrowserInfo) browserInfo = browser.runtime.getBrowserInfo(); var Utils = { isFirefox: (function() { // We want this browser check to also cover Firefox variants, like LibreWolf. See #3773. const isFirefox = typeof InstallTrigger !== 'undefined'; return () => isFirefox; })(), firefoxVersion: (function() { // NOTE(mrmr1993): This only works in the background page. let ffVersion = undefined; if (browserInfo) { browserInfo.then(browserInfo => ffVersion = browserInfo != null ? browserInfo.version : undefined); } return () => ffVersion; })(), getCurrentVersion() { return chrome.runtime.getManifest().version; }, // Returns true whenever the current page (or the page supplied as an argument) is from the extension's // origin (and thus can access the extension's localStorage). isExtensionPage(win) { if (win == null) { win = window; } try { return ((win.document.location != null ? win.document.location.origin : undefined) + "/") === chrome.extension.getURL(""); } catch (error) {} }, // Returns true whenever the current page is the extension's background page. isBackgroundPage() { // NOTE(philc): chrome.extension.getBackgroundPage is undefined under some circumstances, but I wasn't // able to determine precisely which. return this.isBackgroundPage && chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage() === window; }, // Escape all special characters, so RegExp will parse the string 'as is'. // Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex escapeRegexSpecialCharacters: (function() { const escapeRegex = /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g; return str => str.replace(escapeRegex, "\\$&"); })(), escapeHtml(string) { return string.replace(//g, ">"); }, // Generates a unique ID createUniqueId: (function() { let id = 0; return () => id += 1; })(), hasChromePrefix: (function() { const chromePrefixes = ["about:", "view-source:", "extension:", "chrome-extension:", "data:"]; return url => chromePrefixes.some(prefix => url.startsWith(prefix)); })(), hasJavascriptPrefix(url) { return url.startsWith("javascript:"); }, hasFullUrlPrefix: (function() { const urlPrefix = new RegExp("^[a-z][-+.a-z0-9]{2,}://."); return url => urlPrefix.test(url); })(), // Decode valid escape sequences in a URI. This is intended to mimic the best-effort decoding // Chrome itself seems to apply when a Javascript URI is enetered into the omnibox (or clicked). // See https://code.google.com/p/chromium/issues/detail?id=483000, #1611 and #1636. decodeURIByParts(uri) { return uri.split(/(?=%)/).map(function(uriComponent) { try { return decodeURIComponent(uriComponent); } catch (error) { return uriComponent; } }).join(""); }, // Completes a partial URL (without scheme) createFullUrl(partialUrl) { if (this.hasFullUrlPrefix(partialUrl)) return partialUrl; else return ("http://" + partialUrl); }, // Tries to detect if :str is a valid URL. isUrl(str) { // Must not contain spaces if (str.includes(' ')) { return false; } // Starts with a scheme: URL if (this.hasFullUrlPrefix(str)) { return true; } // More or less RFC compliant URL host part parsing. This should be sufficient for our needs const urlRegex = new RegExp( '^(?:([^:]+)(?::([^:]+))?@)?' + // user:password (optional) => \1, \2 '([^:]+|\\[[^\\]]+\\])' + // host name (IPv6 addresses in square brackets allowed) => \3 '(?::(\\d+))?$' // port number (optional) => \4 ); // Official ASCII TLDs that are longer than 3 characters + inofficial .onion TLD used by TOR const longTlds = ['arpa', 'asia', 'coop', 'info', 'jobs', 'local', 'mobi', 'museum', 'name', 'onion']; const specialHostNames = ['localhost']; // Try to parse the URL into its meaningful parts. If matching fails we're pretty sure that we don't have // some kind of URL here. const match = urlRegex.exec((str.split('/'))[0]); if (!match) { return false; } const hostName = match[3]; // Allow known special host names if (specialHostNames.includes(hostName)) { return true; } // Allow IPv6 addresses (need to be wrapped in brackets as required by RFC). It is sufficient to check for // a colon, as the regex wouldn't match colons in the host name unless it's an v6 address if (hostName.includes(':')) { return true; } // At this point we have to make a decision. As a heuristic, we check if the input has dots in it. If yes, // and if the last part could be a TLD, treat it as an URL const dottedParts = hostName.split('.'); if (dottedParts.length > 1) { const lastPart = dottedParts.pop(); if ((2 <= lastPart.length && lastPart.length <= 3) || longTlds.includes(lastPart)) { return true; } } // Allow IPv4 addresses if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostName)) { return true; } // Fallback: no URL return false; }, // Map a search query to its URL encoded form. The query may be either a string or an array of strings. // E.g. "BBC Sport" -> "BBC+Sport". createSearchQuery(query) { if (typeof(query) === "string") { query = query.split(/\s+/); } return query.map(encodeURIComponent).join("+"); }, // Create a search URL from the given :query (using either the provided search URL, or the default one). // It would be better to pull the default search engine from chrome itself. However, chrome does not provide // an API for doing so. createSearchUrl(query, searchUrl) { if (searchUrl == null) { searchUrl = Settings.get("searchUrl"); } if (!['%s', '%S'].some(token => searchUrl.indexOf(token) >= 0)) { searchUrl += "%s"; } searchUrl = searchUrl.replace(/%S/g, query); return searchUrl.replace(/%s/g, this.createSearchQuery(query)); }, // Extract a query from url if it appears to be a URL created from the given search URL. // For example, map "https://www.google.ie/search?q=star+wars&foo&bar" to "star wars". extractQuery: (() => { const queryTerminator = new RegExp("[?&#/]"); const httpProtocolRegexp = new RegExp("^https?://"); return function(searchUrl, url) { let suffixTerms; url = url.replace(httpProtocolRegexp); searchUrl = searchUrl.replace(httpProtocolRegexp); [ searchUrl, ...suffixTerms ] = searchUrl.split("%s"); // We require the URL to start with the search URL. if (!url.startsWith(searchUrl)) { return null; } // We require any remaining terms in the search URL to also be present in the URL. for (let suffix of suffixTerms) { if (!(0 <= url.indexOf(suffix))) { return null; } } // We use try/catch because decodeURIComponent can throw an exception. try { return url.slice(searchUrl.length).split(queryTerminator)[0].split("+").map(decodeURIComponent).join(" "); } catch (error) { return null; } }; })(), // Converts :string into a Google search if it's not already a URL. We don't bother with escaping characters // as Chrome will do that for us. convertToUrl(string) { string = string.trim(); // Special-case about:[url], view-source:[url] and the like if (Utils.hasChromePrefix(string)) { return string; } else if (Utils.hasJavascriptPrefix(string)) { // In Chrome versions older than 46.0.2467.2, encoded javascript URIs weren't handled correctly. if (Utils.haveChromeVersion("46.0.2467.2")) return string; else return Utils.decodeURIByParts(string); } else if (Utils.isUrl(string)) { return Utils.createFullUrl(string); } else { return Utils.createSearchUrl(string); } }, // detects both literals and dynamically created strings isString(obj) { return (typeof obj === 'string') || obj instanceof String; }, // Transform "zjkjkabz" into "abjkz". distinctCharacters(str) { const chars = str.split(""); return Array.from(new Set(chars)).sort().join(""); }, // Compares two version strings (e.g. "1.1" and "1.5") and returns // -1 if versionA is < versionB, 0 if they're equal, and 1 if versionA is > versionB. compareVersions(versionA, versionB) { versionA = versionA.split("."); versionB = versionB.split("."); for (let i = 0, end = Math.max(versionA.length, versionB.length); i < end; i++) { const a = parseInt(versionA[i] || 0, 10); const b = parseInt(versionB[i] || 0, 10); if (a < b) { return -1; } else if (a > b) { return 1; } } return 0; }, // True if the current Chrome version is at least the required version. haveChromeVersion(required) { const match = navigator.appVersion.match(/Chrom(e|ium)\/(.*?) /); const chromeVersion = match ? match[2] : null; return chromeVersion && (0 <= Utils.compareVersions(chromeVersion, required)); }, // Zip two (or more) arrays: // - Utils.zip([ [a,b], [1,2] ]) returns [ [a,1], [b,2] ] // - Length of result is `arrays[0].length`. // - Adapted from: http://stackoverflow.com/questions/4856717/javascript-equivalent-of-pythons-zip-function zip(arrays) { return arrays[0].map((_, i) => arrays.map(array => array[i])); }, // locale-sensitive uppercase detection hasUpperCase(s) { return s.toLowerCase() !== s; }, // Does string match any of these regexps? matchesAnyRegexp(regexps, string) { for (let re of regexps) { if (re.test(string)) { return true; } } return false; }, // Convenience wrapper for setTimeout (with the arguments around the other way). setTimeout(ms, func) { return setTimeout(func, ms); }, // Like Nodejs's nextTick. nextTick(func) { return this.setTimeout(0, func); }, // Make an idempotent function. makeIdempotent(func) { return function(...args) { let previousFunc, ref; const result = ([previousFunc, func] = Array.from(ref = [func, null]), ref)[0]; if (result) { return result(...Array.from(args || [])); } }; }, monitorChromeStorage(key, setter) { return chrome.storage.local.get(key, obj => { if (obj[key] != null) { setter(obj[key]); } return chrome.storage.onChanged.addListener((changes, area) => { if (changes[key] && (changes[key].newValue !== undefined)) { return setter(changes[key].newValue); } }); }); } }; // This creates a new function out of an existing function, where the new function takes fewer arguments. This // allows us to pass around functions instead of functions + a partial list of arguments. Function.prototype.curry = function() { const fixedArguments = Array.copy(arguments); const fn = this; return function() { return fn.apply(this, fixedArguments.concat(Array.copy(arguments))); }; }; Array.copy = array => Array.prototype.slice.call(array, 0); String.prototype.reverse = function() { return this.split("").reverse().join(""); }; // A simple cache. Entries used within two expiry periods are retained, otherwise they are discarded. // At most 2 * @entries entries are retained. class SimpleCache { // expiry: expiry time in milliseconds (default, one hour) // entries: maximum number of entries in @cache (there may be up to this many entries in @previous, too) constructor(expiry, entries) { if (expiry == null) { expiry = 60 * 60 * 1000; } this.expiry = expiry; if (entries == null) { entries = 1000; } this.entries = entries; this.cache = {}; this.previous = {}; this.lastRotation = new Date(); } has(key) { this.rotate(); return (key in this.cache) || key in this.previous; } // Set value, and return that value. If value is null, then delete key. set(key, value = null) { this.rotate(); delete this.previous[key]; if (value != null) { return this.cache[key] = value; } else { delete this.cache[key]; return null; } } get(key) { this.rotate(); if (key in this.cache) { return this.cache[key]; } else if (key in this.previous) { this.cache[key] = this.previous[key]; delete this.previous[key]; return this.cache[key]; } else { return null; } } rotate(force) { if (force == null) { force = false; } Utils.nextTick(() => { if (force || (this.entries < Object.keys(this.cache).length) || (this.expiry < (new Date() - this.lastRotation))) { this.lastRotation = new Date(); this.previous = this.cache; return this.cache = {}; } }); } clear() { this.rotate(true); return this.rotate(true); } } // This is a simple class for the common case where we want to use some data value which may be immediately // available, or for which we may have to wait. It implements a use-immediately-or-wait queue, and calls the // fetch function to fetch the data asynchronously. class AsyncDataFetcher { constructor(fetch) { this.data = null; this.queue = []; Utils.nextTick(() => { return fetch(data => { this.data = data; for (let callback of this.queue) { callback(this.data); } return this.queue = null; }); }); } use(callback) { if (this.data != null) { return callback(this.data); } else { return this.queue.push(callback); } } } // This takes a list of jobs (functions) and runs them, asynchronously. Functions queued with @onReady() are // run once all of the jobs have completed. class JobRunner { constructor(jobs) { this.jobs = jobs; this.fetcher = new AsyncDataFetcher(callback => { return this.jobs.map((job) => (job => { Utils.nextTick(() => { return job(() => { this.jobs = this.jobs.filter(j => j !== job); if (this.jobs.length === 0) { return callback(true); } }); }); return null; })(job)); }); } onReady(callback) { return this.fetcher.use(callback); } } Object.assign(global, {Utils, SimpleCache, AsyncDataFetcher, JobRunner});