diff options
author | Matt A. Tobin <email@mattatobin.com> | 2018-02-09 11:10:00 -0500 |
---|---|---|
committer | Matt A. Tobin <email@mattatobin.com> | 2018-02-09 11:10:00 -0500 |
commit | f164d9124708b50789dbb6959e1de96cc5697c48 (patch) | |
tree | 6dffd12e08c5383130df0252fb69cd6d6330794f /toolkit/components/webextensions/ExtensionUtils.jsm | |
parent | 30de4018913f0cdaea19d1dd12ecd8209e2ed08e (diff) | |
download | UXP-f164d9124708b50789dbb6959e1de96cc5697c48.tar UXP-f164d9124708b50789dbb6959e1de96cc5697c48.tar.gz UXP-f164d9124708b50789dbb6959e1de96cc5697c48.tar.lz UXP-f164d9124708b50789dbb6959e1de96cc5697c48.tar.xz UXP-f164d9124708b50789dbb6959e1de96cc5697c48.zip |
Rename Toolkit's webextensions component directory to better reflect what it is.
Diffstat (limited to 'toolkit/components/webextensions/ExtensionUtils.jsm')
-rw-r--r-- | toolkit/components/webextensions/ExtensionUtils.jsm | 1215 |
1 files changed, 1215 insertions, 0 deletions
diff --git a/toolkit/components/webextensions/ExtensionUtils.jsm b/toolkit/components/webextensions/ExtensionUtils.jsm new file mode 100644 index 000000000..e7f768c07 --- /dev/null +++ b/toolkit/components/webextensions/ExtensionUtils.jsm @@ -0,0 +1,1215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ExtensionUtils"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +const INTEGER = /^[1-9]\d*$/; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ConsoleAPI", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector", + "resource:///modules/translation/LanguageDetector.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Locale", + "resource://gre/modules/Locale.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel", + "resource://gre/modules/MessageChannel.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "styleSheetService", + "@mozilla.org/content/style-sheet-service;1", + "nsIStyleSheetService"); + +function getConsole() { + return new ConsoleAPI({ + maxLogLevelPref: "extensions.webextensions.log.level", + prefix: "WebExtensions", + }); +} + +XPCOMUtils.defineLazyGetter(this, "console", getConsole); + +let nextId = 0; +const {uniqueProcessID} = Services.appinfo; + +function getUniqueId() { + return `${nextId++}-${uniqueProcessID}`; +} + +/** + * An Error subclass for which complete error messages are always passed + * to extensions, rather than being interpreted as an unknown error. + */ +class ExtensionError extends Error {} + +function filterStack(error) { + return String(error.stack).replace(/(^.*(Task\.jsm|Promise-backend\.js).*\n)+/gm, "<Promise Chain>\n"); +} + +// Run a function and report exceptions. +function runSafeSyncWithoutClone(f, ...args) { + try { + return f(...args); + } catch (e) { + dump(`Extension error: ${e} ${e.fileName} ${e.lineNumber}\n[[Exception stack\n${filterStack(e)}Current stack\n${filterStack(Error())}]]\n`); + Cu.reportError(e); + } +} + +// Run a function and report exceptions. +function runSafeWithoutClone(f, ...args) { + if (typeof(f) != "function") { + dump(`Extension error: expected function\n${filterStack(Error())}`); + return; + } + + Promise.resolve().then(() => { + runSafeSyncWithoutClone(f, ...args); + }); +} + +// Run a function, cloning arguments into context.cloneScope, and +// report exceptions. |f| is expected to be in context.cloneScope. +function runSafeSync(context, f, ...args) { + if (context.unloaded) { + Cu.reportError("runSafeSync called after context unloaded"); + return; + } + + try { + args = Cu.cloneInto(args, context.cloneScope); + } catch (e) { + Cu.reportError(e); + dump(`runSafe failure: cloning into ${context.cloneScope}: ${e}\n\n${filterStack(Error())}`); + } + return runSafeSyncWithoutClone(f, ...args); +} + +// Run a function, cloning arguments into context.cloneScope, and +// report exceptions. |f| is expected to be in context.cloneScope. +function runSafe(context, f, ...args) { + try { + args = Cu.cloneInto(args, context.cloneScope); + } catch (e) { + Cu.reportError(e); + dump(`runSafe failure: cloning into ${context.cloneScope}: ${e}\n\n${filterStack(Error())}`); + } + if (context.unloaded) { + dump(`runSafe failure: context is already unloaded ${filterStack(new Error())}\n`); + return undefined; + } + return runSafeWithoutClone(f, ...args); +} + +function getInnerWindowID(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .currentInnerWindowID; +} + +// Return true if the given value is an instance of the given +// native type. +function instanceOf(value, type) { + return {}.toString.call(value) == `[object ${type}]`; +} + +// Extend the object |obj| with the property descriptors of each object in +// |args|. +function extend(obj, ...args) { + for (let arg of args) { + let props = [...Object.getOwnPropertyNames(arg), + ...Object.getOwnPropertySymbols(arg)]; + for (let prop of props) { + let descriptor = Object.getOwnPropertyDescriptor(arg, prop); + Object.defineProperty(obj, prop, descriptor); + } + } + + return obj; +} + +/** + * Similar to a WeakMap, but creates a new key with the given + * constructor if one is not present. + */ +class DefaultWeakMap extends WeakMap { + constructor(defaultConstructor, init) { + super(init); + this.defaultConstructor = defaultConstructor; + } + + get(key) { + if (!this.has(key)) { + this.set(key, this.defaultConstructor(key)); + } + return super.get(key); + } +} + +class DefaultMap extends Map { + constructor(defaultConstructor, init) { + super(init); + this.defaultConstructor = defaultConstructor; + } + + get(key) { + if (!this.has(key)) { + this.set(key, this.defaultConstructor(key)); + } + return super.get(key); + } +} + +class SpreadArgs extends Array { + constructor(args) { + super(); + this.push(...args); + } +} + +// Manages icon details for toolbar buttons in the |pageAction| and +// |browserAction| APIs. +let IconDetails = { + // Normalizes the various acceptable input formats into an object + // with icon size as key and icon URL as value. + // + // If a context is specified (function is called from an extension): + // Throws an error if an invalid icon size was provided or the + // extension is not allowed to load the specified resources. + // + // If no context is specified, instead of throwing an error, this + // function simply logs a warning message. + normalize(details, extension, context = null) { + let result = {}; + + try { + if (details.imageData) { + let imageData = details.imageData; + + if (typeof imageData == "string") { + imageData = {"19": imageData}; + } + + for (let size of Object.keys(imageData)) { + if (!INTEGER.test(size)) { + throw new ExtensionError(`Invalid icon size ${size}, must be an integer`); + } + result[size] = imageData[size]; + } + } + + if (details.path) { + let path = details.path; + if (typeof path != "object") { + path = {"19": path}; + } + + let baseURI = context ? context.uri : extension.baseURI; + + for (let size of Object.keys(path)) { + if (!INTEGER.test(size)) { + throw new ExtensionError(`Invalid icon size ${size}, must be an integer`); + } + + let url = baseURI.resolve(path[size]); + + // The Chrome documentation specifies these parameters as + // relative paths. We currently accept absolute URLs as well, + // which means we need to check that the extension is allowed + // to load them. This will throw an error if it's not allowed. + try { + Services.scriptSecurityManager.checkLoadURIStrWithPrincipal( + extension.principal, url, + Services.scriptSecurityManager.DISALLOW_SCRIPT); + } catch (e) { + throw new ExtensionError(`Illegal URL ${url}`); + } + + result[size] = url; + } + } + } catch (e) { + // Function is called from extension code, delegate error. + if (context) { + throw e; + } + // If there's no context, it's because we're handling this + // as a manifest directive. Log a warning rather than + // raising an error. + extension.manifestError(`Invalid icon data: ${e}`); + } + + return result; + }, + + // Returns the appropriate icon URL for the given icons object and the + // screen resolution of the given window. + getPreferredIcon(icons, extension = null, size = 16) { + const DEFAULT = "chrome://browser/content/extension.svg"; + + let bestSize = null; + if (icons[size]) { + bestSize = size; + } else if (icons[2 * size]) { + bestSize = 2 * size; + } else { + let sizes = Object.keys(icons) + .map(key => parseInt(key, 10)) + .sort((a, b) => a - b); + + bestSize = sizes.find(candidate => candidate > size) || sizes.pop(); + } + + if (bestSize) { + return {size: bestSize, icon: icons[bestSize]}; + } + + return {size, icon: DEFAULT}; + }, + + convertImageURLToDataURL(imageURL, contentWindow, browserWindow, size = 18) { + return new Promise((resolve, reject) => { + let image = new contentWindow.Image(); + image.onload = function() { + let canvas = contentWindow.document.createElement("canvas"); + let ctx = canvas.getContext("2d"); + let dSize = size * browserWindow.devicePixelRatio; + + // Scales the image while maintaing width to height ratio. + // If the width and height differ, the image is centered using the + // smaller of the two dimensions. + let dWidth, dHeight, dx, dy; + if (this.width > this.height) { + dWidth = dSize; + dHeight = image.height * (dSize / image.width); + dx = 0; + dy = (dSize - dHeight) / 2; + } else { + dWidth = image.width * (dSize / image.height); + dHeight = dSize; + dx = (dSize - dWidth) / 2; + dy = 0; + } + + ctx.drawImage(this, 0, 0, this.width, this.height, dx, dy, dWidth, dHeight); + resolve(canvas.toDataURL("image/png")); + }; + image.onerror = reject; + image.src = imageURL; + }); + }, +}; + +const LISTENERS = Symbol("listeners"); + +class EventEmitter { + constructor() { + this[LISTENERS] = new Map(); + } + + /** + * Adds the given function as a listener for the given event. + * + * The listener function may optionally return a Promise which + * resolves when it has completed all operations which event + * dispatchers may need to block on. + * + * @param {string} event + * The name of the event to listen for. + * @param {function(string, ...any)} listener + * The listener to call when events are emitted. + */ + on(event, listener) { + if (!this[LISTENERS].has(event)) { + this[LISTENERS].set(event, new Set()); + } + + this[LISTENERS].get(event).add(listener); + } + + /** + * Removes the given function as a listener for the given event. + * + * @param {string} event + * The name of the event to stop listening for. + * @param {function(string, ...any)} listener + * The listener function to remove. + */ + off(event, listener) { + if (this[LISTENERS].has(event)) { + let set = this[LISTENERS].get(event); + + set.delete(listener); + if (!set.size) { + this[LISTENERS].delete(event); + } + } + } + + /** + * Triggers all listeners for the given event, and returns a promise + * which resolves when all listeners have been called, and any + * promises they have returned have likewise resolved. + * + * @param {string} event + * The name of the event to emit. + * @param {any} args + * Arbitrary arguments to pass to the listener functions, after + * the event name. + * @returns {Promise} + */ + emit(event, ...args) { + let listeners = this[LISTENERS].get(event) || new Set(); + + let promises = Array.from(listeners, listener => { + return runSafeSyncWithoutClone(listener, event, ...args); + }); + + return Promise.all(promises); + } +} + +function LocaleData(data) { + this.defaultLocale = data.defaultLocale; + this.selectedLocale = data.selectedLocale; + this.locales = data.locales || new Map(); + this.warnedMissingKeys = new Set(); + + // Map(locale-name -> Map(message-key -> localized-string)) + // + // Contains a key for each loaded locale, each of which is a + // Map of message keys to their localized strings. + this.messages = data.messages || new Map(); + + if (data.builtinMessages) { + this.messages.set(this.BUILTIN, data.builtinMessages); + } +} + + +LocaleData.prototype = { + // Representation of the object to send to content processes. This + // should include anything the content process might need. + serialize() { + return { + defaultLocale: this.defaultLocale, + selectedLocale: this.selectedLocale, + messages: this.messages, + locales: this.locales, + }; + }, + + BUILTIN: "@@BUILTIN_MESSAGES", + + has(locale) { + return this.messages.has(locale); + }, + + // https://developer.chrome.com/extensions/i18n + localizeMessage(message, substitutions = [], options = {}) { + let defaultOptions = { + locale: this.selectedLocale, + defaultValue: "", + cloneScope: null, + }; + + options = Object.assign(defaultOptions, options); + + let locales = new Set([this.BUILTIN, options.locale, this.defaultLocale] + .filter(locale => this.messages.has(locale))); + + // Message names are case-insensitive, so normalize them to lower-case. + message = message.toLowerCase(); + for (let locale of locales) { + let messages = this.messages.get(locale); + if (messages.has(message)) { + let str = messages.get(message); + + if (!Array.isArray(substitutions)) { + substitutions = [substitutions]; + } + + let replacer = (matched, index, dollarSigns) => { + if (index) { + // This is not quite Chrome-compatible. Chrome consumes any number + // of digits following the $, but only accepts 9 substitutions. We + // accept any number of substitutions. + index = parseInt(index, 10) - 1; + return index in substitutions ? substitutions[index] : ""; + } + // For any series of contiguous `$`s, the first is dropped, and + // the rest remain in the output string. + return dollarSigns; + }; + return str.replace(/\$(?:([1-9]\d*)|(\$+))/g, replacer); + } + } + + // Check for certain pre-defined messages. + if (message == "@@ui_locale") { + return this.uiLocale; + } else if (message.startsWith("@@bidi_")) { + let registry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry); + let rtl = registry.isLocaleRTL("global"); + + if (message == "@@bidi_dir") { + return rtl ? "rtl" : "ltr"; + } else if (message == "@@bidi_reversed_dir") { + return rtl ? "ltr" : "rtl"; + } else if (message == "@@bidi_start_edge") { + return rtl ? "right" : "left"; + } else if (message == "@@bidi_end_edge") { + return rtl ? "left" : "right"; + } + } + + if (!this.warnedMissingKeys.has(message)) { + let error = `Unknown localization message ${message}`; + if (options.cloneScope) { + error = new options.cloneScope.Error(error); + } + Cu.reportError(error); + this.warnedMissingKeys.add(message); + } + return options.defaultValue; + }, + + // Localize a string, replacing all |__MSG_(.*)__| tokens with the + // matching string from the current locale, as determined by + // |this.selectedLocale|. + // + // This may not be called before calling either |initLocale| or + // |initAllLocales|. + localize(str, locale = this.selectedLocale) { + if (!str) { + return str; + } + + return str.replace(/__MSG_([A-Za-z0-9@_]+?)__/g, (matched, message) => { + return this.localizeMessage(message, [], {locale, defaultValue: matched}); + }); + }, + + // Validates the contents of a locale JSON file, normalizes the + // messages into a Map of message key -> localized string pairs. + addLocale(locale, messages, extension) { + let result = new Map(); + + // Chrome does not document the semantics of its localization + // system very well. It handles replacements by pre-processing + // messages, replacing |$[a-zA-Z0-9@_]+$| tokens with the value of their + // replacements. Later, it processes the resulting string for + // |$[0-9]| replacements. + // + // Again, it does not document this, but it accepts any number + // of sequential |$|s, and replaces them with that number minus + // 1. It also accepts |$| followed by any number of sequential + // digits, but refuses to process a localized string which + // provides more than 9 substitutions. + if (!instanceOf(messages, "Object")) { + extension.packagingError(`Invalid locale data for ${locale}`); + return result; + } + + for (let key of Object.keys(messages)) { + let msg = messages[key]; + + if (!instanceOf(msg, "Object") || typeof(msg.message) != "string") { + extension.packagingError(`Invalid locale message data for ${locale}, message ${JSON.stringify(key)}`); + continue; + } + + // Substitutions are case-insensitive, so normalize all of their names + // to lower-case. + let placeholders = new Map(); + if (instanceOf(msg.placeholders, "Object")) { + for (let key of Object.keys(msg.placeholders)) { + placeholders.set(key.toLowerCase(), msg.placeholders[key]); + } + } + + let replacer = (match, name) => { + let replacement = placeholders.get(name.toLowerCase()); + if (instanceOf(replacement, "Object") && "content" in replacement) { + return replacement.content; + } + return ""; + }; + + let value = msg.message.replace(/\$([A-Za-z0-9@_]+)\$/g, replacer); + + // Message names are also case-insensitive, so normalize them to lower-case. + result.set(key.toLowerCase(), value); + } + + this.messages.set(locale, result); + return result; + }, + + get acceptLanguages() { + let result = Preferences.get("intl.accept_languages", "", Ci.nsIPrefLocalizedString); + return result.split(/\s*,\s*/g); + }, + + + get uiLocale() { + // Return the browser locale, but convert it to a Chrome-style + // locale code. + return Locale.getLocale().replace(/-/g, "_"); + }, +}; + +// This is a generic class for managing event listeners. Example usage: +// +// new EventManager(context, "api.subAPI", fire => { +// let listener = (...) => { +// // Fire any listeners registered with addListener. +// fire(arg1, arg2); +// }; +// // Register the listener. +// SomehowRegisterListener(listener); +// return () => { +// // Return a way to unregister the listener. +// SomehowUnregisterListener(listener); +// }; +// }).api() +// +// The result is an object with addListener, removeListener, and +// hasListener methods. |context| is an add-on scope (either an +// ExtensionContext in the chrome process or ExtensionContext in a +// content process). |name| is for debugging. |register| is a function +// to register the listener. |register| is only called once, even if +// multiple listeners are registered. |register| should return an +// unregister function that will unregister the listener. +function EventManager(context, name, register) { + this.context = context; + this.name = name; + this.register = register; + this.unregister = null; + this.callbacks = new Set(); +} + +EventManager.prototype = { + addListener(callback) { + if (typeof(callback) != "function") { + dump(`Expected function\n${Error().stack}`); + return; + } + if (this.context.unloaded) { + dump(`Cannot add listener to ${this.name} after context unloaded`); + return; + } + + if (!this.callbacks.size) { + this.context.callOnClose(this); + + let fireFunc = this.fire.bind(this); + let fireWithoutClone = this.fireWithoutClone.bind(this); + fireFunc.withoutClone = fireWithoutClone; + this.unregister = this.register(fireFunc); + } + this.callbacks.add(callback); + }, + + removeListener(callback) { + if (!this.callbacks.size) { + return; + } + + this.callbacks.delete(callback); + if (this.callbacks.size == 0) { + this.unregister(); + this.unregister = null; + + this.context.forgetOnClose(this); + } + }, + + hasListener(callback) { + return this.callbacks.has(callback); + }, + + fire(...args) { + this._fireCommon("runSafe", args); + }, + + fireWithoutClone(...args) { + this._fireCommon("runSafeWithoutClone", args); + }, + + _fireCommon(runSafeMethod, args) { + for (let callback of this.callbacks) { + Promise.resolve(callback).then(callback => { + if (this.context.unloaded) { + dump(`${this.name} event fired after context unloaded.\n`); + } else if (!this.context.active) { + dump(`${this.name} event fired while context is inactive.\n`); + } else if (this.callbacks.has(callback)) { + this.context[runSafeMethod](callback, ...args); + } + }); + } + }, + + close() { + if (this.callbacks.size) { + this.unregister(); + } + this.callbacks.clear(); + this.register = null; + this.unregister = null; + }, + + api() { + return { + addListener: callback => this.addListener(callback), + removeListener: callback => this.removeListener(callback), + hasListener: callback => this.hasListener(callback), + }; + }, +}; + +// Similar to EventManager, but it doesn't try to consolidate event +// notifications. Each addListener call causes us to register once. It +// allows extra arguments to be passed to addListener. +function SingletonEventManager(context, name, register) { + this.context = context; + this.name = name; + this.register = register; + this.unregister = new Map(); +} + +SingletonEventManager.prototype = { + addListener(callback, ...args) { + let wrappedCallback = (...args) => { + if (this.context.unloaded) { + dump(`${this.name} event fired after context unloaded.\n`); + } else if (this.unregister.has(callback)) { + return callback(...args); + } + }; + + let unregister = this.register(wrappedCallback, ...args); + this.unregister.set(callback, unregister); + this.context.callOnClose(this); + }, + + removeListener(callback) { + if (!this.unregister.has(callback)) { + return; + } + + let unregister = this.unregister.get(callback); + this.unregister.delete(callback); + unregister(); + }, + + hasListener(callback) { + return this.unregister.has(callback); + }, + + close() { + for (let unregister of this.unregister.values()) { + unregister(); + } + }, + + api() { + return { + addListener: (...args) => this.addListener(...args), + removeListener: (...args) => this.removeListener(...args), + hasListener: (...args) => this.hasListener(...args), + }; + }, +}; + +// Simple API for event listeners where events never fire. +function ignoreEvent(context, name) { + return { + addListener: function(callback) { + let id = context.extension.id; + let frame = Components.stack.caller; + let msg = `In add-on ${id}, attempting to use listener "${name}", which is unimplemented.`; + let scriptError = Cc["@mozilla.org/scripterror;1"] + .createInstance(Ci.nsIScriptError); + scriptError.init(msg, frame.filename, null, frame.lineNumber, + frame.columnNumber, Ci.nsIScriptError.warningFlag, + "content javascript"); + let consoleService = Cc["@mozilla.org/consoleservice;1"] + .getService(Ci.nsIConsoleService); + consoleService.logMessage(scriptError); + }, + removeListener: function(callback) {}, + hasListener: function(callback) {}, + }; +} + +// Copy an API object from |source| into the scope |dest|. +function injectAPI(source, dest) { + for (let prop in source) { + // Skip names prefixed with '_'. + if (prop[0] == "_") { + continue; + } + + let desc = Object.getOwnPropertyDescriptor(source, prop); + if (typeof(desc.value) == "function") { + Cu.exportFunction(desc.value, dest, {defineAs: prop}); + } else if (typeof(desc.value) == "object") { + let obj = Cu.createObjectIn(dest, {defineAs: prop}); + injectAPI(desc.value, obj); + } else { + Object.defineProperty(dest, prop, desc); + } + } +} + +/** + * Returns a Promise which resolves when the given document's DOM has + * fully loaded. + * + * @param {Document} doc The document to await the load of. + * @returns {Promise<Document>} + */ +function promiseDocumentReady(doc) { + if (doc.readyState == "interactive" || doc.readyState == "complete") { + return Promise.resolve(doc); + } + + return new Promise(resolve => { + doc.addEventListener("DOMContentLoaded", function onReady(event) { + if (event.target === event.currentTarget) { + doc.removeEventListener("DOMContentLoaded", onReady, true); + resolve(doc); + } + }, true); + }); +} + +/** + * Returns a Promise which resolves when the given document is fully + * loaded. + * + * @param {Document} doc The document to await the load of. + * @returns {Promise<Document>} + */ +function promiseDocumentLoaded(doc) { + if (doc.readyState == "complete") { + return Promise.resolve(doc); + } + + return new Promise(resolve => { + doc.defaultView.addEventListener("load", function onReady(event) { + doc.defaultView.removeEventListener("load", onReady); + resolve(doc); + }); + }); +} + +/** + * Returns a Promise which resolves when the given event is dispatched to the + * given element. + * + * @param {Element} element + * The element on which to listen. + * @param {string} eventName + * The event to listen for. + * @param {boolean} [useCapture = true] + * If true, listen for the even in the capturing rather than + * bubbling phase. + * @param {Event} [test] + * An optional test function which, when called with the + * observer's subject and data, should return true if this is the + * expected event, false otherwise. + * @returns {Promise<Event>} + */ +function promiseEvent(element, eventName, useCapture = true, test = event => true) { + return new Promise(resolve => { + function listener(event) { + if (test(event)) { + element.removeEventListener(eventName, listener, useCapture); + resolve(event); + } + } + element.addEventListener(eventName, listener, useCapture); + }); +} + +/** + * Returns a Promise which resolves the given observer topic has been + * observed. + * + * @param {string} topic + * The topic to observe. + * @param {function(nsISupports, string)} [test] + * An optional test function which, when called with the + * observer's subject and data, should return true if this is the + * expected notification, false otherwise. + * @returns {Promise<object>} + */ +function promiseObserved(topic, test = () => true) { + return new Promise(resolve => { + let observer = (subject, topic, data) => { + if (test(subject, data)) { + Services.obs.removeObserver(observer, topic); + resolve({subject, data}); + } + }; + Services.obs.addObserver(observer, topic, false); + }); +} + +function getMessageManager(target) { + if (target instanceof Ci.nsIFrameLoaderOwner) { + return target.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader.messageManager; + } + return target.QueryInterface(Ci.nsIMessageSender); +} + +function flushJarCache(jarFile) { + Services.obs.notifyObservers(jarFile, "flush-cache-entry", null); +} + +const PlatformInfo = Object.freeze({ + os: (function() { + let os = AppConstants.platform; + if (os == "macosx") { + os = "mac"; + } + return os; + })(), + arch: (function() { + let abi = Services.appinfo.XPCOMABI; + let [arch] = abi.split("-"); + if (arch == "x86") { + arch = "x86-32"; + } else if (arch == "x86_64") { + arch = "x86-64"; + } + return arch; + })(), +}); + +function detectLanguage(text) { + return LanguageDetector.detectLanguage(text).then(result => ({ + isReliable: result.confident, + languages: result.languages.map(lang => { + return { + language: lang.languageCode, + percentage: lang.percent, + }; + }), + })); +} + +/** + * Convert any of several different representations of a date/time to a Date object. + * Accepts several formats: + * a Date object, an ISO8601 string, or a number of milliseconds since the epoch as + * either a number or a string. + * + * @param {Date|string|number} date + * The date to convert. + * @returns {Date} + * A Date object + */ +function normalizeTime(date) { + // Of all the formats we accept the "number of milliseconds since the epoch as a string" + // is an outlier, everything else can just be passed directly to the Date constructor. + return new Date((typeof date == "string" && /^\d+$/.test(date)) + ? parseInt(date, 10) : date); +} + +const stylesheetMap = new DefaultMap(url => { + let uri = NetUtil.newURI(url); + return styleSheetService.preloadSheet(uri, styleSheetService.AGENT_SHEET); +}); + +/** + * Defines a lazy getter for the given property on the given object. The + * first time the property is accessed, the return value of the getter + * is defined on the current `this` object with the given property name. + * Importantly, this means that a lazy getter defined on an object + * prototype will be invoked separately for each object instance that + * it's accessed on. + * + * @param {object} object + * The prototype object on which to define the getter. + * @param {string|Symbol} prop + * The property name for which to define the getter. + * @param {function} getter + * The function to call in order to generate the final property + * value. + */ +function defineLazyGetter(object, prop, getter) { + let redefine = (obj, value) => { + Object.defineProperty(obj, prop, { + enumerable: true, + configurable: true, + writable: true, + value, + }); + return value; + }; + + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + + get() { + return redefine(this, getter.call(this)); + }, + + set(value) { + redefine(this, value); + }, + }); +} + +function findPathInObject(obj, path, printErrors = true) { + let parent; + for (let elt of path.split(".")) { + if (!obj || !(elt in obj)) { + if (printErrors) { + Cu.reportError(`WebExtension API ${path} not found (it may be unimplemented by Firefox).`); + } + return null; + } + + parent = obj; + obj = obj[elt]; + } + + if (typeof obj === "function") { + return obj.bind(parent); + } + return obj; +} + +/** + * Acts as a proxy for a message manager or message manager owner, and + * tracks docShell swaps so that messages are always sent to the same + * receiver, even if it is moved to a different <browser>. + * + * @param {nsIMessageSender|Element} target + * The target message manager on which to send messages, or the + * <browser> element which owns it. + */ +class MessageManagerProxy { + constructor(target) { + this.listeners = new DefaultMap(() => new Map()); + + if (target instanceof Ci.nsIMessageSender) { + Object.defineProperty(this, "messageManager", { + value: target, + configurable: true, + writable: true, + }); + } else { + this.addListeners(target); + } + } + + /** + * Disposes of the proxy object, removes event listeners, and drops + * all references to the underlying message manager. + * + * Must be called before the last reference to the proxy is dropped, + * unless the underlying message manager or <browser> is also being + * destroyed. + */ + dispose() { + if (this.eventTarget) { + this.removeListeners(this.eventTarget); + this.eventTarget = null; + } else { + this.messageManager = null; + } + } + + /** + * Returns true if the given target is the same as, or owns, the given + * message manager. + * + * @param {nsIMessageSender|MessageManagerProxy|Element} target + * The message manager, MessageManagerProxy, or <browser> + * element agaisnt which to match. + * @param {nsIMessageSender} messageManager + * The message manager against which to match `target`. + * + * @returns {boolean} + * True if `messageManager` is the same object as `target`, or + * `target` is a MessageManagerProxy or <browser> element that + * is tied to it. + */ + static matches(target, messageManager) { + return target === messageManager || target.messageManager === messageManager; + } + + /** + * @property {nsIMessageSender|null} messageManager + * The message manager that is currently being proxied. This + * may change during the life of the proxy object, so should + * not be stored elsewhere. + */ + get messageManager() { + return this.eventTarget && this.eventTarget.messageManager; + } + + /** + * Sends a message on the proxied message manager. + * + * @param {array} args + * Arguments to be passed verbatim to the underlying + * sendAsyncMessage method. + * @returns {undefined} + */ + sendAsyncMessage(...args) { + if (this.messageManager) { + return this.messageManager.sendAsyncMessage(...args); + } + /* globals uneval */ + Cu.reportError(`Cannot send message: Other side disconnected: ${uneval(args)}`); + } + + /** + * Adds a message listener to the current message manager, and + * transfers it to the new message manager after a docShell swap. + * + * @param {string} message + * The name of the message to listen for. + * @param {nsIMessageListener} listener + * The listener to add. + * @param {boolean} [listenWhenClosed = false] + * If true, the listener will receive messages which were sent + * after the remote side of the listener began closing. + */ + addMessageListener(message, listener, listenWhenClosed = false) { + this.messageManager.addMessageListener(message, listener, listenWhenClosed); + this.listeners.get(message).set(listener, listenWhenClosed); + } + + /** + * Adds a message listener from the current message manager. + * + * @param {string} message + * The name of the message to stop listening for. + * @param {nsIMessageListener} listener + * The listener to remove. + */ + removeMessageListener(message, listener) { + this.messageManager.removeMessageListener(message, listener); + + let listeners = this.listeners.get(message); + listeners.delete(listener); + if (!listeners.size) { + this.listeners.delete(message); + } + } + + /** + * @private + * Iterates over all of the currently registered message listeners. + */ + * iterListeners() { + for (let [message, listeners] of this.listeners) { + for (let [listener, listenWhenClosed] of listeners) { + yield {message, listener, listenWhenClosed}; + } + } + } + + /** + * @private + * Adds docShell swap listeners to the message manager owner. + * + * @param {Element} target + * The target element. + */ + addListeners(target) { + target.addEventListener("SwapDocShells", this); + + for (let {message, listener, listenWhenClosed} of this.iterListeners()) { + target.addMessageListener(message, listener, listenWhenClosed); + } + + this.eventTarget = target; + } + + /** + * @private + * Removes docShell swap listeners to the message manager owner. + * + * @param {Element} target + * The target element. + */ + removeListeners(target) { + target.removeEventListener("SwapDocShells", this); + + for (let {message, listener} of this.iterListeners()) { + target.removeMessageListener(message, listener); + } + } + + handleEvent(event) { + if (event.type == "SwapDocShells") { + this.removeListeners(this.eventTarget); + this.addListeners(event.detail); + } + } +} + +this.ExtensionUtils = { + defineLazyGetter, + detectLanguage, + extend, + findPathInObject, + flushJarCache, + getConsole, + getInnerWindowID, + getMessageManager, + getUniqueId, + ignoreEvent, + injectAPI, + instanceOf, + normalizeTime, + promiseDocumentLoaded, + promiseDocumentReady, + promiseEvent, + promiseObserved, + runSafe, + runSafeSync, + runSafeSyncWithoutClone, + runSafeWithoutClone, + stylesheetMap, + DefaultMap, + DefaultWeakMap, + EventEmitter, + EventManager, + ExtensionError, + IconDetails, + LocaleData, + MessageManagerProxy, + PlatformInfo, + SingletonEventManager, + SpreadArgs, +}; |