diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /toolkit/modules | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | uxp-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz |
Add m-esr52 at 52.6.0
Diffstat (limited to 'toolkit/modules')
210 files changed, 47861 insertions, 0 deletions
diff --git a/toolkit/modules/AppConstants.jsm b/toolkit/modules/AppConstants.jsm new file mode 100644 index 0000000000..7ce8e1f09a --- /dev/null +++ b/toolkit/modules/AppConstants.jsm @@ -0,0 +1,343 @@ +#filter substitution +#include @TOPOBJDIR@/source-repo.h +/* 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"; + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", "resource://gre/modules/Services.jsm"); + +this.EXPORTED_SYMBOLS = ["AppConstants"]; + +// Immutable for export. +this.AppConstants = Object.freeze({ + // See this wiki page for more details about channel specific build + // defines: https://wiki.mozilla.org/Platform/Channel-specific_build_defines + NIGHTLY_BUILD: +#ifdef NIGHTLY_BUILD + true, +#else + false, +#endif + + RELEASE_OR_BETA: +#ifdef RELEASE_OR_BETA + true, +#else + false, +#endif + + ACCESSIBILITY: +#ifdef ACCESSIBILITY + true, +#else + false, +#endif + + // Official corresponds, roughly, to whether this build is performed + // on Mozilla's continuous integration infrastructure. You should + // disable developer-only functionality when this flag is set. + MOZILLA_OFFICIAL: +#ifdef MOZILLA_OFFICIAL + true, +#else + false, +#endif + + MOZ_OFFICIAL_BRANDING: +#ifdef MOZ_OFFICIAL_BRANDING + true, +#else + false, +#endif + + MOZ_DEV_EDITION: +#ifdef MOZ_DEV_EDITION + true, +#else + false, +#endif + + MOZ_SERVICES_HEALTHREPORT: +#ifdef MOZ_SERVICES_HEALTHREPORT + true, +#else + false, +#endif + + MOZ_DATA_REPORTING: +#ifdef MOZ_DATA_REPORTING + true, +#else + false, +#endif + + MOZ_SANDBOX: +#ifdef MOZ_SANDBOX + true, +#else + false, +#endif + + MOZ_CONTENT_SANDBOX: +#ifdef MOZ_CONTENT_SANDBOX + true, +#else + false, +#endif + + MOZ_TELEMETRY_REPORTING: +#ifdef MOZ_TELEMETRY_REPORTING + true, +#else + false, +#endif + + MOZ_TELEMETRY_ON_BY_DEFAULT: +#ifdef MOZ_TELEMETRY_ON_BY_DEFAULT + true, +#else + false, +#endif + + MOZ_SERVICES_CLOUDSYNC: +#ifdef MOZ_SERVICES_CLOUDSYNC + true, +#else + false, +#endif + + MOZ_UPDATER: +#ifdef MOZ_UPDATER + true, +#else + false, +#endif + + MOZ_SWITCHBOARD: +#ifdef MOZ_SWITCHBOARD + true, +#else + false, +#endif + + MOZ_WEBRTC: +#ifdef MOZ_WEBRTC + true, +#else + false, +#endif + + MOZ_WIDGET_GTK: +#ifdef MOZ_WIDGET_GTK + true, +#else + false, +#endif + +# MOZ_B2G covers both device and desktop b2g + MOZ_B2G: +#ifdef MOZ_B2G + true, +#else + false, +#endif + + XP_UNIX: +#ifdef XP_UNIX + true, +#else + false, +#endif + +# NOTE! XP_LINUX has to go after MOZ_WIDGET_ANDROID otherwise Android +# builds will be misidentified as linux. + platform: +#ifdef MOZ_WIDGET_GTK + "linux", +#elif XP_WIN + "win", +#elif XP_MACOSX + "macosx", +#elif MOZ_WIDGET_ANDROID + "android", +#elif MOZ_WIDGET_GONK + "gonk", +#elif XP_LINUX + "linux", +#else + "other", +#endif + + isPlatformAndVersionAtLeast(platform, version) { + let platformVersion = Services.sysinfo.getProperty("version"); + return platform == this.platform && + Services.vc.compare(platformVersion, version) >= 0; + }, + + isPlatformAndVersionAtMost(platform, version) { + let platformVersion = Services.sysinfo.getProperty("version"); + return platform == this.platform && + Services.vc.compare(platformVersion, version) <= 0; + }, + + MOZ_CRASHREPORTER: +#ifdef MOZ_CRASHREPORTER + true, +#else + false, +#endif + + MOZ_VERIFY_MAR_SIGNATURE: +#ifdef MOZ_VERIFY_MAR_SIGNATURE + true, +#else + false, +#endif + + MOZ_MAINTENANCE_SERVICE: +#ifdef MOZ_MAINTENANCE_SERVICE + true, +#else + false, +#endif + + E10S_TESTING_ONLY: +#ifdef E10S_TESTING_ONLY + true, +#else + false, +#endif + + DEBUG: +#ifdef DEBUG + true, +#else + false, +#endif + + ASAN: +#ifdef MOZ_ASAN + true, +#else + false, +#endif + + MOZ_B2G_RIL: +#ifdef MOZ_B2G_RIL + true, +#else + false, +#endif + + MOZ_GRAPHENE: +#ifdef MOZ_GRAPHENE + true, +#else + false, +#endif + + MOZ_SYSTEM_NSS: +#ifdef MOZ_SYSTEM_NSS + true, +#else + false, +#endif + + MOZ_PLACES: +#ifdef MOZ_PLACES + true, +#else + false, +#endif + + MOZ_REQUIRE_SIGNING: +#ifdef MOZ_REQUIRE_SIGNING + true, +#else + false, +#endif + + MENUBAR_CAN_AUTOHIDE: +#ifdef MENUBAR_CAN_AUTOHIDE + true, +#else + false, +#endif + + CAN_DRAW_IN_TITLEBAR: +#ifdef CAN_DRAW_IN_TITLEBAR + true, +#else + false, +#endif + + MOZ_ANDROID_HISTORY: +#ifdef MOZ_ANDROID_HISTORY + true, +#else + false, +#endif + + MOZ_TOOLKIT_SEARCH: +#ifdef MOZ_TOOLKIT_SEARCH + true, +#else + false, +#endif + + MOZ_ENABLE_PROFILER_SPS: +#ifdef MOZ_ENABLE_PROFILER_SPS + true, +#else + false, +#endif + + MOZ_ANDROID_ACTIVITY_STREAM: +#ifdef MOZ_ANDROID_ACTIVITY_STREAM + true, +#else + false, +#endif + + DLL_PREFIX: "@DLL_PREFIX@", + DLL_SUFFIX: "@DLL_SUFFIX@", + + MOZ_APP_NAME: "@MOZ_APP_NAME@", + MOZ_APP_VERSION: "@MOZ_APP_VERSION@", + MOZ_APP_VERSION_DISPLAY: "@MOZ_APP_VERSION_DISPLAY@", + MOZ_BUILD_APP: "@MOZ_BUILD_APP@", + MOZ_MACBUNDLE_NAME: "@MOZ_MACBUNDLE_NAME@", + MOZ_UPDATE_CHANNEL: "@MOZ_UPDATE_CHANNEL@", + INSTALL_LOCALE: "@AB_CD@", + MOZ_WIDGET_TOOLKIT: "@MOZ_WIDGET_TOOLKIT@", + ANDROID_PACKAGE_NAME: "@ANDROID_PACKAGE_NAME@", + MOZ_B2G_VERSION: @MOZ_B2G_VERSION@, + MOZ_B2G_OS_NAME: @MOZ_B2G_OS_NAME@, + + DEBUG_JS_MODULES: "@DEBUG_JS_MODULES@", + + // URL to the hg revision this was built from (e.g. + // "https://hg.mozilla.org/mozilla-central/rev/6256ec9113c1") + // On unofficial builds, this is an empty string. +#ifndef MOZ_SOURCE_URL +#define MOZ_SOURCE_URL +#endif + SOURCE_REVISION_URL: "@MOZ_SOURCE_URL@", + + HAVE_USR_LIB64_DIR: +#ifdef HAVE_USR_LIB64_DIR + true, +#else + false, +#endif + + HAVE_SHELL_SERVICE: +#ifdef HAVE_SHELL_SERVICE + true, +#else + false, +#endif +}); diff --git a/toolkit/modules/AsyncPrefs.jsm b/toolkit/modules/AsyncPrefs.jsm new file mode 100644 index 0000000000..4ad523fe41 --- /dev/null +++ b/toolkit/modules/AsyncPrefs.jsm @@ -0,0 +1,183 @@ +/* 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 = ["AsyncPrefs"]; + +const {interfaces: Ci, utils: Cu, classes: Cc} = Components; +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +const kInChildProcess = Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; + +const kAllowedPrefs = new Set([ + // NB: please leave the testing prefs at the top, and sort the rest alphabetically if you add + // anything. + "testing.allowed-prefs.some-bool-pref", + "testing.allowed-prefs.some-char-pref", + "testing.allowed-prefs.some-int-pref", + + "narrate.rate", + "narrate.voice", + + "reader.font_size", + "reader.font_type", + "reader.color_scheme", + "reader.content_width", + "reader.line_height", +]); + +const kPrefTypeMap = new Map([ + ["boolean", Services.prefs.PREF_BOOL], + ["number", Services.prefs.PREF_INT], + ["string", Services.prefs.PREF_STRING], +]); + +function maybeReturnErrorForReset(pref) { + if (!kAllowedPrefs.has(pref)) { + return `Resetting pref ${pref} from content is not allowed.`; + } + return false; +} + +function maybeReturnErrorForSet(pref, value) { + if (!kAllowedPrefs.has(pref)) { + return `Setting pref ${pref} from content is not allowed.`; + } + + let valueType = typeof value; + if (!kPrefTypeMap.has(valueType)) { + return `Can't set pref ${pref} to value of type ${valueType}.`; + } + let prefType = Services.prefs.getPrefType(pref); + if (prefType != Services.prefs.PREF_INVALID && + prefType != kPrefTypeMap.get(valueType)) { + return `Can't set pref ${pref} to a value with type ${valueType} that doesn't match the pref's type ${prefType}.`; + } + return false; +} + +var AsyncPrefs; +if (kInChildProcess) { + let gUniqueId = 0; + let gMsgMap = new Map(); + + AsyncPrefs = { + set: Task.async(function(pref, value) { + let error = maybeReturnErrorForSet(pref, value); + if (error) { + return Promise.reject(error); + } + + let msgId = ++gUniqueId; + return new Promise((resolve, reject) => { + gMsgMap.set(msgId, {resolve, reject}); + Services.cpmm.sendAsyncMessage("AsyncPrefs:SetPref", {pref, value, msgId}); + }); + }), + + reset: Task.async(function(pref) { + let error = maybeReturnErrorForReset(pref); + if (error) { + return Promise.reject(error); + } + + let msgId = ++gUniqueId; + return new Promise((resolve, reject) => { + gMsgMap.set(msgId, {resolve, reject}); + Services.cpmm.sendAsyncMessage("AsyncPrefs:ResetPref", {pref, msgId}); + }); + }), + + receiveMessage(msg) { + let promiseRef = gMsgMap.get(msg.data.msgId); + if (promiseRef) { + gMsgMap.delete(msg.data.msgId); + if (msg.data.success) { + promiseRef.resolve(); + } else { + promiseRef.reject(msg.data.message); + } + } + }, + + init() { + Services.cpmm.addMessageListener("AsyncPrefs:PrefSetFinished", this); + Services.cpmm.addMessageListener("AsyncPrefs:PrefResetFinished", this); + }, + }; +} else { + AsyncPrefs = { + methodForType: { + number: "setIntPref", + boolean: "setBoolPref", + string: "setCharPref", + }, + + set: Task.async(function(pref, value) { + let error = maybeReturnErrorForSet(pref, value); + if (error) { + return Promise.reject(error); + } + let methodToUse = this.methodForType[typeof value]; + try { + Services.prefs[methodToUse](pref, value); + return Promise.resolve(value); + } catch (ex) { + Cu.reportError(ex); + return Promise.reject(ex.message); + } + }), + + reset: Task.async(function(pref) { + let error = maybeReturnErrorForReset(pref); + if (error) { + return Promise.reject(error); + } + + try { + Services.prefs.clearUserPref(pref); + return Promise.resolve(); + } catch (ex) { + Cu.reportError(ex); + return Promise.reject(ex.message); + } + }), + + receiveMessage(msg) { + if (msg.name == "AsyncPrefs:SetPref") { + this.onPrefSet(msg); + } else { + this.onPrefReset(msg); + } + }, + + onPrefReset(msg) { + let {pref, msgId} = msg.data; + this.reset(pref).then(function() { + msg.target.sendAsyncMessage("AsyncPrefs:PrefResetFinished", {msgId, success: true}); + }, function(msg) { + msg.target.sendAsyncMessage("AsyncPrefs:PrefResetFinished", {msgId, success: false, message: msg}); + }); + }, + + onPrefSet(msg) { + let {pref, value, msgId} = msg.data; + this.set(pref, value).then(function() { + msg.target.sendAsyncMessage("AsyncPrefs:PrefSetFinished", {msgId, success: true}); + }, function(msg) { + msg.target.sendAsyncMessage("AsyncPrefs:PrefSetFinished", {msgId, success: false, message: msg}); + }); + }, + + init() { + Services.ppmm.addMessageListener("AsyncPrefs:SetPref", this); + Services.ppmm.addMessageListener("AsyncPrefs:ResetPref", this); + } + }; +} + +AsyncPrefs.init(); + diff --git a/toolkit/modules/Battery.jsm b/toolkit/modules/Battery.jsm new file mode 100644 index 0000000000..69184d3611 --- /dev/null +++ b/toolkit/modules/Battery.jsm @@ -0,0 +1,73 @@ +// -*- Mode: javascript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- +// 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 module wraps around navigator.getBattery (https://developer.mozilla.org/en-US/docs/Web/API/Navigator.getBattery). + * and provides a framework for spoofing battery values in test code. + * To spoof the battery values, set `Debugging.fake = true` after exporting this with a BackstagePass, + * after which you can spoof a property yb setting the relevant property of the BatteryManager object. + */ +this.EXPORTED_SYMBOLS = ["GetBattery", "Battery"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +// Load Services, for the BatteryManager API +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +// Values for the fake battery. See the documentation of Navigator.battery for the meaning of each field. +var gFakeBattery = { + charging: false, + chargingTime: 0, + dischargingTime: Infinity, + level: 1, +} + +// BackendPass-exported object for toggling spoofing +this.Debugging = { + /** + * If `false`, use the DOM Battery implementation. + * Set it to `true` if you need to fake battery values + * for testing or debugging purposes. + */ + fake: false +} + +this.GetBattery = function () { + return new Services.appShell.hiddenDOMWindow.Promise(function (resolve, reject) { + // Return fake values if spoofing is enabled, otherwise fetch the real values from the BatteryManager API + if (Debugging.fake) { + resolve(gFakeBattery); + return; + } + Services.appShell.hiddenDOMWindow.navigator.getBattery().then(resolve, reject); + }); +}; + +this.Battery = {}; + +for (let k of ["charging", "chargingTime", "dischargingTime", "level"]) { + let prop = k; + Object.defineProperty(this.Battery, prop, { + get: function() { + // Return fake value if spoofing is enabled, otherwise fetch the real value from the BatteryManager API + if (Debugging.fake) { + return gFakeBattery[prop]; + } + return Services.appShell.hiddenDOMWindow.navigator.battery[prop]; + }, + set: function(fakeSetting) { + if (!Debugging.fake) { + throw new Error("Tried to set fake battery value when battery spoofing was disabled"); + } + gFakeBattery[prop] = fakeSetting; + } + }) +} diff --git a/toolkit/modules/BinarySearch.jsm b/toolkit/modules/BinarySearch.jsm new file mode 100644 index 0000000000..16bca73988 --- /dev/null +++ b/toolkit/modules/BinarySearch.jsm @@ -0,0 +1,75 @@ +/* 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 = [ + "BinarySearch", +]; + +this.BinarySearch = Object.freeze({ + + /** + * Returns the index of the given target in the given array or -1 if the + * target is not found. + * + * See search() for a description of this function's parameters. + * + * @return The index of `target` in `array` or -1 if `target` is not found. + */ + indexOf: function (comparator, array, target) { + let [found, idx] = this.search(comparator, array, target); + return found ? idx : -1; + }, + + /** + * Returns the index within the given array where the given target may be + * inserted to keep the array ordered. + * + * See search() for a description of this function's parameters. + * + * @return The index in `array` where `target` may be inserted to keep `array` + * ordered. + */ + insertionIndexOf: function (comparator, array, target) { + return this.search(comparator, array, target)[1]; + }, + + /** + * Searches for the given target in the given array. + * + * @param comparator + * A function that takes two arguments and compares them, returning a + * negative number if the first should be ordered before the second, + * zero if the first and second have the same ordering, or a positive + * number if the second should be ordered before the first. The first + * argument is always `target`, and the second argument is a value + * from the array. + * @param array + * An array whose elements are ordered by `comparator`. + * @param target + * The value to search for. + * @return An array with two elements. If `target` is found, the first + * element is true, and the second element is its index in the array. + * If `target` is not found, the first element is false, and the + * second element is the index where it may be inserted to keep the + * array ordered. + */ + search: function (comparator, array, target) { + let low = 0; + let high = array.length - 1; + while (low <= high) { + // Thanks to http://jsperf.com/code-review-1480 for this tip. + let mid = (low + high) >> 1; + let cmp = comparator(target, array[mid]); + if (cmp == 0) + return [true, mid]; + if (cmp < 0) + high = mid - 1; + else + low = mid + 1; + } + return [false, low]; + }, +}); diff --git a/toolkit/modules/BrowserUtils.jsm b/toolkit/modules/BrowserUtils.jsm new file mode 100644 index 0000000000..862f9619c4 --- /dev/null +++ b/toolkit/modules/BrowserUtils.jsm @@ -0,0 +1,586 @@ +/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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 = [ "BrowserUtils" ]; + +const {interfaces: Ci, utils: Cu, classes: Cc} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); + +Cu.importGlobalProperties(['URL']); + +this.BrowserUtils = { + + /** + * Prints arguments separated by a space and appends a new line. + */ + dumpLn: function (...args) { + for (let a of args) + dump(a + " "); + dump("\n"); + }, + + /** + * restartApplication: Restarts the application, keeping it in + * safe mode if it is already in safe mode. + */ + restartApplication: function() { + let appStartup = Cc["@mozilla.org/toolkit/app-startup;1"] + .getService(Ci.nsIAppStartup); + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"] + .createInstance(Ci.nsISupportsPRBool); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested", "restart"); + if (cancelQuit.data) { // The quit request has been canceled. + return false; + } + // if already in safe mode restart in safe mode + if (Services.appinfo.inSafeMode) { + appStartup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart); + return undefined; + } + appStartup.quit(Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart); + return undefined; + }, + + /** + * urlSecurityCheck: JavaScript wrapper for checkLoadURIWithPrincipal + * and checkLoadURIStrWithPrincipal. + * If |aPrincipal| is not allowed to link to |aURL|, this function throws with + * an error message. + * + * @param aURL + * The URL a page has linked to. This could be passed either as a string + * or as a nsIURI object. + * @param aPrincipal + * The principal of the document from which aURL came. + * @param aFlags + * Flags to be passed to checkLoadURIStr. If undefined, + * nsIScriptSecurityManager.STANDARD will be passed. + */ + urlSecurityCheck: function(aURL, aPrincipal, aFlags) { + var secMan = Services.scriptSecurityManager; + if (aFlags === undefined) { + aFlags = secMan.STANDARD; + } + + try { + if (aURL instanceof Ci.nsIURI) + secMan.checkLoadURIWithPrincipal(aPrincipal, aURL, aFlags); + else + secMan.checkLoadURIStrWithPrincipal(aPrincipal, aURL, aFlags); + } catch (e) { + let principalStr = ""; + try { + principalStr = " from " + aPrincipal.URI.spec; + } + catch (e2) { } + + throw "Load of " + aURL + principalStr + " denied."; + } + }, + + /** + * Return or create a principal with the codebase of one, and the originAttributes + * of an existing principal (e.g. on a docshell, where the originAttributes ought + * not to change, that is, we should keep the userContextId, privateBrowsingId, + * etc. the same when changing the principal). + * + * @param principal + * The principal whose codebase/null/system-ness we want. + * @param existingPrincipal + * The principal whose originAttributes we want, usually the current + * principal of a docshell. + * @return an nsIPrincipal that matches the codebase/null/system-ness of the first + * param, and the originAttributes of the second. + */ + principalWithMatchingOA(principal, existingPrincipal) { + // Don't care about system principals: + if (principal.isSystemPrincipal) { + return principal; + } + + // If the originAttributes already match, just return the principal as-is. + if (existingPrincipal.originSuffix == principal.originSuffix) { + return principal; + } + + let secMan = Services.scriptSecurityManager; + if (principal.isCodebasePrincipal) { + return secMan.createCodebasePrincipal(principal.URI, existingPrincipal.originAttributes); + } + + if (principal.isNullPrincipal) { + return secMan.createNullPrincipal(existingPrincipal.originAttributes); + } + throw new Error("Can't change the originAttributes of an expanded principal!"); + }, + + /** + * Constructs a new URI, using nsIIOService. + * @param aURL The URI spec. + * @param aOriginCharset The charset of the URI. + * @param aBaseURI Base URI to resolve aURL, or null. + * @return an nsIURI object based on aURL. + */ + makeURI: function(aURL, aOriginCharset, aBaseURI) { + return Services.io.newURI(aURL, aOriginCharset, aBaseURI); + }, + + makeFileURI: function(aFile) { + return Services.io.newFileURI(aFile); + }, + + makeURIFromCPOW: function(aCPOWURI) { + return Services.io.newURI(aCPOWURI.spec, aCPOWURI.originCharset, null); + }, + + /** + * For a given DOM element, returns its position in "screen" + * coordinates. In a content process, the coordinates returned will + * be relative to the left/top of the tab. In the chrome process, + * the coordinates are relative to the user's screen. + */ + getElementBoundingScreenRect: function(aElement) { + return this.getElementBoundingRect(aElement, true); + }, + + /** + * For a given DOM element, returns its position as an offset from the topmost + * window. In a content process, the coordinates returned will be relative to + * the left/top of the topmost content area. If aInScreenCoords is true, + * screen coordinates will be returned instead. + */ + getElementBoundingRect: function(aElement, aInScreenCoords) { + let rect = aElement.getBoundingClientRect(); + let win = aElement.ownerDocument.defaultView; + + let x = rect.left, y = rect.top; + + // We need to compensate for any iframes that might shift things + // over. We also need to compensate for zooming. + let parentFrame = win.frameElement; + while (parentFrame) { + win = parentFrame.ownerDocument.defaultView; + let cstyle = win.getComputedStyle(parentFrame, ""); + + let framerect = parentFrame.getBoundingClientRect(); + x += framerect.left + parseFloat(cstyle.borderLeftWidth) + parseFloat(cstyle.paddingLeft); + y += framerect.top + parseFloat(cstyle.borderTopWidth) + parseFloat(cstyle.paddingTop); + + parentFrame = win.frameElement; + } + + if (aInScreenCoords) { + x += win.mozInnerScreenX; + y += win.mozInnerScreenY; + } + + let fullZoom = win.getInterface(Ci.nsIDOMWindowUtils).fullZoom; + rect = { + left: x * fullZoom, + top: y * fullZoom, + width: rect.width * fullZoom, + height: rect.height * fullZoom + }; + + return rect; + }, + + onBeforeLinkTraversal: function(originalTarget, linkURI, linkNode, isAppTab) { + // Don't modify non-default targets or targets that aren't in top-level app + // tab docshells (isAppTab will be false for app tab subframes). + if (originalTarget != "" || !isAppTab) + return originalTarget; + + // External links from within app tabs should always open in new tabs + // instead of replacing the app tab's page (Bug 575561) + let linkHost; + let docHost; + try { + linkHost = linkURI.host; + docHost = linkNode.ownerDocument.documentURIObject.host; + } catch (e) { + // nsIURI.host can throw for non-nsStandardURL nsIURIs. + // If we fail to get either host, just return originalTarget. + return originalTarget; + } + + if (docHost == linkHost) + return originalTarget; + + // Special case: ignore "www" prefix if it is part of host string + let [longHost, shortHost] = + linkHost.length > docHost.length ? [linkHost, docHost] : [docHost, linkHost]; + if (longHost == "www." + shortHost) + return originalTarget; + + return "_blank"; + }, + + /** + * Map the plugin's name to a filtered version more suitable for UI. + * + * @param aName The full-length name string of the plugin. + * @return the simplified name string. + */ + makeNicePluginName: function (aName) { + if (aName == "Shockwave Flash") + return "Adobe Flash"; + // Regex checks if aName begins with "Java" + non-letter char + if (/^Java\W/.exec(aName)) + return "Java"; + + // Clean up the plugin name by stripping off parenthetical clauses, + // trailing version numbers or "plugin". + // EG, "Foo Bar (Linux) Plugin 1.23_02" --> "Foo Bar" + // Do this by first stripping the numbers, etc. off the end, and then + // removing "Plugin" (and then trimming to get rid of any whitespace). + // (Otherwise, something like "Java(TM) Plug-in 1.7.0_07" gets mangled) + let newName = aName.replace(/\(.*?\)/g, ""). + replace(/[\s\d\.\-\_\(\)]+$/, ""). + replace(/\bplug-?in\b/i, "").trim(); + return newName; + }, + + /** + * Return true if linkNode has a rel="noreferrer" attribute. + * + * @param linkNode The <a> element, or null. + * @return a boolean indicating if linkNode has a rel="noreferrer" attribute. + */ + linkHasNoReferrer: function (linkNode) { + // A null linkNode typically means that we're checking a link that wasn't + // provided via an <a> link, like a text-selected URL. Don't leak + // referrer information in this case. + if (!linkNode) + return true; + + let rel = linkNode.getAttribute("rel"); + if (!rel) + return false; + + // The HTML spec says that rel should be split on spaces before looking + // for particular rel values. + let values = rel.split(/[ \t\r\n\f]/); + return values.indexOf('noreferrer') != -1; + }, + + /** + * Returns true if |mimeType| is text-based, or false otherwise. + * + * @param mimeType + * The MIME type to check. + */ + mimeTypeIsTextBased: function(mimeType) { + return mimeType.startsWith("text/") || + mimeType.endsWith("+xml") || + mimeType == "application/x-javascript" || + mimeType == "application/javascript" || + mimeType == "application/json" || + mimeType == "application/xml" || + mimeType == "mozilla.application/cached-xul"; + }, + + /** + * Return true if we should FAYT for this node + window (could be CPOW): + * + * @param elt + * The element that is focused + * @param win + * The window that is focused + * + */ + shouldFastFind: function(elt, win) { + if (elt) { + if (elt instanceof win.HTMLInputElement && elt.mozIsTextField(false)) + return false; + + if (elt.isContentEditable || win.document.designMode == "on") + return false; + + if (elt instanceof win.HTMLTextAreaElement || + elt instanceof win.HTMLSelectElement || + elt instanceof win.HTMLObjectElement || + elt instanceof win.HTMLEmbedElement) + return false; + } + + return true; + }, + + /** + * Return true if we can FAYT for this window (could be CPOW): + * + * @param win + * The top level window that is focused + * + */ + canFastFind: function(win) { + if (!win) + return false; + + if (!this.mimeTypeIsTextBased(win.document.contentType)) + return false; + + // disable FAYT in about:blank to prevent FAYT opening unexpectedly. + let loc = win.location; + if (loc.href == "about:blank") + return false; + + // disable FAYT in documents that ask for it to be disabled. + if ((loc.protocol == "about:" || loc.protocol == "chrome:") && + (win.document.documentElement && + win.document.documentElement.getAttribute("disablefastfind") == "true")) + return false; + + return true; + }, + + _visibleToolbarsMap: new WeakMap(), + + /** + * Return true if any or a specific toolbar that interacts with the content + * document is visible. + * + * @param {nsIDocShell} docShell The docShell instance that a toolbar should + * be interacting with + * @param {String} which Identifier of a specific toolbar + * @return {Boolean} + */ + isToolbarVisible(docShell, which) { + let window = this.getRootWindow(docShell); + if (!this._visibleToolbarsMap.has(window)) + return false; + let toolbars = this._visibleToolbarsMap.get(window); + return !!toolbars && toolbars.has(which); + }, + + /** + * Track whether a toolbar is visible for a given a docShell. + * + * @param {nsIDocShell} docShell The docShell instance that a toolbar should + * be interacting with + * @param {String} which Identifier of a specific toolbar + * @param {Boolean} [visible] Whether the toolbar is visible. Optional, + * defaults to `true`. + */ + trackToolbarVisibility(docShell, which, visible = true) { + // We have to get the root window object, because XPConnect WrappedNatives + // can't be used as WeakMap keys. + let window = this.getRootWindow(docShell); + let toolbars = this._visibleToolbarsMap.get(window); + if (!toolbars) { + toolbars = new Set(); + this._visibleToolbarsMap.set(window, toolbars); + } + if (!visible) + toolbars.delete(which); + else + toolbars.add(which); + }, + + /** + * Retrieve the root window object (i.e. the top-most content global) for a + * specific docShell object. + * + * @param {nsIDocShell} docShell + * @return {nsIDOMWindow} + */ + getRootWindow(docShell) { + return docShell.QueryInterface(Ci.nsIDocShellTreeItem) + .sameTypeRootTreeItem.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + }, + + getSelectionDetails: function(topWindow, aCharLen) { + // selections of more than 150 characters aren't useful + const kMaxSelectionLen = 150; + const charLen = Math.min(aCharLen || kMaxSelectionLen, kMaxSelectionLen); + + let focusedWindow = {}; + let focusedElement = Services.focus.getFocusedElementForWindow(topWindow, true, focusedWindow); + focusedWindow = focusedWindow.value; + + let selection = focusedWindow.getSelection(); + let selectionStr = selection.toString(); + + let collapsed = selection.isCollapsed; + + let url; + let linkText; + if (selectionStr) { + // Have some text, let's figure out if it looks like a URL that isn't + // actually a link. + linkText = selectionStr.trim(); + if (/^(?:https?|ftp):/i.test(linkText)) { + try { + url = this.makeURI(linkText); + } catch (ex) {} + } + // Check if this could be a valid url, just missing the protocol. + else if (/^(?:[a-z\d-]+\.)+[a-z]+$/i.test(linkText)) { + // Now let's see if this is an intentional link selection. Our guess is + // based on whether the selection begins/ends with whitespace or is + // preceded/followed by a non-word character. + + // selection.toString() trims trailing whitespace, so we look for + // that explicitly in the first and last ranges. + let beginRange = selection.getRangeAt(0); + let delimitedAtStart = /^\s/.test(beginRange); + if (!delimitedAtStart) { + let container = beginRange.startContainer; + let offset = beginRange.startOffset; + if (container.nodeType == container.TEXT_NODE && offset > 0) + delimitedAtStart = /\W/.test(container.textContent[offset - 1]); + else + delimitedAtStart = true; + } + + let delimitedAtEnd = false; + if (delimitedAtStart) { + let endRange = selection.getRangeAt(selection.rangeCount - 1); + delimitedAtEnd = /\s$/.test(endRange); + if (!delimitedAtEnd) { + let container = endRange.endContainer; + let offset = endRange.endOffset; + if (container.nodeType == container.TEXT_NODE && + offset < container.textContent.length) + delimitedAtEnd = /\W/.test(container.textContent[offset]); + else + delimitedAtEnd = true; + } + } + + if (delimitedAtStart && delimitedAtEnd) { + let uriFixup = Cc["@mozilla.org/docshell/urifixup;1"] + .getService(Ci.nsIURIFixup); + try { + url = uriFixup.createFixupURI(linkText, uriFixup.FIXUP_FLAG_NONE); + } catch (ex) {} + } + } + } + + // try getting a selected text in text input. + if (!selectionStr && focusedElement instanceof Ci.nsIDOMNSEditableElement) { + // Don't get the selection for password fields. See bug 565717. + if (focusedElement instanceof Ci.nsIDOMHTMLTextAreaElement || + (focusedElement instanceof Ci.nsIDOMHTMLInputElement && + focusedElement.mozIsTextField(true))) { + selectionStr = focusedElement.editor.selection.toString(); + } + } + + if (selectionStr) { + if (selectionStr.length > charLen) { + // only use the first charLen important chars. see bug 221361 + var pattern = new RegExp("^(?:\\s*.){0," + charLen + "}"); + pattern.test(selectionStr); + selectionStr = RegExp.lastMatch; + } + + selectionStr = selectionStr.trim().replace(/\s+/g, " "); + + if (selectionStr.length > charLen) { + selectionStr = selectionStr.substr(0, charLen); + } + } + + if (url && !url.host) { + url = null; + } + + return { text: selectionStr, docSelectionIsCollapsed: collapsed, + linkURL: url ? url.spec : null, linkText: url ? linkText : "" }; + }, + + // Iterates through every docshell in the window and calls PermitUnload. + canCloseWindow(window) { + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation); + let node = docShell.QueryInterface(Ci.nsIDocShellTreeItem); + for (let i = 0; i < node.childCount; ++i) { + let docShell = node.getChildAt(i).QueryInterface(Ci.nsIDocShell); + let contentViewer = docShell.contentViewer; + if (contentViewer && !contentViewer.permitUnload()) { + return false; + } + } + + return true; + }, + + /** + * Replaces %s or %S in the provided url or postData with the given parameter, + * acccording to the best charset for the given url. + * + * @return [url, postData] + * @throws if nor url nor postData accept a param, but a param was provided. + */ + parseUrlAndPostData: Task.async(function* (url, postData, param) { + let hasGETParam = /%s/i.test(url) + let decodedPostData = postData ? unescape(postData) : ""; + let hasPOSTParam = /%s/i.test(decodedPostData); + + if (!hasGETParam && !hasPOSTParam) { + if (param) { + // If nor the url, nor postData contain parameters, but a parameter was + // provided, return the original input. + throw new Error("A param was provided but there's nothing to bind it to"); + } + return [url, postData]; + } + + let charset = ""; + const re = /^(.*)\&mozcharset=([a-zA-Z][_\-a-zA-Z0-9]+)\s*$/; + let matches = url.match(re); + if (matches) { + [, url, charset] = matches; + } else { + // Try to fetch a charset from History. + try { + // Will return an empty string if character-set is not found. + charset = yield PlacesUtils.getCharsetForURI(this.makeURI(url)); + } catch (ex) { + // makeURI() throws if url is invalid. + Cu.reportError(ex); + } + } + + // encodeURIComponent produces UTF-8, and cannot be used for other charsets. + // escape() works in those cases, but it doesn't uri-encode +, @, and /. + // Therefore we need to manually replace these ASCII characters by their + // encodeURIComponent result, to match the behavior of nsEscape() with + // url_XPAlphas. + let encodedParam = ""; + if (charset && charset != "UTF-8") { + try { + let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = charset; + encodedParam = converter.ConvertFromUnicode(param) + converter.Finish(); + } catch (ex) { + encodedParam = param; + } + encodedParam = escape(encodedParam).replace(/[+@\/]+/g, encodeURIComponent); + } else { + // Default charset is UTF-8 + encodedParam = encodeURIComponent(param); + } + + url = url.replace(/%s/g, encodedParam).replace(/%S/g, param); + if (hasPOSTParam) { + postData = decodedPostData.replace(/%s/g, encodedParam) + .replace(/%S/g, param); + } + return [url, postData]; + }), +}; diff --git a/toolkit/modules/CanonicalJSON.jsm b/toolkit/modules/CanonicalJSON.jsm new file mode 100644 index 0000000000..ae754ff3a3 --- /dev/null +++ b/toolkit/modules/CanonicalJSON.jsm @@ -0,0 +1,62 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["CanonicalJSON"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "jsesc", + "resource://gre/modules/third_party/jsesc/jsesc.js"); + +this.CanonicalJSON = { + /** + * Return the canonical JSON form of the passed source, sorting all the object + * keys recursively. Note that this method will cause an infinite loop if + * cycles exist in the source (bug 1265357). + * + * @param source + * The elements to be serialized. + * + * The output will have all unicode chars escaped with the unicode codepoint + * as lowercase hexadecimal. + * + * @usage + * CanonicalJSON.stringify(listOfRecords); + **/ + stringify: function stringify(source) { + if (Array.isArray(source)) { + const jsonArray = source.map(x => typeof x === "undefined" ? null : x); + return `[${jsonArray.map(stringify).join(",")}]`; + } + + if (typeof source === "number") { + if (source === 0) { + return (Object.is(source, -0)) ? "-0" : "0"; + } + } + + // Leverage jsesc library, mainly for unicode escaping. + const toJSON = (input) => jsesc(input, {lowercaseHex: true, json: true}); + + if (typeof source !== "object" || source === null) { + return toJSON(source); + } + + // Dealing with objects, ordering keys. + const sortedKeys = Object.keys(source).sort(); + const lastIndex = sortedKeys.length - 1; + return sortedKeys.reduce((serial, key, index) => { + const value = source[key]; + // JSON.stringify drops keys with an undefined value. + if (typeof value === "undefined") { + return serial; + } + const jsonValue = value && value.toJSON ? value.toJSON() : value; + const suffix = index !== lastIndex ? "," : ""; + const escapedKey = toJSON(key); + return serial + `${escapedKey}:${stringify(jsonValue)}${suffix}`; + }, "{") + "}"; + }, +}; diff --git a/toolkit/modules/CertUtils.jsm b/toolkit/modules/CertUtils.jsm new file mode 100644 index 0000000000..e61ea9de74 --- /dev/null +++ b/toolkit/modules/CertUtils.jsm @@ -0,0 +1,222 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ +this.EXPORTED_SYMBOLS = [ "BadCertHandler", "checkCert", "readCertPrefs", "validateCert" ]; + +const Ce = Components.Exception; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +Components.utils.import("resource://gre/modules/Services.jsm"); + +/** + * Reads a set of expected certificate attributes from preferences. The returned + * array can be passed to validateCert or checkCert to validate that a + * certificate matches the expected attributes. The preferences should look like + * this: + * prefix.1.attribute1 + * prefix.1.attribute2 + * prefix.2.attribute1 + * etc. + * Each numeric branch contains a set of required attributes for a single + * certificate. Having multiple numeric branches means that multiple + * certificates would be accepted by validateCert. + * + * @param aPrefBranch + * The prefix for all preferences, should end with a ".". + * @return An array of JS objects with names / values corresponding to the + * expected certificate's attribute names / values. + */ +this.readCertPrefs = + function readCertPrefs(aPrefBranch) { + if (Services.prefs.getBranch(aPrefBranch).getChildList("").length == 0) + return null; + + let certs = []; + let counter = 1; + while (true) { + let prefBranchCert = Services.prefs.getBranch(aPrefBranch + counter + "."); + let prefCertAttrs = prefBranchCert.getChildList(""); + if (prefCertAttrs.length == 0) + break; + + let certAttrs = {}; + for (let prefCertAttr of prefCertAttrs) + certAttrs[prefCertAttr] = prefBranchCert.getCharPref(prefCertAttr); + + certs.push(certAttrs); + counter++; + } + + return certs; +} + +/** + * Verifies that an nsIX509Cert matches the expected certificate attribute + * values. + * + * @param aCertificate + * The nsIX509Cert to compare to the expected attributes. + * @param aCerts + * An array of JS objects with names / values corresponding to the + * expected certificate's attribute names / values. If this is null or + * an empty array then no checks are performed. + * @throws NS_ERROR_ILLEGAL_VALUE if a certificate attribute name from the + * aCerts param does not exist or the value for a certificate attribute + * from the aCerts param is different than the expected value or + * aCertificate wasn't specified and aCerts is not null or an empty + * array. + */ +this.validateCert = + function validateCert(aCertificate, aCerts) { + // If there are no certificate requirements then just exit + if (!aCerts || aCerts.length == 0) + return; + + if (!aCertificate) { + const missingCertErr = "A required certificate was not present."; + Cu.reportError(missingCertErr); + throw new Ce(missingCertErr, Cr.NS_ERROR_ILLEGAL_VALUE); + } + + var errors = []; + for (var i = 0; i < aCerts.length; ++i) { + var error = false; + var certAttrs = aCerts[i]; + for (var name in certAttrs) { + if (!(name in aCertificate)) { + error = true; + errors.push("Expected attribute '" + name + "' not present in " + + "certificate."); + break; + } + if (aCertificate[name] != certAttrs[name]) { + error = true; + errors.push("Expected certificate attribute '" + name + "' " + + "value incorrect, expected: '" + certAttrs[name] + + "', got: '" + aCertificate[name] + "'."); + break; + } + } + + if (!error) + break; + } + + if (error) { + errors.forEach(Cu.reportError.bind(Cu)); + const certCheckErr = "Certificate checks failed. See previous errors " + + "for details."; + Cu.reportError(certCheckErr); + throw new Ce(certCheckErr, Cr.NS_ERROR_ILLEGAL_VALUE); + } +} + +/** + * Checks if the connection must be HTTPS and if so, only allows built-in + * certificates and validates application specified certificate attribute + * values. + * See bug 340198 and bug 544442. + * + * @param aChannel + * The nsIChannel that will have its certificate checked. + * @param aAllowNonBuiltInCerts (optional) + * When true certificates that aren't builtin are allowed. When false + * or not specified the certificate must be a builtin certificate. + * @param aCerts (optional) + * An array of JS objects with names / values corresponding to the + * channel's expected certificate's attribute names / values. If it + * isn't null or not specified the the scheme for the channel's + * originalURI must be https. + * @throws NS_ERROR_UNEXPECTED if a certificate is expected and the URI scheme + * is not https. + * NS_ERROR_ILLEGAL_VALUE if a certificate attribute name from the + * aCerts param does not exist or the value for a certificate attribute + * from the aCerts param is different than the expected value. + * NS_ERROR_ABORT if the certificate issuer is not built-in. + */ +this.checkCert = + function checkCert(aChannel, aAllowNonBuiltInCerts, aCerts) { + if (!aChannel.originalURI.schemeIs("https")) { + // Require https if there are certificate values to verify + if (aCerts) { + throw new Ce("SSL is required and URI scheme is not https.", + Cr.NS_ERROR_UNEXPECTED); + } + return; + } + + var cert = + aChannel.securityInfo.QueryInterface(Ci.nsISSLStatusProvider). + SSLStatus.QueryInterface(Ci.nsISSLStatus).serverCert; + + validateCert(cert, aCerts); + + if (aAllowNonBuiltInCerts === true) + return; + + var issuerCert = cert; + while (issuerCert.issuer && !issuerCert.issuer.equals(issuerCert)) + issuerCert = issuerCert.issuer; + + const certNotBuiltInErr = "Certificate issuer is not built-in."; + if (!issuerCert) + throw new Ce(certNotBuiltInErr, Cr.NS_ERROR_ABORT); + + var tokenNames = issuerCert.getAllTokenNames({}); + + if (!tokenNames || !tokenNames.some(isBuiltinToken)) + throw new Ce(certNotBuiltInErr, Cr.NS_ERROR_ABORT); +} + +function isBuiltinToken(tokenName) { + return tokenName == "Builtin Object Token"; +} + +/** + * This class implements nsIBadCertListener. Its job is to prevent "bad cert" + * security dialogs from being shown to the user. It is better to simply fail + * if the certificate is bad. See bug 304286. + * + * @param aAllowNonBuiltInCerts (optional) + * When true certificates that aren't builtin are allowed. When false + * or not specified the certificate must be a builtin certificate. + */ +this.BadCertHandler = + function BadCertHandler(aAllowNonBuiltInCerts) { + this.allowNonBuiltInCerts = aAllowNonBuiltInCerts; +} +BadCertHandler.prototype = { + + // nsIChannelEventSink + asyncOnChannelRedirect: function(oldChannel, newChannel, flags, callback) { + if (this.allowNonBuiltInCerts) { + callback.onRedirectVerifyCallback(Components.results.NS_OK); + return; + } + + // make sure the certificate of the old channel checks out before we follow + // a redirect from it. See bug 340198. + // Don't call checkCert for internal redirects. See bug 569648. + if (!(flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL)) + checkCert(oldChannel); + + callback.onRedirectVerifyCallback(Components.results.NS_OK); + }, + + // nsIInterfaceRequestor + getInterface: function(iid) { + return this.QueryInterface(iid); + }, + + // nsISupports + QueryInterface: function(iid) { + if (!iid.equals(Ci.nsIChannelEventSink) && + !iid.equals(Ci.nsIInterfaceRequestor) && + !iid.equals(Ci.nsISupports)) + throw Cr.NS_ERROR_NO_INTERFACE; + return this; + } +}; diff --git a/toolkit/modules/CharsetMenu.jsm b/toolkit/modules/CharsetMenu.jsm new file mode 100644 index 0000000000..f6479c0248 --- /dev/null +++ b/toolkit/modules/CharsetMenu.jsm @@ -0,0 +1,267 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = [ "CharsetMenu" ]; + +const { classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyGetter(this, "gBundle", function() { + const kUrl = "chrome://global/locale/charsetMenu.properties"; + return Services.strings.createBundle(kUrl); +}); + +XPCOMUtils.defineLazyModuleGetter(this, "Deprecated", + "resource://gre/modules/Deprecated.jsm"); + +const kAutoDetectors = [ + ["off", ""], + ["ja", "ja_parallel_state_machine"], + ["ru", "ruprob"], + ["uk", "ukprob"] +]; + +/** + * This set contains encodings that are in the Encoding Standard, except: + * - XSS-dangerous encodings (except ISO-2022-JP which is assumed to be + * too common not to be included). + * - x-user-defined, which practically never makes sense as an end-user-chosen + * override. + * - Encodings that IE11 doesn't have in its correspoding menu. + */ +const kEncodings = new Set([ + // Globally relevant + "UTF-8", + "windows-1252", + // Arabic + "windows-1256", + "ISO-8859-6", + // Baltic + "windows-1257", + "ISO-8859-4", + // "ISO-8859-13", // Hidden since not in menu in IE11 + // Central European + "windows-1250", + "ISO-8859-2", + // Chinese, Simplified + "gbk", + // Chinese, Traditional + "Big5", + // Cyrillic + "windows-1251", + "ISO-8859-5", + "KOI8-R", + "KOI8-U", + "IBM866", // Not in menu in Chromium. Maybe drop this? + // "x-mac-cyrillic", // Not in menu in IE11 or Chromium. + // Greek + "windows-1253", + "ISO-8859-7", + // Hebrew + "windows-1255", + "ISO-8859-8", + // Japanese + "Shift_JIS", + "EUC-JP", + "ISO-2022-JP", + // Korean + "EUC-KR", + // Thai + "windows-874", + // Turkish + "windows-1254", + // Vietnamese + "windows-1258", + // Hiding rare European encodings that aren't in the menu in IE11 and would + // make the menu messy by sorting all over the place + // "ISO-8859-3", + // "ISO-8859-10", + // "ISO-8859-14", + // "ISO-8859-15", + // "ISO-8859-16", + // "macintosh" +]); + +// Always at the start of the menu, in this order, followed by a separator. +const kPinned = [ + "UTF-8", + "windows-1252" +]; + +kPinned.forEach(x => kEncodings.delete(x)); + +function CharsetComparator(a, b) { + // Normal sorting sorts the part in parenthesis in an order that + // happens to make the less frequently-used items first. + let titleA = a.label.replace(/\(.*/, "") + b.value; + let titleB = b.label.replace(/\(.*/, "") + a.value; + // Secondarily reverse sort by encoding name to sort "windows" or + // "shift_jis" first. + return titleA.localeCompare(titleB) || b.value.localeCompare(a.value); +} + +function SetDetector(event) { + let str = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); + str.data = event.target.getAttribute("detector"); + Services.prefs.setComplexValue("intl.charset.detector", Ci.nsISupportsString, str); +} + +function UpdateDetectorMenu(event) { + event.stopPropagation(); + let detector = Services.prefs.getComplexValue("intl.charset.detector", Ci.nsIPrefLocalizedString); + let menuitem = this.getElementsByAttribute("detector", detector).item(0); + if (menuitem) { + menuitem.setAttribute("checked", "true"); + } +} + +var gDetectorInfoCache, gCharsetInfoCache, gPinnedInfoCache; + +var CharsetMenu = { + build: function(parent, deprecatedShowAccessKeys=true, showDetector=true) { + if (!deprecatedShowAccessKeys) { + Deprecated.warning("CharsetMenu no longer supports building a menu with no access keys.", + "https://bugzilla.mozilla.org/show_bug.cgi?id=1088710"); + } + function createDOMNode(doc, nodeInfo) { + let node = doc.createElement("menuitem"); + node.setAttribute("type", "radio"); + node.setAttribute("name", nodeInfo.name + "Group"); + node.setAttribute(nodeInfo.name, nodeInfo.value); + node.setAttribute("label", nodeInfo.label); + if (nodeInfo.accesskey) { + node.setAttribute("accesskey", nodeInfo.accesskey); + } + return node; + } + + if (parent.hasChildNodes()) { + // Detector menu or charset menu already built + return; + } + this._ensureDataReady(); + let doc = parent.ownerDocument; + + if (showDetector) { + let menuNode = doc.createElement("menu"); + menuNode.setAttribute("label", gBundle.GetStringFromName("charsetMenuAutodet")); + menuNode.setAttribute("accesskey", gBundle.GetStringFromName("charsetMenuAutodet.key")); + parent.appendChild(menuNode); + + let menuPopupNode = doc.createElement("menupopup"); + menuNode.appendChild(menuPopupNode); + menuPopupNode.addEventListener("command", SetDetector); + menuPopupNode.addEventListener("popupshown", UpdateDetectorMenu); + + gDetectorInfoCache.forEach(detectorInfo => menuPopupNode.appendChild(createDOMNode(doc, detectorInfo))); + parent.appendChild(doc.createElement("menuseparator")); + } + + gPinnedInfoCache.forEach(charsetInfo => parent.appendChild(createDOMNode(doc, charsetInfo))); + parent.appendChild(doc.createElement("menuseparator")); + gCharsetInfoCache.forEach(charsetInfo => parent.appendChild(createDOMNode(doc, charsetInfo))); + }, + + getData: function() { + this._ensureDataReady(); + return { + detectors: gDetectorInfoCache, + pinnedCharsets: gPinnedInfoCache, + otherCharsets: gCharsetInfoCache + }; + }, + + _ensureDataReady: function() { + if (!gDetectorInfoCache) { + gDetectorInfoCache = this.getDetectorInfo(); + gPinnedInfoCache = this.getCharsetInfo(kPinned, false); + gCharsetInfoCache = this.getCharsetInfo(kEncodings); + } + }, + + getDetectorInfo: function() { + return kAutoDetectors.map(([detectorName, nodeId]) => ({ + label: this._getDetectorLabel(detectorName), + accesskey: this._getDetectorAccesskey(detectorName), + name: "detector", + value: nodeId + })); + }, + + getCharsetInfo: function(charsets, sort=true) { + let list = Array.from(charsets, charset => ({ + label: this._getCharsetLabel(charset), + accesskey: this._getCharsetAccessKey(charset), + name: "charset", + value: charset + })); + + if (sort) { + list.sort(CharsetComparator); + } + return list; + }, + + _getDetectorLabel: function(detector) { + try { + return gBundle.GetStringFromName("charsetMenuAutodet." + detector); + } catch (ex) {} + return detector; + }, + _getDetectorAccesskey: function(detector) { + try { + return gBundle.GetStringFromName("charsetMenuAutodet." + detector + ".key"); + } catch (ex) {} + return ""; + }, + + _getCharsetLabel: function(charset) { + if (charset == "gbk") { + // Localization key has been revised + charset = "gbk.bis"; + } + try { + return gBundle.GetStringFromName(charset); + } catch (ex) {} + return charset; + }, + _getCharsetAccessKey: function(charset) { + if (charset == "gbk") { + // Localization key has been revised + charset = "gbk.bis"; + } + try { + return gBundle.GetStringFromName(charset + ".key"); + } catch (ex) {} + return ""; + }, + + /** + * For substantially similar encodings, treat two encodings as the same + * for the purpose of the check mark. + */ + foldCharset: function(charset) { + switch (charset) { + case "ISO-8859-8-I": + return "windows-1255"; + + case "gb18030": + return "gbk"; + + default: + return charset; + } + }, + + update: function(parent, charset) { + let menuitem = parent.getElementsByAttribute("charset", this.foldCharset(charset)).item(0); + if (menuitem) { + menuitem.setAttribute("checked", "true"); + } + }, +}; + +Object.freeze(CharsetMenu); + diff --git a/toolkit/modules/ClientID.jsm b/toolkit/modules/ClientID.jsm new file mode 100644 index 0000000000..e29e1ee30b --- /dev/null +++ b/toolkit/modules/ClientID.jsm @@ -0,0 +1,231 @@ +/* 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 = ["ClientID"]; + +const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components; + +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "ClientID::"; + +XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", + "resource://services-common/utils.js"); + +XPCOMUtils.defineLazyGetter(this, "gDatareportingPath", () => { + return OS.Path.join(OS.Constants.Path.profileDir, "datareporting"); +}); + +XPCOMUtils.defineLazyGetter(this, "gStateFilePath", () => { + return OS.Path.join(gDatareportingPath, "state.json"); +}); + +const PREF_CACHED_CLIENTID = "toolkit.telemetry.cachedClientID"; + +/** + * Checks if client ID has a valid format. + * + * @param {String} id A string containing the client ID. + * @return {Boolean} True when the client ID has valid format, or False + * otherwise. + */ +function isValidClientID(id) { + const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return UUID_REGEX.test(id); +} + +this.ClientID = Object.freeze({ + /** + * This returns a promise resolving to the the stable client ID we use for + * data reporting (FHR & Telemetry). Previously exising FHR client IDs are + * migrated to this. + * + * WARNING: This functionality is duplicated for Android (see GeckoProfile.getClientId + * for more). There are Java tests (TestGeckoProfile) to ensure the functionality is + * consistent and Gecko tests to come (bug 1249156). However, THIS IS NOT FOOLPROOF. + * Be careful when changing this code and, in particular, the underlying file format. + * + * @return {Promise<string>} The stable client ID. + */ + getClientID: function() { + return ClientIDImpl.getClientID(); + }, + +/** + * Get the client id synchronously without hitting the disk. + * This returns: + * - the current on-disk client id if it was already loaded + * - the client id that we cached into preferences (if any) + * - null otherwise + */ + getCachedClientID: function() { + return ClientIDImpl.getCachedClientID(); + }, + + /** + * Only used for testing. Invalidates the client ID so that it gets read + * again from file. + */ + _reset: function() { + return ClientIDImpl._reset(); + }, +}); + +var ClientIDImpl = { + _clientID: null, + _loadClientIdTask: null, + _saveClientIdTask: null, + _logger: null, + + _loadClientID: function () { + if (this._loadClientIdTask) { + return this._loadClientIdTask; + } + + this._loadClientIdTask = this._doLoadClientID(); + let clear = () => this._loadClientIdTask = null; + this._loadClientIdTask.then(clear, clear); + return this._loadClientIdTask; + }, + + _doLoadClientID: Task.async(function* () { + // As we want to correlate FHR and telemetry data (and move towards unifying the two), + // we first moved the ID management from the FHR implementation to the datareporting + // service, then to a common shared module. + // Consequently, we try to import an existing FHR ID, so we can keep using it. + + // Try to load the client id from the DRS state file first. + try { + let state = yield CommonUtils.readJSON(gStateFilePath); + if (state && this.updateClientID(state.clientID)) { + return this._clientID; + } + } catch (e) { + // fall through to next option + } + + // If we dont have DRS state yet, try to import from the FHR state. + try { + let fhrStatePath = OS.Path.join(OS.Constants.Path.profileDir, "healthreport", "state.json"); + let state = yield CommonUtils.readJSON(fhrStatePath); + if (state && this.updateClientID(state.clientID)) { + this._saveClientID(); + return this._clientID; + } + } catch (e) { + // fall through to next option + } + + // We dont have an id from FHR yet, generate a new ID. + this.updateClientID(CommonUtils.generateUUID()); + this._saveClientIdTask = this._saveClientID(); + + // Wait on persisting the id. Otherwise failure to save the ID would result in + // the client creating and subsequently sending multiple IDs to the server. + // This would appear as multiple clients submitting similar data, which would + // result in orphaning. + yield this._saveClientIdTask; + + return this._clientID; + }), + + /** + * Save the client ID to the client ID file. + * + * @return {Promise} A promise resolved when the client ID is saved to disk. + */ + _saveClientID: Task.async(function* () { + let obj = { clientID: this._clientID }; + yield OS.File.makeDir(gDatareportingPath); + yield CommonUtils.writeJSON(obj, gStateFilePath); + this._saveClientIdTask = null; + }), + + /** + * This returns a promise resolving to the the stable client ID we use for + * data reporting (FHR & Telemetry). Previously exising FHR client IDs are + * migrated to this. + * + * @return {Promise<string>} The stable client ID. + */ + getClientID: function() { + if (!this._clientID) { + return this._loadClientID(); + } + + return Promise.resolve(this._clientID); + }, + + /** + * Get the client id synchronously without hitting the disk. + * This returns: + * - the current on-disk client id if it was already loaded + * - the client id that we cached into preferences (if any) + * - null otherwise + */ + getCachedClientID: function() { + if (this._clientID) { + // Already loaded the client id from disk. + return this._clientID; + } + + // Not yet loaded, return the cached client id if we have one. + let id = Preferences.get(PREF_CACHED_CLIENTID, null); + if (id === null) { + return null; + } + if (!isValidClientID(id)) { + this._log.error("getCachedClientID - invalid client id in preferences, resetting", id); + Preferences.reset(PREF_CACHED_CLIENTID); + return null; + } + return id; + }, + + /* + * Resets the provider. This is for testing only. + */ + _reset: Task.async(function* () { + yield this._loadClientIdTask; + yield this._saveClientIdTask; + this._clientID = null; + }), + + /** + * Sets the client id to the given value and updates the value cached in + * preferences only if the given id is a valid. + * + * @param {String} id A string containing the client ID. + * @return {Boolean} True when the client ID has valid format, or False + * otherwise. + */ + updateClientID: function (id) { + if (!isValidClientID(id)) { + this._log.error("updateClientID - invalid client ID", id); + return false; + } + + this._clientID = id; + Preferences.set(PREF_CACHED_CLIENTID, this._clientID); + return true; + }, + + /** + * A helper for getting access to telemetry logger. + */ + get _log() { + if (!this._logger) { + this._logger = Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX); + } + + return this._logger; + }, +}; diff --git a/toolkit/modules/Color.jsm b/toolkit/modules/Color.jsm new file mode 100644 index 0000000000..00a9bd9534 --- /dev/null +++ b/toolkit/modules/Color.jsm @@ -0,0 +1,85 @@ +/* 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 = ["Color"]; + +/** + * Color class, which describes a color. + * In the future, this object may be extended to allow for conversions between + * different color formats and notations, support transparency. + * + * @param {Number} r Red color component + * @param {Number} g Green color component + * @param {Number} b Blue color component + */ +function Color(r, g, b) { + this.r = r; + this.g = g; + this.b = b; +} + +Color.prototype = { + /** + * Formula from W3C's WCAG 2.0 spec's relative luminance, section 1.4.1, + * http://www.w3.org/TR/WCAG20/. + * + * @return {Number} Relative luminance, represented as number between 0 and 1. + */ + get relativeLuminance() { + let colorArr = [this.r, this.b, this.g].map(color => { + color = parseInt(color, 10); + if (color <= 10) + return color / 255 / 12.92; + return Math.pow(((color / 255) + 0.055) / 1.055, 2.4); + }); + return colorArr[0] * 0.2126 + + colorArr[1] * 0.7152 + + colorArr[2] * 0.0722; + }, + + /** + * @return {Boolean} TRUE if the color value can be considered bright. + */ + get isBright() { + // Note: this is a high enough value to be considered as 'bright', but was + // decided upon empirically. + return this.relativeLuminance > 0.7; + }, + + /** + * Get the contrast ratio between the current color and a second other color. + * A common use case is to express the difference between a foreground and a + * background color in numbers. + * Formula from W3C's WCAG 2.0 spec's contrast ratio, section 1.4.1, + * http://www.w3.org/TR/WCAG20/. + * + * @param {Color} otherColor Color instance to calculate the contrast with + * @return {Number} Contrast ratios can range from 1 to 21, commonly written + * as 1:1 to 21:1. + */ + contrastRatio(otherColor) { + if (!(otherColor instanceof Color)) + throw new TypeError("The first argument should be an instance of Color"); + + let luminance = this.relativeLuminance; + let otherLuminance = otherColor.relativeLuminance; + return (Math.max(luminance, otherLuminance) + 0.05) / + (Math.min(luminance, otherLuminance) + 0.05); + }, + + /** + * Biased method to check if the contrast ratio between two colors is high + * enough to be discernable. + * + * @param {Color} otherColor Color instance to calculate the contrast with + * @return {Boolean} + */ + isContrastRatioAcceptable(otherColor) { + // Note: this is a high enough value to be considered as 'high contrast', + // but was decided upon empirically. + return this.contrastRatio(otherColor) > 3; + } +}; diff --git a/toolkit/modules/Console.jsm b/toolkit/modules/Console.jsm new file mode 100644 index 0000000000..8cf63bcf04 --- /dev/null +++ b/toolkit/modules/Console.jsm @@ -0,0 +1,713 @@ +/* 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"; + +/** + * Define a 'console' API to roughly match the implementation provided by + * Firebug. + * This module helps cases where code is shared between the web and Firefox. + * See also Browser.jsm for an implementation of other web constants to help + * sharing code between the web and firefox; + * + * The API is only be a rough approximation for 3 reasons: + * - The Firebug console API is implemented in many places with differences in + * the implementations, so there isn't a single reference to adhere to + * - The Firebug console is a rich display compared with dump(), so there will + * be many things that we can't replicate + * - The primary use of this API is debugging and error logging so the perfect + * implementation isn't always required (or even well defined) + */ + +this.EXPORTED_SYMBOLS = [ "console", "ConsoleAPI" ]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +var gTimerRegistry = new Map(); + +/** + * String utility to ensure that strings are a specified length. Strings + * that are too long are truncated to the max length and the last char is + * set to "_". Strings that are too short are padded with spaces. + * + * @param {string} aStr + * The string to format to the correct length + * @param {number} aMaxLen + * The maximum allowed length of the returned string + * @param {number} aMinLen (optional) + * The minimum allowed length of the returned string. If undefined, + * then aMaxLen will be used + * @param {object} aOptions (optional) + * An object allowing format customization. Allowed customizations: + * 'truncate' - can take the value "start" to truncate strings from + * the start as opposed to the end or "center" to truncate + * strings in the center. + * 'align' - takes an alignment when padding is needed for MinLen, + * either "start" or "end". Defaults to "start". + * @return {string} + * The original string formatted to fit the specified lengths + */ +function fmt(aStr, aMaxLen, aMinLen, aOptions) { + if (aMinLen == null) { + aMinLen = aMaxLen; + } + if (aStr == null) { + aStr = ""; + } + if (aStr.length > aMaxLen) { + if (aOptions && aOptions.truncate == "start") { + return "_" + aStr.substring(aStr.length - aMaxLen + 1); + } + else if (aOptions && aOptions.truncate == "center") { + let start = aStr.substring(0, (aMaxLen / 2)); + + let end = aStr.substring((aStr.length - (aMaxLen / 2)) + 1); + return start + "_" + end; + } + return aStr.substring(0, aMaxLen - 1) + "_"; + } + if (aStr.length < aMinLen) { + let padding = Array(aMinLen - aStr.length + 1).join(" "); + aStr = (aOptions.align === "end") ? padding + aStr : aStr + padding; + } + return aStr; +} + +/** + * Utility to extract the constructor name of an object. + * Object.toString gives: "[object ?????]"; we want the "?????". + * + * @param {object} aObj + * The object from which to extract the constructor name + * @return {string} + * The constructor name + */ +function getCtorName(aObj) { + if (aObj === null) { + return "null"; + } + if (aObj === undefined) { + return "undefined"; + } + if (aObj.constructor && aObj.constructor.name) { + return aObj.constructor.name; + } + // If that fails, use Objects toString which sometimes gives something + // better than 'Object', and at least defaults to Object if nothing better + return Object.prototype.toString.call(aObj).slice(8, -1); +} + +/** + * Indicates whether an object is a JS or `Components.Exception` error. + * + * @param {object} aThing + The object to check + * @return {boolean} + Is this object an error? + */ +function isError(aThing) { + return aThing && ( + (typeof aThing.name == "string" && + aThing.name.startsWith("NS_ERROR_")) || + getCtorName(aThing).endsWith("Error")); +} + +/** + * A single line stringification of an object designed for use by humans + * + * @param {any} aThing + * The object to be stringified + * @param {boolean} aAllowNewLines + * @return {string} + * A single line representation of aThing, which will generally be at + * most 80 chars long + */ +function stringify(aThing, aAllowNewLines) { + if (aThing === undefined) { + return "undefined"; + } + + if (aThing === null) { + return "null"; + } + + if (isError(aThing)) { + return "Message: " + aThing; + } + + if (typeof aThing == "object") { + let type = getCtorName(aThing); + if (aThing instanceof Components.interfaces.nsIDOMNode && aThing.tagName) { + return debugElement(aThing); + } + type = (type == "Object" ? "" : type + " "); + let json; + try { + json = JSON.stringify(aThing); + } + catch (ex) { + // Can't use a real ellipsis here, because cmd.exe isn't unicode-enabled + json = "{" + Object.keys(aThing).join(":..,") + ":.., " + "}"; + } + return type + json; + } + + if (typeof aThing == "function") { + return aThing.toString().replace(/\s+/g, " "); + } + + let str = aThing.toString(); + if (!aAllowNewLines) { + str = str.replace(/\n/g, "|"); + } + return str; +} + +/** + * Create a simple debug representation of a given element. + * + * @param {nsIDOMElement} aElement + * The element to debug + * @return {string} + * A simple single line representation of aElement + */ +function debugElement(aElement) { + return "<" + aElement.tagName + + (aElement.id ? "#" + aElement.id : "") + + (aElement.className && aElement.className.split ? + "." + aElement.className.split(" ").join(" .") : + "") + + ">"; +} + +/** + * A multi line stringification of an object, designed for use by humans + * + * @param {any} aThing + * The object to be stringified + * @return {string} + * A multi line representation of aThing + */ +function log(aThing) { + if (aThing === null) { + return "null\n"; + } + + if (aThing === undefined) { + return "undefined\n"; + } + + if (typeof aThing == "object") { + let reply = ""; + let type = getCtorName(aThing); + if (type == "Map") { + reply += "Map\n"; + for (let [key, value] of aThing) { + reply += logProperty(key, value); + } + } + else if (type == "Set") { + let i = 0; + reply += "Set\n"; + for (let value of aThing) { + reply += logProperty('' + i, value); + i++; + } + } + else if (isError(aThing)) { + reply += " Message: " + aThing + "\n"; + if (aThing.stack) { + reply += " Stack:\n"; + var frame = aThing.stack; + while (frame) { + reply += " " + frame + "\n"; + frame = frame.caller; + } + } + } + else if (aThing instanceof Components.interfaces.nsIDOMNode && aThing.tagName) { + reply += " " + debugElement(aThing) + "\n"; + } + else { + let keys = Object.getOwnPropertyNames(aThing); + if (keys.length > 0) { + reply += type + "\n"; + keys.forEach(function(aProp) { + reply += logProperty(aProp, aThing[aProp]); + }); + } + else { + reply += type + "\n"; + let root = aThing; + let logged = []; + while (root != null) { + let properties = Object.keys(root); + properties.sort(); + properties.forEach(function(property) { + if (!(property in logged)) { + logged[property] = property; + reply += logProperty(property, aThing[property]); + } + }); + + root = Object.getPrototypeOf(root); + if (root != null) { + reply += ' - prototype ' + getCtorName(root) + '\n'; + } + } + } + } + + return reply; + } + + return " " + aThing.toString() + "\n"; +} + +/** + * Helper for log() which converts a property/value pair into an output + * string + * + * @param {string} aProp + * The name of the property to include in the output string + * @param {object} aValue + * Value assigned to aProp to be converted to a single line string + * @return {string} + * Multi line output string describing the property/value pair + */ +function logProperty(aProp, aValue) { + let reply = ""; + if (aProp == "stack" && typeof value == "string") { + let trace = parseStack(aValue); + reply += formatTrace(trace); + } + else { + reply += " - " + aProp + " = " + stringify(aValue) + "\n"; + } + return reply; +} + +const LOG_LEVELS = { + "all": Number.MIN_VALUE, + "debug": 2, + "log": 3, + "info": 3, + "clear": 3, + "trace": 3, + "timeEnd": 3, + "time": 3, + "group": 3, + "groupEnd": 3, + "dir": 3, + "dirxml": 3, + "warn": 4, + "error": 5, + "off": Number.MAX_VALUE, +}; + +/** + * Helper to tell if a console message of `aLevel` type + * should be logged in stdout and sent to consoles given + * the current maximum log level being defined in `console.maxLogLevel` + * + * @param {string} aLevel + * Console message log level + * @param {string} aMaxLevel {string} + * String identifier (See LOG_LEVELS for possible + * values) that allows to filter which messages + * are logged based on their log level + * @return {boolean} + * Should this message be logged or not? + */ +function shouldLog(aLevel, aMaxLevel) { + return LOG_LEVELS[aMaxLevel] <= LOG_LEVELS[aLevel]; +} + +/** + * Parse a stack trace, returning an array of stack frame objects, where + * each has filename/lineNumber/functionName members + * + * @param {string} aStack + * The serialized stack trace + * @return {object[]} + * Array of { file: "...", line: NNN, call: "..." } objects + */ +function parseStack(aStack) { + let trace = []; + aStack.split("\n").forEach(function(line) { + if (!line) { + return; + } + let at = line.lastIndexOf("@"); + let posn = line.substring(at + 1); + trace.push({ + filename: posn.split(":")[0], + lineNumber: posn.split(":")[1], + functionName: line.substring(0, at) + }); + }); + return trace; +} + +/** + * Format a frame coming from Components.stack such that it can be used by the + * Browser Console, via console-api-log-event notifications. + * + * @param {object} aFrame + * The stack frame from which to begin the walk. + * @param {number=0} aMaxDepth + * Maximum stack trace depth. Default is 0 - no depth limit. + * @return {object[]} + * An array of {filename, lineNumber, functionName, language} objects. + * These objects follow the same format as other console-api-log-event + * messages. + */ +function getStack(aFrame, aMaxDepth = 0) { + if (!aFrame) { + aFrame = Components.stack.caller; + } + let trace = []; + while (aFrame) { + trace.push({ + filename: aFrame.filename, + lineNumber: aFrame.lineNumber, + functionName: aFrame.name, + language: aFrame.language, + }); + if (aMaxDepth == trace.length) { + break; + } + aFrame = aFrame.caller; + } + return trace; +} + +/** + * Take the output from parseStack() and convert it to nice readable + * output + * + * @param {object[]} aTrace + * Array of trace objects as created by parseStack() + * @return {string} Multi line report of the stack trace + */ +function formatTrace(aTrace) { + let reply = ""; + aTrace.forEach(function(frame) { + reply += fmt(frame.filename, 20, 20, { truncate: "start" }) + " " + + fmt(frame.lineNumber, 5, 5) + " " + + fmt(frame.functionName, 75, 0, { truncate: "center" }) + "\n"; + }); + return reply; +} + +/** + * Create a new timer by recording the current time under the specified name. + * + * @param {string} aName + * The name of the timer. + * @param {number} [aTimestamp=Date.now()] + * Optional timestamp that tells when the timer was originally started. + * @return {object} + * The name property holds the timer name and the started property + * holds the time the timer was started. In case of error, it returns + * an object with the single property "error" that contains the key + * for retrieving the localized error message. + */ +function startTimer(aName, aTimestamp) { + let key = aName.toString(); + if (!gTimerRegistry.has(key)) { + gTimerRegistry.set(key, aTimestamp || Date.now()); + } + return { name: aName, started: gTimerRegistry.get(key) }; +} + +/** + * Stop the timer with the specified name and retrieve the elapsed time. + * + * @param {string} aName + * The name of the timer. + * @param {number} [aTimestamp=Date.now()] + * Optional timestamp that tells when the timer was originally stopped. + * @return {object} + * The name property holds the timer name and the duration property + * holds the number of milliseconds since the timer was started. + */ +function stopTimer(aName, aTimestamp) { + let key = aName.toString(); + let duration = (aTimestamp || Date.now()) - gTimerRegistry.get(key); + gTimerRegistry.delete(key); + return { name: aName, duration: duration }; +} + +/** + * Dump a new message header to stdout by taking care of adding an eventual + * prefix + * + * @param {object} aConsole + * ConsoleAPI instance + * @param {string} aLevel + * The string identifier for the message log level + * @param {string} aMessage + * The string message to print to stdout + */ +function dumpMessage(aConsole, aLevel, aMessage) { + aConsole.dump( + "console." + aLevel + ": " + + (aConsole.prefix ? aConsole.prefix + ": " : "") + + aMessage + "\n" + ); +} + +/** + * Create a function which will output a concise level of output when used + * as a logging function + * + * @param {string} aLevel + * A prefix to all output generated from this function detailing the + * level at which output occurred + * @return {function} + * A logging function + * @see createMultiLineDumper() + */ +function createDumper(aLevel) { + return function() { + if (!shouldLog(aLevel, this.maxLogLevel)) { + return; + } + let args = Array.prototype.slice.call(arguments, 0); + let frame = getStack(Components.stack.caller, 1)[0]; + sendConsoleAPIMessage(this, aLevel, frame, args); + let data = args.map(function(arg) { + return stringify(arg, true); + }); + dumpMessage(this, aLevel, data.join(" ")); + }; +} + +/** + * Create a function which will output more detailed level of output when + * used as a logging function + * + * @param {string} aLevel + * A prefix to all output generated from this function detailing the + * level at which output occurred + * @return {function} + * A logging function + * @see createDumper() + */ +function createMultiLineDumper(aLevel) { + return function() { + if (!shouldLog(aLevel, this.maxLogLevel)) { + return; + } + dumpMessage(this, aLevel, ""); + let args = Array.prototype.slice.call(arguments, 0); + let frame = getStack(Components.stack.caller, 1)[0]; + sendConsoleAPIMessage(this, aLevel, frame, args); + args.forEach(function(arg) { + this.dump(log(arg)); + }, this); + }; +} + +/** + * Send a Console API message. This function will send a console-api-log-event + * notification through the nsIObserverService. + * + * @param {object} aConsole + * The instance of ConsoleAPI performing the logging. + * @param {string} aLevel + * Message severity level. This is usually the name of the console method + * that was called. + * @param {object} aFrame + * The youngest stack frame coming from Components.stack, as formatted by + * getStack(). + * @param {array} aArgs + * The arguments given to the console method. + * @param {object} aOptions + * Object properties depend on the console method that was invoked: + * - timer: for time() and timeEnd(). Holds the timer information. + * - groupName: for group(), groupCollapsed() and groupEnd(). + * - stacktrace: for trace(). Holds the array of stack frames as given by + * getStack(). + */ +function sendConsoleAPIMessage(aConsole, aLevel, aFrame, aArgs, aOptions = {}) +{ + let consoleEvent = { + ID: "jsm", + innerID: aConsole.innerID || aFrame.filename, + consoleID: aConsole.consoleID, + level: aLevel, + filename: aFrame.filename, + lineNumber: aFrame.lineNumber, + functionName: aFrame.functionName, + timeStamp: Date.now(), + arguments: aArgs, + prefix: aConsole.prefix, + }; + + consoleEvent.wrappedJSObject = consoleEvent; + + switch (aLevel) { + case "trace": + consoleEvent.stacktrace = aOptions.stacktrace; + break; + case "time": + case "timeEnd": + consoleEvent.timer = aOptions.timer; + break; + case "group": + case "groupCollapsed": + case "groupEnd": + try { + consoleEvent.groupName = Array.prototype.join.call(aArgs, " "); + } + catch (ex) { + Cu.reportError(ex); + Cu.reportError(ex.stack); + return; + } + break; + } + + let ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"] + .getService(Ci.nsIConsoleAPIStorage); + if (ConsoleAPIStorage) { + ConsoleAPIStorage.recordEvent("jsm", null, consoleEvent); + } +} + +/** + * This creates a console object that somewhat replicates Firebug's console + * object + * + * @param {object} aConsoleOptions + * Optional dictionary with a set of runtime console options: + * - prefix {string} : An optional prefix string to be printed before + * the actual logged message + * - maxLogLevel {string} : String identifier (See LOG_LEVELS for + * possible values) that allows to filter which + * messages are logged based on their log level. + * If falsy value, all messages will be logged. + * If wrong value that doesn't match any key of + * LOG_LEVELS, no message will be logged + * - maxLogLevelPref {string} : String pref name which contains the + * level to use for maxLogLevel. If the pref doesn't + * exist or gets removed, the maxLogLevel will default + * to the value passed to this constructor (or "all" + * if it wasn't specified). + * - dump {function} : An optional function to intercept all strings + * written to stdout + * - innerID {string}: An ID representing the source of the message. + * Normally the inner ID of a DOM window. + * - consoleID {string} : String identified for the console, this will + * be passed through the console notifications + * @return {object} + * A console API instance object + */ +function ConsoleAPI(aConsoleOptions = {}) { + // Normalize console options to set default values + // in order to avoid runtime checks on each console method call. + this.dump = aConsoleOptions.dump || dump; + this.prefix = aConsoleOptions.prefix || ""; + this.maxLogLevel = aConsoleOptions.maxLogLevel; + this.innerID = aConsoleOptions.innerID || null; + this.consoleID = aConsoleOptions.consoleID || ""; + + // Setup maxLogLevelPref watching + let updateMaxLogLevel = () => { + if (Services.prefs.getPrefType(aConsoleOptions.maxLogLevelPref) == Services.prefs.PREF_STRING) { + this._maxLogLevel = Services.prefs.getCharPref(aConsoleOptions.maxLogLevelPref).toLowerCase(); + } else { + this._maxLogLevel = this._maxExplicitLogLevel; + } + }; + + if (aConsoleOptions.maxLogLevelPref) { + updateMaxLogLevel(); + Services.prefs.addObserver(aConsoleOptions.maxLogLevelPref, updateMaxLogLevel, false); + } + + // Bind all the functions to this object. + for (let prop in this) { + if (typeof(this[prop]) === "function") { + this[prop] = this[prop].bind(this); + } + } +} + +ConsoleAPI.prototype = { + /** + * The last log level that was specified via the constructor or setter. This + * is used as a fallback if the pref doesn't exist or is removed. + */ + _maxExplicitLogLevel: null, + /** + * The current log level via all methods of setting (pref or via the API). + */ + _maxLogLevel: null, + debug: createMultiLineDumper("debug"), + log: createDumper("log"), + info: createDumper("info"), + warn: createDumper("warn"), + error: createMultiLineDumper("error"), + exception: createMultiLineDumper("error"), + + trace: function Console_trace() { + if (!shouldLog("trace", this.maxLogLevel)) { + return; + } + let args = Array.prototype.slice.call(arguments, 0); + let trace = getStack(Components.stack.caller); + sendConsoleAPIMessage(this, "trace", trace[0], args, + { stacktrace: trace }); + dumpMessage(this, "trace", "\n" + formatTrace(trace)); + }, + clear: function Console_clear() {}, + + dir: createMultiLineDumper("dir"), + dirxml: createMultiLineDumper("dirxml"), + group: createDumper("group"), + groupEnd: createDumper("groupEnd"), + + time: function Console_time() { + if (!shouldLog("time", this.maxLogLevel)) { + return; + } + let args = Array.prototype.slice.call(arguments, 0); + let frame = getStack(Components.stack.caller, 1)[0]; + let timer = startTimer(args[0]); + sendConsoleAPIMessage(this, "time", frame, args, { timer: timer }); + dumpMessage(this, "time", + "'" + timer.name + "' @ " + (new Date())); + }, + + timeEnd: function Console_timeEnd() { + if (!shouldLog("timeEnd", this.maxLogLevel)) { + return; + } + let args = Array.prototype.slice.call(arguments, 0); + let frame = getStack(Components.stack.caller, 1)[0]; + let timer = stopTimer(args[0]); + sendConsoleAPIMessage(this, "timeEnd", frame, args, { timer: timer }); + dumpMessage(this, "timeEnd", + "'" + timer.name + "' " + timer.duration + "ms"); + }, + + get maxLogLevel() { + return this._maxLogLevel || "all"; + }, + + set maxLogLevel(aValue) { + this._maxLogLevel = this._maxExplicitLogLevel = aValue; + }, +}; + +this.console = new ConsoleAPI(); +this.ConsoleAPI = ConsoleAPI; diff --git a/toolkit/modules/DateTimePickerHelper.jsm b/toolkit/modules/DateTimePickerHelper.jsm new file mode 100644 index 0000000000..3986879886 --- /dev/null +++ b/toolkit/modules/DateTimePickerHelper.jsm @@ -0,0 +1,174 @@ +/* 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"; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +const DEBUG = false; +function debug(aStr) { + if (DEBUG) { + dump("-*- DateTimePickerHelper: " + aStr + "\n"); + } +} + +this.EXPORTED_SYMBOLS = [ + "DateTimePickerHelper" +]; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +/* + * DateTimePickerHelper receives message from content side (input box) and + * is reposible for opening, closing and updating the picker. Similary, + * DateTimePickerHelper listens for picker's events and notifies the content + * side (input box) about them. + */ +this.DateTimePickerHelper = { + picker: null, + weakBrowser: null, + + MESSAGES: [ + "FormDateTime:OpenPicker", + "FormDateTime:ClosePicker", + "FormDateTime:UpdatePicker" + ], + + init: function() { + for (let msg of this.MESSAGES) { + Services.mm.addMessageListener(msg, this); + } + }, + + uninit: function() { + for (let msg of this.MESSAGES) { + Services.mm.removeMessageListener(msg, this); + } + }, + + // nsIMessageListener + receiveMessage: function(aMessage) { + debug("receiveMessage: " + aMessage.name); + switch (aMessage.name) { + case "FormDateTime:OpenPicker": { + this.showPicker(aMessage.target, aMessage.data); + break; + } + case "FormDateTime:ClosePicker": { + if (!this.picker) { + return; + } + this.picker.closePicker(); + break; + } + case "FormDateTime:UpdatePicker": { + this.picker.setPopupValue(aMessage.data); + break; + } + default: + break; + } + }, + + // nsIDOMEventListener + handleEvent: function(aEvent) { + debug("handleEvent: " + aEvent.type); + switch (aEvent.type) { + case "DateTimePickerValueChanged": { + this.updateInputBoxValue(aEvent); + break; + } + case "popuphidden": { + let browser = this.weakBrowser ? this.weakBrowser.get() : null; + if (browser) { + browser.messageManager.sendAsyncMessage("FormDateTime:PickerClosed"); + } + this.close(); + break; + } + default: + break; + } + }, + + // Called when picker value has changed, notify input box about it. + updateInputBoxValue: function(aEvent) { + // TODO: parse data based on input type. + const { hour, minute } = aEvent.detail; + debug("hour: " + hour + ", minute: " + minute); + let browser = this.weakBrowser ? this.weakBrowser.get() : null; + if (browser) { + browser.messageManager.sendAsyncMessage( + "FormDateTime:PickerValueChanged", { hour, minute }); + } + }, + + // Get picker from browser and show it anchored to the input box. + showPicker: function(aBrowser, aData) { + let rect = aData.rect; + let dir = aData.dir; + let type = aData.type; + let detail = aData.detail; + + this._anchor = aBrowser.ownerGlobal.gBrowser.popupAnchor; + this._anchor.left = rect.left; + this._anchor.top = rect.top; + this._anchor.width = rect.width; + this._anchor.height = rect.height; + this._anchor.hidden = false; + + debug("Opening picker with details: " + JSON.stringify(detail)); + + let window = aBrowser.ownerDocument.defaultView; + let tabbrowser = window.gBrowser; + if (Services.focus.activeWindow != window || + tabbrowser.selectedBrowser != aBrowser) { + // We were sent a message from a window or tab that went into the + // background, so we'll ignore it for now. + return; + } + + this.weakBrowser = Cu.getWeakReference(aBrowser); + this.picker = aBrowser.dateTimePicker; + if (!this.picker) { + debug("aBrowser.dateTimePicker not found, exiting now."); + return; + } + this.picker.loadPicker(type, detail); + // The arrow panel needs an anchor to work. The popupAnchor (this._anchor) + // is a transparent div that the arrow can point to. + this.picker.openPopup(this._anchor, "after_start", rect.left, rect.top); + + this.addPickerListeners(); + }, + + // Picker is closed, do some cleanup. + close: function() { + this.removePickerListeners(); + this.picker = null; + this.weakBrowser = null; + this._anchor.hidden = true; + }, + + // Listen to picker's event. + addPickerListeners: function() { + if (!this.picker) { + return; + } + this.picker.addEventListener("popuphidden", this); + this.picker.addEventListener("DateTimePickerValueChanged", this); + }, + + // Stop listening to picker's event. + removePickerListeners: function() { + if (!this.picker) { + return; + } + this.picker.removeEventListener("popuphidden", this); + this.picker.removeEventListener("DateTimePickerValueChanged", this); + }, +}; diff --git a/toolkit/modules/DeferredTask.jsm b/toolkit/modules/DeferredTask.jsm new file mode 100644 index 0000000000..f13c71f53c --- /dev/null +++ b/toolkit/modules/DeferredTask.jsm @@ -0,0 +1,301 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ +/* 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 = [ + "DeferredTask", +]; + +/** + * Sets up a function or an asynchronous task whose execution can be triggered + * after a defined delay. Multiple attempts to run the task before the delay + * has passed are coalesced. The task cannot be re-entered while running, but + * can be executed again after a previous run finished. + * + * A common use case occurs when a data structure should be saved into a file + * every time the data changes, using asynchronous calls, and multiple changes + * to the data may happen within a short time: + * + * let saveDeferredTask = new DeferredTask(function* () { + * yield OS.File.writeAtomic(...); + * // Any uncaught exception will be reported. + * }, 2000); + * + * // The task is ready, but will not be executed until requested. + * + * The "arm" method can be used to start the internal timer that will result in + * the eventual execution of the task. Multiple attempts to arm the timer don't + * introduce further delays: + * + * saveDeferredTask.arm(); + * + * // The task will be executed in 2 seconds from now. + * + * yield waitOneSecond(); + * saveDeferredTask.arm(); + * + * // The task will be executed in 1 second from now. + * + * The timer can be disarmed to reset the delay, or just to cancel execution: + * + * saveDeferredTask.disarm(); + * saveDeferredTask.arm(); + * + * // The task will be executed in 2 seconds from now. + * + * When the internal timer fires and the execution of the task starts, the task + * cannot be canceled anymore. It is however possible to arm the timer again + * during the execution of the task, in which case the task will need to finish + * before the timer is started again, thus guaranteeing a time of inactivity + * between executions that is at least equal to the provided delay. + * + * The "finalize" method can be used to ensure that the task terminates + * properly. The promise it returns is resolved only after the last execution + * of the task is finished. To guarantee that the task is executed for the + * last time, the method prevents any attempt to arm the timer again. + * + * If the timer is already armed when the "finalize" method is called, then the + * task is executed immediately. If the task was already running at this point, + * then one last execution from start to finish will happen again, immediately + * after the current execution terminates. If the timer is not armed, the + * "finalize" method only ensures that any running task terminates. + * + * For example, during shutdown, you may want to ensure that any pending write + * is processed, using the latest version of the data if the timer is armed: + * + * AsyncShutdown.profileBeforeChange.addBlocker( + * "Example service: shutting down", + * () => saveDeferredTask.finalize() + * ); + * + * Instead, if you are going to delete the saved data from disk anyways, you + * might as well prevent any pending write from starting, while still ensuring + * that any write that is currently in progress terminates, so that the file is + * not in use anymore: + * + * saveDeferredTask.disarm(); + * saveDeferredTask.finalize().then(() => OS.File.remove(...)) + * .then(null, Components.utils.reportError); + */ + +// Globals + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); + +const Timer = Components.Constructor("@mozilla.org/timer;1", "nsITimer", + "initWithCallback"); + +// DeferredTask + +/** + * Sets up a task whose execution can be triggered after a delay. + * + * @param aTaskFn + * Function or generator function to execute. This argument is passed to + * the "Task.spawn" method every time the task should be executed. This + * task is never re-entered while running. + * @param aDelayMs + * Time between executions, in milliseconds. Multiple attempts to run + * the task before the delay has passed are coalesced. This time of + * inactivity is guaranteed to pass between multiple executions of the + * task, except on finalization, when the task may restart immediately + * after the previous execution finished. + */ +this.DeferredTask = function (aTaskFn, aDelayMs) { + this._taskFn = aTaskFn; + this._delayMs = aDelayMs; +} + +this.DeferredTask.prototype = { + /** + * Function or generator function to execute. + */ + _taskFn: null, + + /** + * Time between executions, in milliseconds. + */ + _delayMs: null, + + /** + * Indicates whether the task is currently requested to start again later, + * regardless of whether it is currently running. + */ + get isArmed() { + return this._armed; + }, + _armed: false, + + /** + * Indicates whether the task is currently running. This is always true when + * read from code inside the task function, but can also be true when read + * from external code, in case the task is an asynchronous generator function. + */ + get isRunning() { + return !!this._runningPromise; + }, + + /** + * Promise resolved when the current execution of the task terminates, or null + * if the task is not currently running. + */ + _runningPromise: null, + + /** + * nsITimer used for triggering the task after a delay, or null in case the + * task is running or there is no task scheduled for execution. + */ + _timer: null, + + /** + * Actually starts the timer with the delay specified on construction. + */ + _startTimer: function () + { + this._timer = new Timer(this._timerCallback.bind(this), this._delayMs, + Ci.nsITimer.TYPE_ONE_SHOT); + }, + + /** + * Requests the execution of the task after the delay specified on + * construction. Multiple calls don't introduce further delays. If the task + * is running, the delay will start when the current execution finishes. + * + * The task will always be executed on a different tick of the event loop, + * even if the delay specified on construction is zero. Multiple "arm" calls + * within the same tick of the event loop are guaranteed to result in a single + * execution of the task. + * + * @note By design, this method doesn't provide a way for the caller to detect + * when the next execution terminates, or collect a result. In fact, + * doing that would often result in duplicate processing or logging. If + * a special operation or error logging is needed on completion, it can + * be better handled from within the task itself, for example using a + * try/catch/finally clause in the task. The "finalize" method can be + * used in the common case of waiting for completion on shutdown. + */ + arm: function () + { + if (this._finalized) { + throw new Error("Unable to arm timer, the object has been finalized."); + } + + this._armed = true; + + // In case the timer callback is running, do not create the timer now, + // because this will be handled by the timer callback itself. Also, the + // timer is not restarted in case it is already running. + if (!this._runningPromise && !this._timer) { + this._startTimer(); + } + }, + + /** + * Cancels any request for a delayed the execution of the task, though the + * task itself cannot be canceled in case it is already running. + * + * This method stops any currently running timer, thus the delay will restart + * from its original value in case the "arm" method is called again. + */ + disarm: function () { + this._armed = false; + if (this._timer) { + // Calling the "cancel" method and discarding the timer reference makes + // sure that the timer callback will not be called later, even if the + // timer thread has already posted the timer event on the main thread. + this._timer.cancel(); + this._timer = null; + } + }, + + /** + * Ensures that any pending task is executed from start to finish, while + * preventing any attempt to arm the timer again. + * + * - If the task is running and the timer is armed, then one last execution + * from start to finish will happen again, immediately after the current + * execution terminates, then the returned promise will be resolved. + * - If the task is running and the timer is not armed, the returned promise + * will be resolved when the current execution terminates. + * - If the task is not running and the timer is armed, then the task is + * started immediately, and the returned promise resolves when the new + * execution terminates. + * - If the task is not running and the timer is not armed, the method returns + * a resolved promise. + * + * @return {Promise} + * @resolves After the last execution of the task is finished. + * @rejects Never. + */ + finalize: function () { + if (this._finalized) { + throw new Error("The object has been already finalized."); + } + this._finalized = true; + + // If the timer is armed, it means that the task is not running but it is + // scheduled for execution. Cancel the timer and run the task immediately. + if (this._timer) { + this.disarm(); + this._timerCallback(); + } + + // Wait for the operation to be completed, or resolve immediately. + if (this._runningPromise) { + return this._runningPromise; + } + return Promise.resolve(); + }, + _finalized: false, + + /** + * Timer callback used to run the delayed task. + */ + _timerCallback: function () + { + let runningDeferred = Promise.defer(); + + // All these state changes must occur at the same time directly inside the + // timer callback, to prevent race conditions and to ensure that all the + // methods behave consistently even if called from inside the task. This + // means that the assignment of "this._runningPromise" must complete before + // the task gets a chance to start. + this._timer = null; + this._armed = false; + this._runningPromise = runningDeferred.promise; + + runningDeferred.resolve(Task.spawn(function* () { + // Execute the provided function asynchronously. + yield Task.spawn(this._taskFn).then(null, Cu.reportError); + + // Now that the task has finished, we check the state of the object to + // determine if we should restart the task again. + if (this._armed) { + if (!this._finalized) { + this._startTimer(); + } else { + // Execute the task again immediately, for the last time. The isArmed + // property should return false while the task is running, and should + // remain false after the last execution terminates. + this._armed = false; + yield Task.spawn(this._taskFn).then(null, Cu.reportError); + } + } + + // Indicate that the execution of the task has finished. This happens + // synchronously with the previous state changes in the function. + this._runningPromise = null; + }.bind(this)).then(null, Cu.reportError)); + }, +}; diff --git a/toolkit/modules/Deprecated.jsm b/toolkit/modules/Deprecated.jsm new file mode 100644 index 0000000000..7491a49387 --- /dev/null +++ b/toolkit/modules/Deprecated.jsm @@ -0,0 +1,81 @@ +/* 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 = [ "Deprecated" ]; + +const Cu = Components.utils; +const Ci = Components.interfaces; +const PREF_DEPRECATION_WARNINGS = "devtools.errorconsole.deprecation_warnings"; + +Cu.import("resource://gre/modules/Services.jsm"); + +// A flag that indicates whether deprecation warnings should be logged. +var logWarnings = Services.prefs.getBoolPref(PREF_DEPRECATION_WARNINGS); + +Services.prefs.addObserver(PREF_DEPRECATION_WARNINGS, + function (aSubject, aTopic, aData) { + logWarnings = Services.prefs.getBoolPref(PREF_DEPRECATION_WARNINGS); + }, false); + +/** + * Build a callstack log message. + * + * @param nsIStackFrame aStack + * A callstack to be converted into a string log message. + */ +function stringifyCallstack (aStack) { + // If aStack is invalid, use Components.stack (ignoring the last frame). + if (!aStack || !(aStack instanceof Ci.nsIStackFrame)) { + aStack = Components.stack.caller; + } + + let frame = aStack.caller; + let msg = ""; + // Get every frame in the callstack. + while (frame) { + msg += frame.filename + " " + frame.lineNumber + + " " + frame.name + "\n"; + frame = frame.caller; + } + return msg; +} + +this.Deprecated = { + /** + * Log a deprecation warning. + * + * @param string aText + * Deprecation warning text. + * @param string aUrl + * A URL pointing to documentation describing deprecation + * and the way to address it. + * @param nsIStackFrame aStack + * An optional callstack. If it is not provided a + * snapshot of the current JavaScript callstack will be + * logged. + */ + warning: function (aText, aUrl, aStack) { + if (!logWarnings) { + return; + } + + // If URL is not provided, report an error. + if (!aUrl) { + Cu.reportError("Error in Deprecated.warning: warnings must " + + "provide a URL documenting this deprecation."); + return; + } + + let textMessage = "DEPRECATION WARNING: " + aText + + "\nYou may find more details about this deprecation at: " + + aUrl + "\n" + + // Append a callstack part to the deprecation message. + stringifyCallstack(aStack); + + // Report deprecation warning. + Cu.reportError(textMessage); + } +}; diff --git a/toolkit/modules/FileUtils.jsm b/toolkit/modules/FileUtils.jsm new file mode 100644 index 0000000000..df17d60a2b --- /dev/null +++ b/toolkit/modules/FileUtils.jsm @@ -0,0 +1,176 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +this.EXPORTED_SYMBOLS = [ "FileUtils" ]; + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; + +XPCOMUtils.defineLazyServiceGetter(this, "gDirService", + "@mozilla.org/file/directory_service;1", + "nsIProperties"); + +this.FileUtils = { + MODE_RDONLY : 0x01, + MODE_WRONLY : 0x02, + MODE_RDWR : 0x04, + MODE_CREATE : 0x08, + MODE_APPEND : 0x10, + MODE_TRUNCATE : 0x20, + + PERMS_FILE : 0o644, + PERMS_DIRECTORY : 0o755, + + /** + * Gets a file at the specified hierarchy under a nsIDirectoryService key. + * @param key + * The Directory Service Key to start from + * @param pathArray + * An array of path components to locate beneath the directory + * specified by |key|. The last item in this array must be the + * leaf name of a file. + * @return nsIFile object for the file specified. The file is NOT created + * if it does not exist, however all required directories along + * the way are. + */ + getFile: function FileUtils_getFile(key, pathArray, followLinks) { + var file = this.getDir(key, pathArray.slice(0, -1), true, followLinks); + file.append(pathArray[pathArray.length - 1]); + return file; + }, + + /** + * Gets a directory at the specified hierarchy under a nsIDirectoryService + * key. + * @param key + * The Directory Service Key to start from + * @param pathArray + * An array of path components to locate beneath the directory + * specified by |key| + * @param shouldCreate + * true if the directory hierarchy specified in |pathArray| + * should be created if it does not exist, false otherwise. + * @param followLinks (optional) + * true if links should be followed, false otherwise. + * @return nsIFile object for the location specified. + */ + getDir: function FileUtils_getDir(key, pathArray, shouldCreate, followLinks) { + var dir = gDirService.get(key, Ci.nsIFile); + for (var i = 0; i < pathArray.length; ++i) { + dir.append(pathArray[i]); + } + + if (shouldCreate) { + try { + dir.create(Ci.nsIFile.DIRECTORY_TYPE, this.PERMS_DIRECTORY); + } catch (ex) { + if (ex.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) { + throw ex; + } + // Ignore the exception due to a directory that already exists. + } + } + + if (!followLinks) + dir.followLinks = false; + return dir; + }, + + /** + * Opens a file output stream for writing. + * @param file + * The file to write to. + * @param modeFlags + * (optional) File open flags. Can be undefined. + * @returns nsIFileOutputStream to write to. + * @note The stream is initialized with the DEFER_OPEN behavior flag. + * See nsIFileOutputStream. + */ + openFileOutputStream: function FileUtils_openFileOutputStream(file, modeFlags) { + var fos = Cc["@mozilla.org/network/file-output-stream;1"]. + createInstance(Ci.nsIFileOutputStream); + return this._initFileOutputStream(fos, file, modeFlags); + }, + + /** + * Opens an atomic file output stream for writing. + * @param file + * The file to write to. + * @param modeFlags + * (optional) File open flags. Can be undefined. + * @returns nsIFileOutputStream to write to. + * @note The stream is initialized with the DEFER_OPEN behavior flag. + * See nsIFileOutputStream. + * OpeanAtomicFileOutputStream is generally better than openSafeFileOutputStream + * baecause flushing is not needed in most of the issues. + */ + openAtomicFileOutputStream: function FileUtils_openAtomicFileOutputStream(file, modeFlags) { + var fos = Cc["@mozilla.org/network/atomic-file-output-stream;1"]. + createInstance(Ci.nsIFileOutputStream); + return this._initFileOutputStream(fos, file, modeFlags); + }, + + /** + * Opens a safe file output stream for writing. + * @param file + * The file to write to. + * @param modeFlags + * (optional) File open flags. Can be undefined. + * @returns nsIFileOutputStream to write to. + * @note The stream is initialized with the DEFER_OPEN behavior flag. + * See nsIFileOutputStream. + */ + openSafeFileOutputStream: function FileUtils_openSafeFileOutputStream(file, modeFlags) { + var fos = Cc["@mozilla.org/network/safe-file-output-stream;1"]. + createInstance(Ci.nsIFileOutputStream); + return this._initFileOutputStream(fos, file, modeFlags); + }, + + _initFileOutputStream: function FileUtils__initFileOutputStream(fos, file, modeFlags) { + if (modeFlags === undefined) + modeFlags = this.MODE_WRONLY | this.MODE_CREATE | this.MODE_TRUNCATE; + fos.init(file, modeFlags, this.PERMS_FILE, fos.DEFER_OPEN); + return fos; + }, + + /** + * Closes an atomic file output stream. + * @param stream + * The stream to close. + */ + closeAtomicFileOutputStream: function FileUtils_closeAtomicFileOutputStream(stream) { + if (stream instanceof Ci.nsISafeOutputStream) { + try { + stream.finish(); + return; + } + catch (e) { + } + } + stream.close(); + }, + + /** + * Closes a safe file output stream. + * @param stream + * The stream to close. + */ + closeSafeFileOutputStream: function FileUtils_closeSafeFileOutputStream(stream) { + if (stream instanceof Ci.nsISafeOutputStream) { + try { + stream.finish(); + return; + } + catch (e) { + } + } + stream.close(); + }, + + File: Components.Constructor("@mozilla.org/file/local;1", Ci.nsILocalFile, "initWithPath") +}; diff --git a/toolkit/modules/Finder.jsm b/toolkit/modules/Finder.jsm new file mode 100644 index 0000000000..c2a9af5b15 --- /dev/null +++ b/toolkit/modules/Finder.jsm @@ -0,0 +1,639 @@ +// vim: set ts=2 sw=2 sts=2 tw=80: +// 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/. + +this.EXPORTED_SYMBOLS = ["Finder", "GetClipboardSearchString"]; + +const { interfaces: Ci, classes: Cc, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Geometry.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "TextToSubURIService", + "@mozilla.org/intl/texttosuburi;1", + "nsITextToSubURI"); +XPCOMUtils.defineLazyServiceGetter(this, "Clipboard", + "@mozilla.org/widget/clipboard;1", + "nsIClipboard"); +XPCOMUtils.defineLazyServiceGetter(this, "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper"); + +const kSelectionMaxLen = 150; +const kMatchesCountLimitPref = "accessibility.typeaheadfind.matchesCountLimit"; + +function Finder(docShell) { + this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance(Ci.nsITypeAheadFind); + this._fastFind.init(docShell); + + this._currentFoundRange = null; + this._docShell = docShell; + this._listeners = []; + this._previousLink = null; + this._searchString = null; + this._highlighter = null; + + docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress) + .addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); + BrowserUtils.getRootWindow(this._docShell).addEventListener("unload", + this.onLocationChange.bind(this, { isTopLevel: true })); +} + +Finder.prototype = { + get iterator() { + if (this._iterator) + return this._iterator; + this._iterator = Cu.import("resource://gre/modules/FinderIterator.jsm", null).FinderIterator; + return this._iterator; + }, + + destroy: function() { + if (this._iterator) + this._iterator.reset(); + let window = this._getWindow(); + if (this._highlighter && window) { + // if we clear all the references before we hide the highlights (in both + // highlighting modes), we simply can't use them to find the ranges we + // need to clear from the selection. + this._highlighter.hide(window); + this._highlighter.clear(window); + } + this.listeners = []; + this._docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress) + .removeProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); + this._listeners = []; + this._currentFoundRange = this._fastFind = this._docShell = this._previousLink = + this._highlighter = null; + }, + + addResultListener: function (aListener) { + if (this._listeners.indexOf(aListener) === -1) + this._listeners.push(aListener); + }, + + removeResultListener: function (aListener) { + this._listeners = this._listeners.filter(l => l != aListener); + }, + + _notify: function (options) { + if (typeof options.storeResult != "boolean") + options.storeResult = true; + + if (options.storeResult) { + this._searchString = options.searchString; + this.clipboardSearchString = options.searchString + } + + let foundLink = this._fastFind.foundLink; + let linkURL = null; + if (foundLink) { + let docCharset = null; + let ownerDoc = foundLink.ownerDocument; + if (ownerDoc) + docCharset = ownerDoc.characterSet; + + linkURL = TextToSubURIService.unEscapeURIForUI(docCharset, foundLink.href); + } + + options.linkURL = linkURL; + options.rect = this._getResultRect(); + options.searchString = this._searchString; + + if (!this.iterator.continueRunning({ + caseSensitive: this._fastFind.caseSensitive, + entireWord: this._fastFind.entireWord, + linksOnly: options.linksOnly, + word: options.searchString + })) { + this.iterator.stop(); + } + + this.highlighter.update(options); + this.requestMatchesCount(options.searchString, options.linksOnly); + + this._outlineLink(options.drawOutline); + + for (let l of this._listeners) { + try { + l.onFindResult(options); + } catch (ex) {} + } + }, + + get searchString() { + if (!this._searchString && this._fastFind.searchString) + this._searchString = this._fastFind.searchString; + return this._searchString; + }, + + get clipboardSearchString() { + return GetClipboardSearchString(this._getWindow() + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsILoadContext)); + }, + + set clipboardSearchString(aSearchString) { + if (!aSearchString || !Clipboard.supportsFindClipboard()) + return; + + ClipboardHelper.copyStringToClipboard(aSearchString, + Ci.nsIClipboard.kFindClipboard); + }, + + set caseSensitive(aSensitive) { + if (this._fastFind.caseSensitive === aSensitive) + return; + this._fastFind.caseSensitive = aSensitive; + this.iterator.reset(); + }, + + set entireWord(aEntireWord) { + if (this._fastFind.entireWord === aEntireWord) + return; + this._fastFind.entireWord = aEntireWord; + this.iterator.reset(); + }, + + get highlighter() { + if (this._highlighter) + return this._highlighter; + + const {FinderHighlighter} = Cu.import("resource://gre/modules/FinderHighlighter.jsm", {}); + return this._highlighter = new FinderHighlighter(this); + }, + + get matchesCountLimit() { + if (typeof this._matchesCountLimit == "number") + return this._matchesCountLimit; + + this._matchesCountLimit = Services.prefs.getIntPref(kMatchesCountLimitPref) || 0; + return this._matchesCountLimit; + }, + + _lastFindResult: null, + + /** + * Used for normal search operations, highlights the first match. + * + * @param aSearchString String to search for. + * @param aLinksOnly Only consider nodes that are links for the search. + * @param aDrawOutline Puts an outline around matched links. + */ + fastFind: function (aSearchString, aLinksOnly, aDrawOutline) { + this._lastFindResult = this._fastFind.find(aSearchString, aLinksOnly); + let searchString = this._fastFind.searchString; + this._notify({ + searchString, + result: this._lastFindResult, + findBackwards: false, + findAgain: false, + drawOutline: aDrawOutline, + linksOnly: aLinksOnly + }); + }, + + /** + * Repeat the previous search. Should only be called after a previous + * call to Finder.fastFind. + * + * @param aFindBackwards Controls the search direction: + * true: before current match, false: after current match. + * @param aLinksOnly Only consider nodes that are links for the search. + * @param aDrawOutline Puts an outline around matched links. + */ + findAgain: function (aFindBackwards, aLinksOnly, aDrawOutline) { + this._lastFindResult = this._fastFind.findAgain(aFindBackwards, aLinksOnly); + let searchString = this._fastFind.searchString; + this._notify({ + searchString, + result: this._lastFindResult, + findBackwards: aFindBackwards, + findAgain: true, + drawOutline: aDrawOutline, + linksOnly: aLinksOnly + }); + }, + + /** + * Forcibly set the search string of the find clipboard to the currently + * selected text in the window, on supported platforms (i.e. OSX). + */ + setSearchStringToSelection: function() { + let searchString = this.getActiveSelectionText(); + + // Empty strings are rather useless to search for. + if (!searchString.length) + return null; + + this.clipboardSearchString = searchString; + return searchString; + }, + + highlight: Task.async(function* (aHighlight, aWord, aLinksOnly) { + yield this.highlighter.highlight(aHighlight, aWord, aLinksOnly); + }), + + getInitialSelection: function() { + this._getWindow().setTimeout(() => { + let initialSelection = this.getActiveSelectionText(); + for (let l of this._listeners) { + try { + l.onCurrentSelection(initialSelection, true); + } catch (ex) {} + } + }, 0); + }, + + getActiveSelectionText: function() { + let focusedWindow = {}; + let focusedElement = + Services.focus.getFocusedElementForWindow(this._getWindow(), true, + focusedWindow); + focusedWindow = focusedWindow.value; + + let selText; + + if (focusedElement instanceof Ci.nsIDOMNSEditableElement && + focusedElement.editor) { + // The user may have a selection in an input or textarea. + selText = focusedElement.editor.selectionController + .getSelection(Ci.nsISelectionController.SELECTION_NORMAL) + .toString(); + } else { + // Look for any selected text on the actual page. + selText = focusedWindow.getSelection().toString(); + } + + if (!selText) + return ""; + + // Process our text to get rid of unwanted characters. + selText = selText.trim().replace(/\s+/g, " "); + let truncLength = kSelectionMaxLen; + if (selText.length > truncLength) { + let truncChar = selText.charAt(truncLength).charCodeAt(0); + if (truncChar >= 0xDC00 && truncChar <= 0xDFFF) + truncLength++; + selText = selText.substr(0, truncLength); + } + + return selText; + }, + + enableSelection: function() { + this._fastFind.setSelectionModeAndRepaint(Ci.nsISelectionController.SELECTION_ON); + this._restoreOriginalOutline(); + }, + + removeSelection: function() { + this._fastFind.collapseSelection(); + this.enableSelection(); + this.highlighter.clear(this._getWindow()); + }, + + focusContent: function() { + // Allow Finder listeners to cancel focusing the content. + for (let l of this._listeners) { + try { + if ("shouldFocusContent" in l && + !l.shouldFocusContent()) + return; + } catch (ex) { + Cu.reportError(ex); + } + } + + let fastFind = this._fastFind; + const fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager); + try { + // Try to find the best possible match that should receive focus and + // block scrolling on focus since find already scrolls. Further + // scrolling is due to user action, so don't override this. + if (fastFind.foundLink) { + fm.setFocus(fastFind.foundLink, fm.FLAG_NOSCROLL); + } else if (fastFind.foundEditable) { + fm.setFocus(fastFind.foundEditable, fm.FLAG_NOSCROLL); + fastFind.collapseSelection(); + } else { + this._getWindow().focus() + } + } catch (e) {} + }, + + onFindbarClose: function() { + this.enableSelection(); + this.highlighter.highlight(false); + this.iterator.reset(); + BrowserUtils.trackToolbarVisibility(this._docShell, "findbar", false); + }, + + onFindbarOpen: function() { + BrowserUtils.trackToolbarVisibility(this._docShell, "findbar", true); + }, + + onModalHighlightChange(useModalHighlight) { + if (this._highlighter) + this._highlighter.onModalHighlightChange(useModalHighlight); + }, + + onHighlightAllChange(highlightAll) { + if (this._highlighter) + this._highlighter.onHighlightAllChange(highlightAll); + if (this._iterator) + this._iterator.reset(); + }, + + keyPress: function (aEvent) { + let controller = this._getSelectionController(this._getWindow()); + + switch (aEvent.keyCode) { + case Ci.nsIDOMKeyEvent.DOM_VK_RETURN: + if (this._fastFind.foundLink) { + let view = this._fastFind.foundLink.ownerDocument.defaultView; + this._fastFind.foundLink.dispatchEvent(new view.MouseEvent("click", { + view: view, + cancelable: true, + bubbles: true, + ctrlKey: aEvent.ctrlKey, + altKey: aEvent.altKey, + shiftKey: aEvent.shiftKey, + metaKey: aEvent.metaKey + })); + } + break; + case Ci.nsIDOMKeyEvent.DOM_VK_TAB: + let direction = Services.focus.MOVEFOCUS_FORWARD; + if (aEvent.shiftKey) { + direction = Services.focus.MOVEFOCUS_BACKWARD; + } + Services.focus.moveFocus(this._getWindow(), null, direction, 0); + break; + case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP: + controller.scrollPage(false); + break; + case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN: + controller.scrollPage(true); + break; + case Ci.nsIDOMKeyEvent.DOM_VK_UP: + controller.scrollLine(false); + break; + case Ci.nsIDOMKeyEvent.DOM_VK_DOWN: + controller.scrollLine(true); + break; + } + }, + + _notifyMatchesCount: function(result = this._currentMatchesCountResult) { + // The `_currentFound` property is only used for internal bookkeeping. + delete result._currentFound; + result.limit = this.matchesCountLimit; + if (result.total == result.limit) + result.total = -1; + + for (let l of this._listeners) { + try { + l.onMatchesCountResult(result); + } catch (ex) {} + } + + this._currentMatchesCountResult = null; + }, + + requestMatchesCount: function(aWord, aLinksOnly) { + if (this._lastFindResult == Ci.nsITypeAheadFind.FIND_NOTFOUND || + this.searchString == "" || !aWord || !this.matchesCountLimit) { + this._notifyMatchesCount({ + total: 0, + current: 0 + }); + return; + } + + let window = this._getWindow(); + this._currentFoundRange = this._fastFind.getFoundRange(); + + let params = { + caseSensitive: this._fastFind.caseSensitive, + entireWord: this._fastFind.entireWord, + linksOnly: aLinksOnly, + word: aWord + }; + if (!this.iterator.continueRunning(params)) + this.iterator.stop(); + + this.iterator.start(Object.assign(params, { + finder: this, + limit: this.matchesCountLimit, + listener: this, + useCache: true, + })).then(() => { + // Without a valid result, there's nothing to notify about. This happens + // when the iterator was started before and won the race. + if (!this._currentMatchesCountResult || !this._currentMatchesCountResult.total) + return; + this._notifyMatchesCount(); + }); + }, + + // FinderIterator listener implementation + + onIteratorRangeFound(range) { + let result = this._currentMatchesCountResult; + if (!result) + return; + + ++result.total; + if (!result._currentFound) { + ++result.current; + result._currentFound = (this._currentFoundRange && + range.startContainer == this._currentFoundRange.startContainer && + range.startOffset == this._currentFoundRange.startOffset && + range.endContainer == this._currentFoundRange.endContainer && + range.endOffset == this._currentFoundRange.endOffset); + } + }, + + onIteratorReset() {}, + + onIteratorRestart({ word, linksOnly }) { + this.requestMatchesCount(word, linksOnly); + }, + + onIteratorStart() { + this._currentMatchesCountResult = { + total: 0, + current: 0, + _currentFound: false + }; + }, + + _getWindow: function () { + if (!this._docShell) + return null; + return this._docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow); + }, + + /** + * Get the bounding selection rect in CSS px relative to the origin of the + * top-level content document. + */ + _getResultRect: function () { + let topWin = this._getWindow(); + let win = this._fastFind.currentWindow; + if (!win) + return null; + + let selection = win.getSelection(); + if (!selection.rangeCount || selection.isCollapsed) { + // The selection can be into an input or a textarea element. + let nodes = win.document.querySelectorAll("input, textarea"); + for (let node of nodes) { + if (node instanceof Ci.nsIDOMNSEditableElement && node.editor) { + try { + let sc = node.editor.selectionController; + selection = sc.getSelection(Ci.nsISelectionController.SELECTION_NORMAL); + if (selection.rangeCount && !selection.isCollapsed) { + break; + } + } catch (e) { + // If this textarea is hidden, then its selection controller might + // not be intialized. Ignore the failure. + } + } + } + } + + if (!selection.rangeCount || selection.isCollapsed) { + return null; + } + + let utils = topWin.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + + let scrollX = {}, scrollY = {}; + utils.getScrollXY(false, scrollX, scrollY); + + for (let frame = win; frame != topWin; frame = frame.parent) { + let rect = frame.frameElement.getBoundingClientRect(); + let left = frame.getComputedStyle(frame.frameElement, "").borderLeftWidth; + let top = frame.getComputedStyle(frame.frameElement, "").borderTopWidth; + scrollX.value += rect.left + parseInt(left, 10); + scrollY.value += rect.top + parseInt(top, 10); + } + let rect = Rect.fromRect(selection.getRangeAt(0).getBoundingClientRect()); + return rect.translate(scrollX.value, scrollY.value); + }, + + _outlineLink: function (aDrawOutline) { + let foundLink = this._fastFind.foundLink; + + // Optimization: We are drawing outlines and we matched + // the same link before, so don't duplicate work. + if (foundLink == this._previousLink && aDrawOutline) + return; + + this._restoreOriginalOutline(); + + if (foundLink && aDrawOutline) { + // Backup original outline + this._tmpOutline = foundLink.style.outline; + this._tmpOutlineOffset = foundLink.style.outlineOffset; + + // Draw pseudo focus rect + // XXX Should we change the following style for FAYT pseudo focus? + // XXX Shouldn't we change default design if outline is visible + // already? + // Don't set the outline-color, we should always use initial value. + foundLink.style.outline = "1px dotted"; + foundLink.style.outlineOffset = "0"; + + this._previousLink = foundLink; + } + }, + + _restoreOriginalOutline: function () { + // Removes the outline around the last found link. + if (this._previousLink) { + this._previousLink.style.outline = this._tmpOutline; + this._previousLink.style.outlineOffset = this._tmpOutlineOffset; + this._previousLink = null; + } + }, + + _getSelectionController: function(aWindow) { + // display: none iframes don't have a selection controller, see bug 493658 + try { + if (!aWindow.innerWidth || !aWindow.innerHeight) + return null; + } catch (e) { + // If getting innerWidth or innerHeight throws, we can't get a selection + // controller. + return null; + } + + // Yuck. See bug 138068. + let docShell = aWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + + let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + return controller; + }, + + // Start of nsIWebProgressListener implementation. + + onLocationChange: function(aWebProgress, aRequest, aLocation, aFlags) { + if (!aWebProgress.isTopLevel) + return; + // Ignore events that don't change the document. + if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) + return; + + // Avoid leaking if we change the page. + this._lastFindResult = this._previousLink = this._currentFoundRange = null; + this.highlighter.onLocationChange(); + this.iterator.reset(); + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference]) +}; + +function GetClipboardSearchString(aLoadContext) { + let searchString = ""; + if (!Clipboard.supportsFindClipboard()) + return searchString; + + try { + let trans = Cc["@mozilla.org/widget/transferable;1"] + .createInstance(Ci.nsITransferable); + trans.init(aLoadContext); + trans.addDataFlavor("text/unicode"); + + Clipboard.getData(trans, Ci.nsIClipboard.kFindClipboard); + + let data = {}; + let dataLen = {}; + trans.getTransferData("text/unicode", data, dataLen); + if (data.value) { + data = data.value.QueryInterface(Ci.nsISupportsString); + searchString = data.toString(); + } + } catch (ex) {} + + return searchString; +} + +this.Finder = Finder; +this.GetClipboardSearchString = GetClipboardSearchString; diff --git a/toolkit/modules/FinderHighlighter.jsm b/toolkit/modules/FinderHighlighter.jsm new file mode 100644 index 0000000000..e2079fd37c --- /dev/null +++ b/toolkit/modules/FinderHighlighter.jsm @@ -0,0 +1,1615 @@ +/* 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 = ["FinderHighlighter"]; + +const { interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Color", "resource://gre/modules/Color.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Rect", "resource://gre/modules/Geometry.jsm"); +XPCOMUtils.defineLazyGetter(this, "kDebug", () => { + const kDebugPref = "findbar.modalHighlight.debug"; + return Services.prefs.getPrefType(kDebugPref) && Services.prefs.getBoolPref(kDebugPref); +}); + +const kContentChangeThresholdPx = 5; +const kBrightTextSampleSize = 5; +const kModalHighlightRepaintLoFreqMs = 100; +const kModalHighlightRepaintHiFreqMs = 16; +const kHighlightAllPref = "findbar.highlightAll"; +const kModalHighlightPref = "findbar.modalHighlight"; +const kFontPropsCSS = ["color", "font-family", "font-kerning", "font-size", + "font-size-adjust", "font-stretch", "font-variant", "font-weight", "line-height", + "letter-spacing", "text-emphasis", "text-orientation", "text-transform", "word-spacing"]; +const kFontPropsCamelCase = kFontPropsCSS.map(prop => { + let parts = prop.split("-"); + return parts.shift() + parts.map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(""); +}); +const kRGBRE = /^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*/i; +// This uuid is used to prefix HTML element IDs in order to make them unique and +// hard to clash with IDs content authors come up with. +const kModalIdPrefix = "cedee4d0-74c5-4f2d-ab43-4d37c0f9d463"; +const kModalOutlineId = kModalIdPrefix + "-findbar-modalHighlight-outline"; +const kOutlineBoxColor = "255,197,53"; +const kOutlineBoxBorderSize = 2; +const kOutlineBoxBorderRadius = 3; +const kModalStyles = { + outlineNode: [ + ["background-color", `rgb(${kOutlineBoxColor})`], + ["background-clip", "padding-box"], + ["border", `${kOutlineBoxBorderSize}px solid`], + ["-moz-border-top-colors", `rgba(${kOutlineBoxColor},.1) rgba(${kOutlineBoxColor},.4) rgba(${kOutlineBoxColor},.7)`], + ["-moz-border-right-colors", `rgba(${kOutlineBoxColor},.1) rgba(${kOutlineBoxColor},.4) rgba(${kOutlineBoxColor},.7)`], + ["-moz-border-bottom-colors", `rgba(${kOutlineBoxColor},.1) rgba(${kOutlineBoxColor},.4) rgba(${kOutlineBoxColor},.7)`], + ["-moz-border-left-colors", `rgba(${kOutlineBoxColor},.1) rgba(${kOutlineBoxColor},.4) rgba(${kOutlineBoxColor},.7)`], + ["border-radius", `${kOutlineBoxBorderRadius}px`], + ["box-shadow", `0 ${kOutlineBoxBorderSize}px 0 0 rgba(0,0,0,.1)`], + ["color", "#000"], + ["display", "-moz-box"], + ["margin", `-${kOutlineBoxBorderSize}px 0 0 -${kOutlineBoxBorderSize}px !important`], + ["overflow", "hidden"], + ["pointer-events", "none"], + ["position", "absolute"], + ["white-space", "nowrap"], + ["will-change", "transform"], + ["z-index", 2] + ], + outlineNodeDebug: [ ["z-index", 2147483647] ], + outlineText: [ + ["margin", "0 !important"], + ["padding", "0 !important"], + ["vertical-align", "top !important"] + ], + maskNode: [ + ["background", "rgba(0,0,0,.25)"], + ["pointer-events", "none"], + ["position", "absolute"], + ["z-index", 1] + ], + maskNodeTransition: [ + ["transition", "background .2s ease-in"] + ], + maskNodeDebug: [ + ["z-index", 2147483646], + ["top", 0], + ["left", 0] + ], + maskNodeBrightText: [ ["background", "rgba(255,255,255,.25)"] ] +}; +const kModalOutlineAnim = { + "keyframes": [ + { transform: "scaleX(1) scaleY(1)" }, + { transform: "scaleX(1.5) scaleY(1.5)", offset: .5, easing: "ease-in" }, + { transform: "scaleX(1) scaleY(1)" } + ], + duration: 50, +}; +const kNSHTML = "http://www.w3.org/1999/xhtml"; + +function mockAnonymousContentNode(domNode) { + return { + setTextContentForElement(id, text) { + (domNode.querySelector("#" + id) || domNode).textContent = text; + }, + getAttributeForElement(id, attrName) { + let node = domNode.querySelector("#" + id) || domNode; + if (!node.hasAttribute(attrName)) + return undefined; + return node.getAttribute(attrName); + }, + setAttributeForElement(id, attrName, attrValue) { + (domNode.querySelector("#" + id) || domNode).setAttribute(attrName, attrValue); + }, + removeAttributeForElement(id, attrName) { + let node = domNode.querySelector("#" + id) || domNode; + if (!node.hasAttribute(attrName)) + return; + node.removeAttribute(attrName); + }, + remove() { + try { + domNode.parentNode.removeChild(domNode); + } catch (ex) {} + }, + setAnimationForElement(id, keyframes, duration) { + return (domNode.querySelector("#" + id) || domNode).animate(keyframes, duration); + }, + setCutoutRectsForElement(id, rects) { + // no-op for now. + } + }; +} + +let gWindows = new WeakMap(); + +/** + * FinderHighlighter class that is used by Finder.jsm to take care of the + * 'Highlight All' feature, which can highlight all find occurrences in a page. + * + * @param {Finder} finder Finder.jsm instance + */ +function FinderHighlighter(finder) { + this._highlightAll = Services.prefs.getBoolPref(kHighlightAllPref); + this._modal = Services.prefs.getBoolPref(kModalHighlightPref); + this.finder = finder; +} + +FinderHighlighter.prototype = { + get iterator() { + if (this._iterator) + return this._iterator; + this._iterator = Cu.import("resource://gre/modules/FinderIterator.jsm", null).FinderIterator; + return this._iterator; + }, + + /** + * Each window is unique, globally, and the relation between an active + * highlighting session and a window is 1:1. + * For each window we track a number of properties which _at least_ consist of + * - {Boolean} detectedGeometryChange Whether the geometry of the found ranges' + * rectangles has changed substantially + * - {Set} dynamicRangesSet Set of ranges that may move around, depending + * on page layout changes and user input + * - {Map} frames Collection of frames that were encountered + * when inspecting the found ranges + * - {Map} modalHighlightRectsMap Collection of ranges and their corresponding + * Rects + * + * @param {nsIDOMWindow} window + * @return {Object} + */ + getForWindow(window, propName = null) { + if (!gWindows.has(window)) { + gWindows.set(window, { + detectedGeometryChange: false, + dynamicRangesSet: new Set(), + frames: new Map(), + modalHighlightRectsMap: new Map(), + previousRangeRectsCount: 0 + }); + } + return gWindows.get(window); + }, + + /** + * Notify all registered listeners that the 'Highlight All' operation finished. + * + * @param {Boolean} highlight Whether highlighting was turned on + */ + notifyFinished(highlight) { + for (let l of this.finder._listeners) { + try { + l.onHighlightFinished(highlight); + } catch (ex) {} + } + }, + + /** + * Toggle highlighting all occurrences of a word in a page. This method will + * be called recursively for each (i)frame inside a page. + * + * @param {Booolean} highlight Whether highlighting should be turned on + * @param {String} word Needle to search for and highlight when found + * @param {Boolean} linksOnly Only consider nodes that are links for the search + * @yield {Promise} that resolves once the operation has finished + */ + highlight: Task.async(function* (highlight, word, linksOnly) { + let window = this.finder._getWindow(); + let dict = this.getForWindow(window); + let controller = this.finder._getSelectionController(window); + let doc = window.document; + this._found = false; + + if (!controller || !doc || !doc.documentElement) { + // Without the selection controller, + // we are unable to (un)highlight any matches + return; + } + + if (highlight) { + let params = { + allowDistance: 1, + caseSensitive: this.finder._fastFind.caseSensitive, + entireWord: this.finder._fastFind.entireWord, + linksOnly, word, + finder: this.finder, + listener: this, + useCache: true, + window + }; + if (this.iterator.isAlreadyRunning(params) || + (this._modal && this.iterator._areParamsEqual(params, dict.lastIteratorParams))) { + return; + } + + if (!this._modal) + dict.visible = true; + yield this.iterator.start(params); + if (this._found) { + this.finder._outlineLink(true); + dict.updateAllRanges = true; + } + } else { + this.hide(window); + + // Removing the highlighting always succeeds, so return true. + this._found = true; + } + + this.notifyFinished({ highlight, found: this._found }); + }), + + // FinderIterator listener implementation + + onIteratorRangeFound(range) { + this.highlightRange(range); + this._found = true; + }, + + onIteratorReset() {}, + + onIteratorRestart() { + this.clear(this.finder._getWindow()); + }, + + onIteratorStart(params) { + let window = this.finder._getWindow(); + let dict = this.getForWindow(window); + // Save a clean params set for use later in the `update()` method. + dict.lastIteratorParams = params; + if (!this._modal) + this.hide(window, this.finder._fastFind.getFoundRange()); + this.clear(window); + }, + + /** + * Add a range to the find selection, i.e. highlight it, and if it's inside an + * editable node, track it. + * + * @param {nsIDOMRange} range Range object to be highlighted + */ + highlightRange(range) { + let node = range.startContainer; + let editableNode = this._getEditableNode(node); + let window = node.ownerDocument.defaultView; + let controller = this.finder._getSelectionController(window); + if (editableNode) { + controller = editableNode.editor.selectionController; + } + + if (this._modal) { + this._modalHighlight(range, controller, window); + } else { + let findSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); + findSelection.addRange(range); + // Check if the range is inside an iframe. + if (window != window.top) { + let dict = this.getForWindow(window.top); + if (!dict.frames.has(window)) + dict.frames.set(window, null); + } + } + + if (editableNode) { + // Highlighting added, so cache this editor, and hook up listeners + // to ensure we deal properly with edits within the highlighting + this._addEditorListeners(editableNode.editor); + } + }, + + /** + * If modal highlighting is enabled, show the dimmed background that will overlay + * the page. + * + * @param {nsIDOMWindow} window The dimmed background will overlay this window. + * Optional, defaults to the finder window. + */ + show(window = null) { + window = (window || this.finder._getWindow()).top; + let dict = this.getForWindow(window); + if (!this._modal || dict.visible) + return; + + dict.visible = true; + + this._maybeCreateModalHighlightNodes(window); + this._addModalHighlightListeners(window); + }, + + /** + * Clear all highlighted matches. If modal highlighting is enabled and + * the outline + dimmed background is currently visible, both will be hidden. + * + * @param {nsIDOMWindow} window The dimmed background will overlay this window. + * Optional, defaults to the finder window. + * @param {nsIDOMRange} skipRange A range that should not be removed from the + * find selection. + * @param {nsIDOMEvent} event When called from an event handler, this will + * be the triggering event. + */ + hide(window, skipRange = null, event = null) { + try { + window = window.top; + } catch (ex) { + Cu.reportError(ex); + return; + } + let dict = this.getForWindow(window); + + let isBusySelecting = dict.busySelecting; + dict.busySelecting = false; + // Do not hide on anything but a left-click. + if (event && event.type == "click" && (event.button !== 0 || event.altKey || + event.ctrlKey || event.metaKey || event.shiftKey || event.relatedTarget || + isBusySelecting || (event.target.localName == "a" && event.target.href))) { + return; + } + + this._clearSelection(this.finder._getSelectionController(window), skipRange); + for (let frame of dict.frames.keys()) + this._clearSelection(this.finder._getSelectionController(frame), skipRange); + + // Next, check our editor cache, for editors belonging to this + // document + if (this._editors) { + let doc = window.document; + for (let x = this._editors.length - 1; x >= 0; --x) { + if (this._editors[x].document == doc) { + this._clearSelection(this._editors[x].selectionController, skipRange); + // We don't need to listen to this editor any more + this._unhookListenersAtIndex(x); + } + } + } + + if (dict.modalRepaintScheduler) { + window.clearTimeout(dict.modalRepaintScheduler); + dict.modalRepaintScheduler = null; + } + dict.lastWindowDimensions = null; + + if (dict.modalHighlightOutline) { + dict.modalHighlightOutline.setAttributeForElement(kModalOutlineId, "style", + dict.modalHighlightOutline.getAttributeForElement(kModalOutlineId, "style") + + "; opacity: 0"); + } + + this._removeHighlightAllMask(window); + this._removeModalHighlightListeners(window); + + dict.visible = false; + }, + + /** + * Called by the Finder after a find result comes in; update the position and + * content of the outline to the newly found occurrence. + * To make sure that the outline covers the found range completely, all the + * CSS styles that influence the text are copied and applied to the outline. + * + * @param {Object} data Dictionary coming from Finder that contains the + * following properties: + * {Number} result One of the nsITypeAheadFind.FIND_* constants + * indicating the result of a search operation. + * {Boolean} findBackwards If TRUE, the search was performed backwards, + * FALSE if forwards. + * {Boolean} findAgain If TRUE, the search was performed using the same + * search string as before. + * {String} linkURL If a link was hit, this will contain a URL string. + * {Rect} rect An object with top, left, width and height + * coordinates of the current selection. + * {String} searchString The string the search was performed with. + * {Boolean} storeResult Indicator if the search string should be stored + * by the consumer of the Finder. + */ + update(data) { + let window = this.finder._getWindow(); + let dict = this.getForWindow(window); + let foundRange = this.finder._fastFind.getFoundRange(); + + // Place the match placeholder on top of the current found range. + if (data.result == Ci.nsITypeAheadFind.FIND_NOTFOUND || !data.searchString || !foundRange) { + this.hide(window); + return; + } + + if (!this._modal) { + if (this._highlightAll) { + dict.currentFoundRange = foundRange; + let params = this.iterator.params; + if (dict.visible && this.iterator._areParamsEqual(params, dict.lastIteratorParams)) + return; + if (!dict.visible && !params) + params = {word: data.searchString, linksOnly: data.linksOnly}; + if (params) + this.highlight(true, params.word, params.linksOnly); + } + return; + } + + if (foundRange !== dict.currentFoundRange || data.findAgain) { + dict.currentFoundRange = foundRange; + + let textContent = this._getRangeContentArray(foundRange); + if (!textContent.length) { + this.hide(window); + return; + } + + if (data.findAgain) + dict.updateAllRanges = true; + + if (!dict.visible) + this.show(window); + else + this._maybeCreateModalHighlightNodes(window); + } + + let outlineNode = dict.modalHighlightOutline; + if (outlineNode) { + if (dict.animation) + dict.animation.finish(); + dict.animation = outlineNode.setAnimationForElement(kModalOutlineId, + Cu.cloneInto(kModalOutlineAnim.keyframes, window), kModalOutlineAnim.duration); + dict.animation.onfinish = () => dict.animation = null; + } + + if (this._highlightAll) + this.highlight(true, data.searchString, data.linksOnly); + }, + + /** + * Invalidates the list by clearing the map of highlighted ranges that we + * keep to build the mask for. + */ + clear(window = null) { + if (!window || !window.top) + return; + + let dict = this.getForWindow(window.top); + if (dict.animation) + dict.animation.finish(); + dict.dynamicRangesSet.clear(); + dict.frames.clear(); + dict.modalHighlightRectsMap.clear(); + dict.brightText = null; + }, + + /** + * When the current page is refreshed or navigated away from, the CanvasFrame + * contents is not valid anymore, i.e. all anonymous content is destroyed. + * We need to clear the references we keep, which'll make sure we redraw + * everything when the user starts to find in page again. + */ + onLocationChange() { + let window = this.finder._getWindow(); + if (!window || !window.top) + return; + this.hide(window); + let dict = this.getForWindow(window); + this.clear(window); + dict.currentFoundRange = dict.lastIteratorParams = null; + + if (!dict.modalHighlightOutline) + return; + + if (kDebug) { + dict.modalHighlightOutline.remove(); + } else { + try { + window.document.removeAnonymousContent(dict.modalHighlightOutline); + } catch (ex) {} + } + + dict.modalHighlightOutline = null; + }, + + /** + * When `kModalHighlightPref` pref changed during a session, this callback is + * invoked. When modal highlighting is turned off, we hide the CanvasFrame + * contents. + * + * @param {Boolean} useModalHighlight + */ + onModalHighlightChange(useModalHighlight) { + let window = this.finder._getWindow(); + if (window && this._modal && !useModalHighlight) { + this.hide(window); + this.clear(window); + } + this._modal = useModalHighlight; + }, + + /** + * When 'Highlight All' is toggled during a session, this callback is invoked + * and when it's turned off, the found occurrences will be removed from the mask. + * + * @param {Boolean} highlightAll + */ + onHighlightAllChange(highlightAll) { + this._highlightAll = highlightAll; + if (!highlightAll) { + let window = this.finder._getWindow(); + if (!this._modal) + this.hide(window); + this.clear(window); + this._scheduleRepaintOfMask(window); + } + }, + + /** + * Utility; removes all ranges from the find selection that belongs to a + * controller. Optionally skips a specific range. + * + * @param {nsISelectionController} controller + * @param {nsIDOMRange} restoreRange + */ + _clearSelection(controller, restoreRange = null) { + if (!controller) + return; + let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); + sel.removeAllRanges(); + if (restoreRange) { + sel = controller.getSelection(Ci.nsISelectionController.SELECTION_NORMAL); + sel.addRange(restoreRange); + controller.setDisplaySelection(Ci.nsISelectionController.SELECTION_ATTENTION); + controller.repaintSelection(Ci.nsISelectionController.SELECTION_NORMAL); + } + }, + + /** + * Utility; get the nsIDOMWindowUtils for a window. + * + * @param {nsIDOMWindow} window Optional, defaults to the finder window. + * @return {nsIDOMWindowUtils} + */ + _getDWU(window = null) { + return (window || this.finder._getWindow()) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + }, + + /** + * Utility; returns the bounds of the page relative to the viewport. + * If the pages is part of a frameset or inside an iframe of any kind, its + * offset is accounted for. + * Geometry.jsm takes care of the DOMRect calculations. + * + * @param {nsIDOMWindow} window Window to read the boundary rect from + * @param {Boolean} [includeScroll] Whether to ignore the scroll offset, + * which is useful for comparing DOMRects. + * Optional, defaults to `true` + * @return {Rect} + */ + _getRootBounds(window, includeScroll = true) { + let dwu = this._getDWU(window.top); + let cssPageRect = Rect.fromRect(dwu.getRootBounds()); + let scrollX = {}; + let scrollY = {}; + if (includeScroll && window == window.top) { + dwu.getScrollXY(false, scrollX, scrollY); + cssPageRect.translate(scrollX.value, scrollY.value); + } + + // If we're in a frame, update the position of the rect (top/ left). + let currWin = window; + while (currWin != window.top) { + // Since the frame is an element inside a parent window, we'd like to + // learn its position relative to it. + let el = this._getDWU(currWin).containerElement; + currWin = currWin.parent; + dwu = this._getDWU(currWin); + let parentRect = Rect.fromRect(dwu.getBoundsWithoutFlushing(el)); + + if (includeScroll) { + dwu.getScrollXY(false, scrollX, scrollY); + parentRect.translate(scrollX.value, scrollY.value); + // If the current window is an iframe with scrolling="no" and its parent + // is also an iframe the scroll offsets from the parents' documentElement + // (inverse scroll position) needs to be subtracted from the parent + // window rect. + if (el.getAttribute("scrolling") == "no" && currWin != window.top) { + let docEl = currWin.document.documentElement; + parentRect.translate(-docEl.scrollLeft, -docEl.scrollTop); + } + } + + cssPageRect.translate(parentRect.left, parentRect.top); + } + + return cssPageRect; + }, + + /** + * Utility; fetch the full width and height of the current window, excluding + * scrollbars. + * + * @param {nsiDOMWindow} window The current finder window. + * @return {Object} The current full page dimensions with `width` and `height` + * properties + */ + _getWindowDimensions(window) { + // First we'll try without flushing layout, because it's way faster. + let dwu = this._getDWU(window); + let { width, height } = dwu.getRootBounds(); + + if (!width || !height) { + // We need a flush after all :'( + width = window.innerWidth + window.scrollMaxX - window.scrollMinX; + height = window.innerHeight + window.scrollMaxY - window.scrollMinY; + + let scrollbarHeight = {}; + let scrollbarWidth = {}; + dwu.getScrollbarSize(false, scrollbarWidth, scrollbarHeight); + width -= scrollbarWidth.value; + height -= scrollbarHeight.value; + } + + return { width, height }; + }, + + /** + * Utility; fetch the current text contents of a given range. + * + * @param {nsIDOMRange} range Range object to extract the contents from. + * @return {Array} Snippets of text. + */ + _getRangeContentArray(range) { + let content = range.cloneContents(); + let textContent = []; + for (let node of content.childNodes) { + textContent.push(node.textContent || node.nodeValue); + } + return textContent; + }, + + /** + * Utility; get all available font styles as applied to the content of a given + * range. The CSS properties we look for can be found in `kFontPropsCSS`. + * + * @param {nsIDOMRange} range Range to fetch style info from. + * @return {Object} Dictionary consisting of the styles that were found. + */ + _getRangeFontStyle(range) { + let node = range.startContainer; + while (node.nodeType != 1) + node = node.parentNode; + let style = node.ownerDocument.defaultView.getComputedStyle(node, ""); + let props = {}; + for (let prop of kFontPropsCamelCase) { + if (prop in style && style[prop]) + props[prop] = style[prop]; + } + return props; + }, + + /** + * Utility; transform a dictionary object as returned by `_getRangeFontStyle` + * above into a HTML style attribute value. + * + * @param {Object} fontStyle + * @return {String} + */ + _getHTMLFontStyle(fontStyle) { + let style = []; + for (let prop of Object.getOwnPropertyNames(fontStyle)) { + let idx = kFontPropsCamelCase.indexOf(prop); + if (idx == -1) + continue; + style.push(`${kFontPropsCSS[idx]}: ${fontStyle[prop]}`); + } + return style.join("; "); + }, + + /** + * Transform a style definition array as defined in `kModalStyles` into a CSS + * string that can be used to set the 'style' property of a DOM node. + * + * @param {Array} stylePairs Two-dimensional array of style pairs + * @param {...Array} [additionalStyles] Optional set of style pairs that will + * augment or override the styles defined + * by `stylePairs` + * @return {String} + */ + _getStyleString(stylePairs, ...additionalStyles) { + let baseStyle = new Map(stylePairs); + for (let additionalStyle of additionalStyles) { + for (let [prop, value] of additionalStyle) + baseStyle.set(prop, value); + } + return [...baseStyle].map(([cssProp, cssVal]) => `${cssProp}: ${cssVal}`).join("; "); + }, + + /** + * Checks whether a CSS RGB color value can be classified as being 'bright'. + * + * @param {String} cssColor RGB color value in the default format rgb[a](r,g,b) + * @return {Boolean} + */ + _isColorBright(cssColor) { + cssColor = cssColor.match(kRGBRE); + if (!cssColor || !cssColor.length) + return false; + cssColor.shift(); + return new Color(...cssColor).isBright; + }, + + /** + * Detects if the overall text color in the page can be described as bright. + * This is done according to the following algorithm: + * 1. With the entire set of ranges that we have found thusfar; + * 2. Get an odd-numbered `sampleSize`, with a maximum of `kBrightTextSampleSize` + * ranges, + * 3. Slice the set of ranges into `sampleSize` number of equal parts, + * 4. Grab the first range for each slice and inspect the brightness of the + * color of its text content. + * 5. When the majority of ranges are counted as contain bright colored text, + * the page is considered to contain bright text overall. + * + * @param {Object} dict Dictionary of properties belonging to the + * currently active window. The page text color property + * will be recorded in `dict.brightText` as `true` or `false`. + */ + _detectBrightText(dict) { + let sampleSize = Math.min(dict.modalHighlightRectsMap.size, kBrightTextSampleSize); + let ranges = [...dict.modalHighlightRectsMap.keys()]; + let rangesCount = ranges.length; + // Make sure the sample size is an odd number. + if (sampleSize % 2 == 0) { + // Make the currently found range weigh heavier. + if (dict.currentFoundRange) { + ranges.push(dict.currentFoundRange); + ++sampleSize; + ++rangesCount; + } else { + --sampleSize; + } + } + let brightCount = 0; + for (let i = 0; i < sampleSize; ++i) { + let range = ranges[Math.floor((rangesCount / sampleSize) * i)]; + let fontStyle = this._getRangeFontStyle(range); + if (this._isColorBright(fontStyle.color)) + ++brightCount; + } + + dict.brightText = (brightCount >= Math.ceil(sampleSize / 2)); + }, + + /** + * Checks if a range is inside a DOM node that's positioned in a way that it + * doesn't scroll along when the document is scrolled and/ or zoomed. This + * is the case for 'fixed' and 'sticky' positioned elements, elements inside + * (i)frames and elements that have their overflow styles set to 'auto' or + * 'scroll'. + * + * @param {nsIDOMRange} range Range that be enclosed in a dynamic container + * @return {Boolean} + */ + _isInDynamicContainer(range) { + const kFixed = new Set(["fixed", "sticky", "scroll", "auto"]); + let node = range.startContainer; + while (node.nodeType != 1) + node = node.parentNode; + let document = node.ownerDocument; + let window = document.defaultView; + let dict = this.getForWindow(window.top); + + // Check if we're in a frameset (including iframes). + if (window != window.top) { + if (!dict.frames.has(window)) + dict.frames.set(window, null); + return true; + } + + do { + let style = window.getComputedStyle(node, null); + if (kFixed.has(style.position) || kFixed.has(style.overflow) || + kFixed.has(style.overflowX) || kFixed.has(style.overflowY)) { + return true; + } + node = node.parentNode; + } while (node && node != document.documentElement) + + return false; + }, + + /** + * Read and store the rectangles that encompass the entire region of a range + * for use by the drawing function of the highlighter. + * + * @param {nsIDOMRange} range Range to fetch the rectangles from + * @param {Object} [dict] Dictionary of properties belonging to + * the currently active window + * @return {Set} Set of rects that were found for the range + */ + _getRangeRects(range, dict = null) { + let window = range.startContainer.ownerDocument.defaultView; + let bounds; + // If the window is part of a frameset, try to cache the bounds query. + if (dict && dict.frames.has(window)) { + bounds = dict.frames.get(window); + if (!bounds) { + bounds = this._getRootBounds(window); + dict.frames.set(window, bounds); + } + } else + bounds = this._getRootBounds(window); + + let topBounds = this._getRootBounds(window.top, false); + let rects = []; + // A range may consist of multiple rectangles, we can also do these kind of + // precise cut-outs. range.getBoundingClientRect() returns the fully + // encompassing rectangle, which is too much for our purpose here. + for (let rect of range.getClientRects()) { + rect = Rect.fromRect(rect); + rect.x += bounds.x; + rect.y += bounds.y; + // If the rect is not even visible from the top document, we can ignore it. + if (rect.intersects(topBounds)) + rects.push(rect); + } + return rects; + }, + + /** + * Read and store the rectangles that encompass the entire region of a range + * for use by the drawing function of the highlighter and store them in the + * cache. + * + * @param {nsIDOMRange} range Range to fetch the rectangles from + * @param {Boolean} [checkIfDynamic] Whether we should check if the range + * is dynamic as per the rules in + * `_isInDynamicContainer()`. Optional, + * defaults to `true` + * @param {Object} [dict] Dictionary of properties belonging to + * the currently active window + * @return {Set} Set of rects that were found for the range + */ + _updateRangeRects(range, checkIfDynamic = true, dict = null) { + let window = range.startContainer.ownerDocument.defaultView; + let rects = this._getRangeRects(range, dict); + + // Only fetch the rect at this point, if not passed in as argument. + dict = dict || this.getForWindow(window.top); + let oldRects = dict.modalHighlightRectsMap.get(range); + dict.modalHighlightRectsMap.set(range, rects); + // Check here if we suddenly went down to zero rects from more than zero before, + // which indicates that we should re-iterate the document. + if (oldRects && oldRects.length && !rects.length) + dict.detectedGeometryChange = true; + if (checkIfDynamic && this._isInDynamicContainer(range)) + dict.dynamicRangesSet.add(range); + return rects; + }, + + /** + * Re-read the rectangles of the ranges that we keep track of separately, + * because they're enclosed by a position: fixed container DOM node or (i)frame. + * + * @param {Object} dict Dictionary of properties belonging to the currently + * active window + */ + _updateDynamicRangesRects(dict) { + // Reset the frame bounds cache. + for (let frame of dict.frames.keys()) + dict.frames.set(frame, null); + for (let range of dict.dynamicRangesSet) + this._updateRangeRects(range, false, dict); + }, + + /** + * Update the content, position and style of the yellow current found range + * outline that floats atop the mask with the dimmed background. + * Rebuild it, if necessary, This will deactivate the animation between + * occurrences. + * + * @param {Object} dict Dictionary of properties belonging to the + * currently active window + * @param {Array} [textContent] Array of text that's inside the range. Optional, + * defaults to `null` + * @param {Object} [fontStyle] Dictionary of CSS styles in camelCase as + * returned by `_getRangeFontStyle()`. Optional + */ + _updateRangeOutline(dict, textContent = null, fontStyle = null) { + let range = dict.currentFoundRange; + if (!range) + return; + + fontStyle = fontStyle || this._getRangeFontStyle(range); + // Text color in the outline is determined by kModalStyles. + delete fontStyle.color; + + let rects = this._getRangeRects(range); + textContent = textContent || this._getRangeContentArray(range); + + let outlineAnonNode = dict.modalHighlightOutline; + let rectCount = rects.length; + // (re-)Building the outline is conditional and happens when one of the + // following conditions is met: + // 1. No outline nodes were built before, or + // 2. When the amount of rectangles to draw is different from before, or + // 3. When there's more than one rectangle to draw, because it's impossible + // to animate that consistently with AnonymousContent nodes. + let rebuildOutline = (!outlineAnonNode || rectCount !== dict.previousRangeRectsCount || + rectCount != 1); + dict.previousRangeRectsCount = rectCount; + + let window = range.startContainer.ownerDocument.defaultView.top; + let document = window.document; + // First see if we need to and can remove the previous outline nodes. + if (rebuildOutline && outlineAnonNode) { + if (kDebug) { + outlineAnonNode.remove(); + } else { + try { + document.removeAnonymousContent(outlineAnonNode); + } catch (ex) {} + } + dict.modalHighlightOutline = null; + } + + // Abort when there's no text to highlight. + if (!textContent.length) + return; + + let outlineBox; + if (rebuildOutline) { + // Create the main (yellow) highlight outline box. + outlineBox = document.createElementNS(kNSHTML, "div"); + outlineBox.setAttribute("id", kModalOutlineId); + } + + const kModalOutlineTextId = kModalOutlineId + "-text"; + let i = 0; + for (let rect of rects) { + // if the current rect is the last rect, then text is set to the rest of + // the textContent with single spaces injected between the text. Otherwise + // text is set to the current textContent for the matching rect. + let text = (i == rectCount - 1) ? textContent.slice(i).join(" ") : textContent[i]; + + // Next up is to check of the outline box' borders will not overlap with + // rects that we drew before or will draw after this one. + // We're taking the width of the border into account, which is + // `kOutlineBoxBorderSize` pixels. + // When left and/ or right sides will overlap with the current, previous + // or next rect, make sure to make the necessary adjustments to the style. + // These adjustments will override the styles as defined in `kModalStyles.outlineNode`. + let intersectingSides = new Set(); + let previous = rects[i - 1]; + if (previous && + rect.left - previous.right <= 2 * kOutlineBoxBorderSize) { + intersectingSides.add("left"); + } + let next = rects[i + 1]; + if (next && + next.left - rect.right <= 2 * kOutlineBoxBorderSize) { + intersectingSides.add("right"); + } + let borderStyles = [...intersectingSides].map(side => [ "border-" + side, 0 ]); + if (intersectingSides.size) { + borderStyles.push([ "margin", `-${kOutlineBoxBorderSize}px 0 0 ${ + intersectingSides.has("left") ? 0 : -kOutlineBoxBorderSize}px !important`]); + borderStyles.push([ "border-radius", + (intersectingSides.has("left") ? 0 : kOutlineBoxBorderRadius) + "px " + + (intersectingSides.has("right") ? 0 : kOutlineBoxBorderRadius) + "px " + + (intersectingSides.has("right") ? 0 : kOutlineBoxBorderRadius) + "px " + + (intersectingSides.has("left") ? 0 : kOutlineBoxBorderRadius) + "px" ]); + } + + ++i; + let outlineStyle = this._getStyleString(kModalStyles.outlineNode, [ + ["top", rect.top + "px"], + ["left", rect.left + "px"], + ["height", rect.height + "px"], + ["width", rect.width + "px"] + ], borderStyles, kDebug ? kModalStyles.outlineNodeDebug : []); + fontStyle.lineHeight = rect.height + "px"; + let textStyle = this._getStyleString(kModalStyles.outlineText) + "; " + + this._getHTMLFontStyle(fontStyle); + + if (rebuildOutline) { + let textBoxParent = (rectCount == 1) ? outlineBox : + outlineBox.appendChild(document.createElementNS(kNSHTML, "div")); + textBoxParent.setAttribute("style", outlineStyle); + + let textBox = document.createElementNS(kNSHTML, "span"); + if (rectCount == 1) + textBox.setAttribute("id", kModalOutlineTextId); + textBox.setAttribute("style", textStyle); + textBox.textContent = text; + textBoxParent.appendChild(textBox); + } else { + // Set the appropriate properties on the existing nodes, which will also + // activate the transitions. + outlineAnonNode.setAttributeForElement(kModalOutlineId, "style", outlineStyle); + outlineAnonNode.setAttributeForElement(kModalOutlineTextId, "style", textStyle); + outlineAnonNode.setTextContentForElement(kModalOutlineTextId, text); + } + } + + if (rebuildOutline) { + dict.modalHighlightOutline = kDebug ? + mockAnonymousContentNode((document.body || + document.documentElement).appendChild(outlineBox)) : + document.insertAnonymousContent(outlineBox); + } + }, + + /** + * Add a range to the list of ranges to highlight on, or cut out of, the dimmed + * background. + * + * @param {nsIDOMRange} range Range object that should be inspected + * @param {nsIDOMWindow} window Window object, whose DOM tree is being traversed + */ + _modalHighlight(range, controller, window) { + if (!this._getRangeContentArray(range).length) + return; + + this._updateRangeRects(range); + + this.show(window); + // We don't repaint the mask right away, but pass it off to a render loop of + // sorts. + this._scheduleRepaintOfMask(window); + }, + + /** + * Lazily insert the nodes we need as anonymous content into the CanvasFrame + * of a window. + * + * @param {nsIDOMWindow} window Window to draw in. + */ + _maybeCreateModalHighlightNodes(window) { + window = window.top; + let dict = this.getForWindow(window); + if (dict.modalHighlightOutline) { + if (!dict.modalHighlightAllMask) { + // Make sure to at least show the dimmed background. + this._repaintHighlightAllMask(window, false); + this._scheduleRepaintOfMask(window); + } else { + this._scheduleRepaintOfMask(window, { scrollOnly: true }); + } + return; + } + + let document = window.document; + // A hidden document doesn't accept insertAnonymousContent calls yet. + if (document.hidden) { + let onVisibilityChange = () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + this._maybeCreateModalHighlightNodes(window); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return; + } + + // Make sure to at least show the dimmed background. + this._repaintHighlightAllMask(window, false); + }, + + /** + * Build and draw the mask that takes care of the dimmed background that + * overlays the current page and the mask that cuts out all the rectangles of + * the ranges that were found. + * + * @param {nsIDOMWindow} window Window to draw in. + * @param {Boolean} [paintContent] + */ + _repaintHighlightAllMask(window, paintContent = true) { + window = window.top; + let dict = this.getForWindow(window); + + const kMaskId = kModalIdPrefix + "-findbar-modalHighlight-outlineMask"; + if (!dict.modalHighlightAllMask) { + let document = window.document; + let maskNode = document.createElementNS(kNSHTML, "div"); + maskNode.setAttribute("id", kMaskId); + dict.modalHighlightAllMask = kDebug ? + mockAnonymousContentNode((document.body || document.documentElement).appendChild(maskNode)) : + document.insertAnonymousContent(maskNode); + } + + // Make sure the dimmed mask node takes the full width and height that's available. + let {width, height} = dict.lastWindowDimensions = this._getWindowDimensions(window); + if (typeof dict.brightText != "boolean" || dict.updateAllRanges) + this._detectBrightText(dict); + let maskStyle = this._getStyleString(kModalStyles.maskNode, + [ ["width", width + "px"], ["height", height + "px"] ], + dict.brightText ? kModalStyles.maskNodeBrightText : [], + paintContent ? kModalStyles.maskNodeTransition : [], + kDebug ? kModalStyles.maskNodeDebug : []); + dict.modalHighlightAllMask.setAttributeForElement(kMaskId, "style", maskStyle); + + this._updateRangeOutline(dict); + + let allRects = []; + if (paintContent || dict.modalHighlightAllMask) { + this._updateDynamicRangesRects(dict); + + let DOMRect = window.DOMRect; + for (let [range, rects] of dict.modalHighlightRectsMap) { + if (dict.updateAllRanges) + rects = this._updateRangeRects(range); + + // If a geometry change was detected, we bail out right away here, because + // the current set of ranges has been invalidated. + if (dict.detectedGeometryChange) + return; + + for (let rect of rects) + allRects.push(new DOMRect(rect.x, rect.y, rect.width, rect.height)); + } + dict.updateAllRanges = false; + } + + dict.modalHighlightAllMask.setCutoutRectsForElement(kMaskId, allRects); + }, + + /** + * Safely remove the mask AnoymousContent node from the CanvasFrame. + * + * @param {nsIDOMWindow} window + */ + _removeHighlightAllMask(window) { + window = window.top; + let dict = this.getForWindow(window); + if (!dict.modalHighlightAllMask) + return; + + // If the current window isn't the one the content was inserted into, this + // will fail, but that's fine. + if (kDebug) { + dict.modalHighlightAllMask.remove(); + } else { + try { + window.document.removeAnonymousContent(dict.modalHighlightAllMask); + } catch (ex) {} + } + dict.modalHighlightAllMask = null; + }, + + /** + * Doing a full repaint each time a range is delivered by the highlight iterator + * is way too costly, thus we pipe the frequency down to every + * `kModalHighlightRepaintLoFreqMs` milliseconds. If there are dynamic ranges + * found (see `_isInDynamicContainer()` for the definition), the frequency + * will be upscaled to `kModalHighlightRepaintHiFreqMs`. + * + * @param {nsIDOMWindow} window + * @param {Object} options Dictionary of painter hints that contains the + * following properties: + * {Boolean} contentChanged Whether the documents' content changed in the + * meantime. This happens when the DOM is updated + * whilst the page is loaded. + * {Boolean} scrollOnly TRUE when the page has scrolled in the meantime, + * which means that the dynamically positioned + * elements need to be repainted. + * {Boolean} updateAllRanges Whether to recalculate the rects of all ranges + * that were found up until now. + */ + _scheduleRepaintOfMask(window, { contentChanged, scrollOnly, updateAllRanges } = + { contentChanged: false, scrollOnly: false, updateAllRanges: false }) { + if (!this._modal) + return; + + window = window.top; + let dict = this.getForWindow(window); + let hasDynamicRanges = !!dict.dynamicRangesSet.size; + let repaintDynamicRanges = ((scrollOnly || contentChanged) && hasDynamicRanges); + + // When we request to repaint unconditionally, we mean to call + // `_repaintHighlightAllMask()` right after the timeout. + if (!dict.unconditionalRepaintRequested) + dict.unconditionalRepaintRequested = !contentChanged || repaintDynamicRanges; + // Some events, like a resize, call for recalculation of all the rects of all ranges. + if (!dict.updateAllRanges) + dict.updateAllRanges = updateAllRanges; + + if (dict.modalRepaintScheduler) + return; + + dict.modalRepaintScheduler = window.setTimeout(() => { + dict.modalRepaintScheduler = null; + + let { width: previousWidth, height: previousHeight } = dict.lastWindowDimensions; + let { width, height } = dict.lastWindowDimensions = this._getWindowDimensions(window); + let pageContentChanged = dict.detectedGeometryChange || + (Math.abs(previousWidth - width) > kContentChangeThresholdPx || + Math.abs(previousHeight - height) > kContentChangeThresholdPx); + dict.detectedGeometryChange = false; + // When the page has changed significantly enough in size, we'll restart + // the iterator with the same parameters as before to find us new ranges. + if (pageContentChanged) + this.iterator.restart(this.finder); + + if (dict.unconditionalRepaintRequested || + (dict.modalHighlightRectsMap.size && pageContentChanged)) { + dict.unconditionalRepaintRequested = false; + this._repaintHighlightAllMask(window); + } + }, hasDynamicRanges ? kModalHighlightRepaintHiFreqMs : kModalHighlightRepaintLoFreqMs); + }, + + /** + * Add event listeners to the content which will cause the modal highlight + * AnonymousContent to be re-painted or hidden. + * + * @param {nsIDOMWindow} window + */ + _addModalHighlightListeners(window) { + window = window.top; + let dict = this.getForWindow(window); + if (dict.highlightListeners) + return; + + window = window.top; + dict.highlightListeners = [ + this._scheduleRepaintOfMask.bind(this, window, { contentChanged: true }), + this._scheduleRepaintOfMask.bind(this, window, { updateAllRanges: true }), + this._scheduleRepaintOfMask.bind(this, window, { scrollOnly: true }), + this.hide.bind(this, window, null), + () => dict.busySelecting = true + ]; + let target = this.iterator._getDocShell(window).chromeEventHandler; + target.addEventListener("MozAfterPaint", dict.highlightListeners[0]); + target.addEventListener("resize", dict.highlightListeners[1]); + target.addEventListener("scroll", dict.highlightListeners[2]); + target.addEventListener("click", dict.highlightListeners[3]); + target.addEventListener("selectstart", dict.highlightListeners[4]); + }, + + /** + * Remove event listeners from content. + * + * @param {nsIDOMWindow} window + */ + _removeModalHighlightListeners(window) { + window = window.top; + let dict = this.getForWindow(window); + if (!dict.highlightListeners) + return; + + let target = this.iterator._getDocShell(window).chromeEventHandler; + target.removeEventListener("MozAfterPaint", dict.highlightListeners[0]); + target.removeEventListener("resize", dict.highlightListeners[1]); + target.removeEventListener("scroll", dict.highlightListeners[2]); + target.removeEventListener("click", dict.highlightListeners[3]); + target.removeEventListener("selectstart", dict.highlightListeners[4]); + + dict.highlightListeners = null; + }, + + /** + * For a given node returns its editable parent or null if there is none. + * It's enough to check if node is a text node and its parent's parent is + * instance of nsIDOMNSEditableElement. + * + * @param node the node we want to check + * @returns the first node in the parent chain that is editable, + * null if there is no such node + */ + _getEditableNode(node) { + if (node.nodeType === node.TEXT_NODE && node.parentNode && node.parentNode.parentNode && + node.parentNode.parentNode instanceof Ci.nsIDOMNSEditableElement) { + return node.parentNode.parentNode; + } + return null; + }, + + /** + * Add ourselves as an nsIEditActionListener and nsIDocumentStateListener for + * a given editor + * + * @param editor the editor we'd like to listen to + */ + _addEditorListeners(editor) { + if (!this._editors) { + this._editors = []; + this._stateListeners = []; + } + + let existingIndex = this._editors.indexOf(editor); + if (existingIndex == -1) { + let x = this._editors.length; + this._editors[x] = editor; + this._stateListeners[x] = this._createStateListener(); + this._editors[x].addEditActionListener(this); + this._editors[x].addDocumentStateListener(this._stateListeners[x]); + } + }, + + /** + * Helper method to unhook listeners, remove cached editors + * and keep the relevant arrays in sync + * + * @param idx the index into the array of editors/state listeners + * we wish to remove + */ + _unhookListenersAtIndex(idx) { + this._editors[idx].removeEditActionListener(this); + this._editors[idx] + .removeDocumentStateListener(this._stateListeners[idx]); + this._editors.splice(idx, 1); + this._stateListeners.splice(idx, 1); + if (!this._editors.length) { + delete this._editors; + delete this._stateListeners; + } + }, + + /** + * Remove ourselves as an nsIEditActionListener and + * nsIDocumentStateListener from a given cached editor + * + * @param editor the editor we no longer wish to listen to + */ + _removeEditorListeners(editor) { + // editor is an editor that we listen to, so therefore must be + // cached. Find the index of this editor + let idx = this._editors.indexOf(editor); + if (idx == -1) { + return; + } + // Now unhook ourselves, and remove our cached copy + this._unhookListenersAtIndex(idx); + }, + + /* + * nsIEditActionListener logic follows + * + * We implement this interface to allow us to catch the case where + * the findbar found a match in a HTML <input> or <textarea>. If the + * user adjusts the text in some way, it will no longer match, so we + * want to remove the highlight, rather than have it expand/contract + * when letters are added or removed. + */ + + /** + * Helper method used to check whether a selection intersects with + * some highlighting + * + * @param selectionRange the range from the selection to check + * @param findRange the highlighted range to check against + * @returns true if they intersect, false otherwise + */ + _checkOverlap(selectionRange, findRange) { + if (!selectionRange || !findRange) + return false; + // The ranges overlap if one of the following is true: + // 1) At least one of the endpoints of the deleted selection + // is in the find selection + // 2) At least one of the endpoints of the find selection + // is in the deleted selection + if (findRange.isPointInRange(selectionRange.startContainer, + selectionRange.startOffset)) + return true; + if (findRange.isPointInRange(selectionRange.endContainer, + selectionRange.endOffset)) + return true; + if (selectionRange.isPointInRange(findRange.startContainer, + findRange.startOffset)) + return true; + if (selectionRange.isPointInRange(findRange.endContainer, + findRange.endOffset)) + return true; + + return false; + }, + + /** + * Helper method to determine if an edit occurred within a highlight + * + * @param selection the selection we wish to check + * @param node the node we want to check is contained in selection + * @param offset the offset into node that we want to check + * @returns the range containing (node, offset) or null if no ranges + * in the selection contain it + */ + _findRange(selection, node, offset) { + let rangeCount = selection.rangeCount; + let rangeidx = 0; + let foundContainingRange = false; + let range = null; + + // Check to see if this node is inside one of the selection's ranges + while (!foundContainingRange && rangeidx < rangeCount) { + range = selection.getRangeAt(rangeidx); + if (range.isPointInRange(node, offset)) { + foundContainingRange = true; + break; + } + rangeidx++; + } + + if (foundContainingRange) { + return range; + } + + return null; + }, + + // Start of nsIEditActionListener implementations + + WillDeleteText(textNode, offset, length) { + let editor = this._getEditableNode(textNode).editor; + let controller = editor.selectionController; + let fSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); + let range = this._findRange(fSelection, textNode, offset); + + if (range) { + // Don't remove the highlighting if the deleted text is at the + // end of the range + if (textNode != range.endContainer || + offset != range.endOffset) { + // Text within the highlight is being removed - the text can + // no longer be a match, so remove the highlighting + fSelection.removeRange(range); + if (fSelection.rangeCount == 0) { + this._removeEditorListeners(editor); + } + } + } + }, + + DidInsertText(textNode, offset, aString) { + let editor = this._getEditableNode(textNode).editor; + let controller = editor.selectionController; + let fSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); + let range = this._findRange(fSelection, textNode, offset); + + if (range) { + // If the text was inserted before the highlight + // adjust the highlight's bounds accordingly + if (textNode == range.startContainer && + offset == range.startOffset) { + range.setStart(range.startContainer, + range.startOffset+aString.length); + } else if (textNode != range.endContainer || + offset != range.endOffset) { + // The edit occurred within the highlight - any addition of text + // will result in the text no longer being a match, + // so remove the highlighting + fSelection.removeRange(range); + if (fSelection.rangeCount == 0) { + this._removeEditorListeners(editor); + } + } + } + }, + + WillDeleteSelection(selection) { + let editor = this._getEditableNode(selection.getRangeAt(0) + .startContainer).editor; + let controller = editor.selectionController; + let fSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); + + let selectionIndex = 0; + let findSelectionIndex = 0; + let shouldDelete = {}; + let numberOfDeletedSelections = 0; + let numberOfMatches = fSelection.rangeCount; + + // We need to test if any ranges in the deleted selection (selection) + // are in any of the ranges of the find selection + // Usually both selections will only contain one range, however + // either may contain more than one. + + for (let fIndex = 0; fIndex < numberOfMatches; fIndex++) { + shouldDelete[fIndex] = false; + let fRange = fSelection.getRangeAt(fIndex); + + for (let index = 0; index < selection.rangeCount; index++) { + if (shouldDelete[fIndex]) { + continue; + } + + let selRange = selection.getRangeAt(index); + let doesOverlap = this._checkOverlap(selRange, fRange); + if (doesOverlap) { + shouldDelete[fIndex] = true; + numberOfDeletedSelections++; + } + } + } + + // OK, so now we know what matches (if any) are in the selection + // that is being deleted. Time to remove them. + if (!numberOfDeletedSelections) { + return; + } + + for (let i = numberOfMatches - 1; i >= 0; i--) { + if (shouldDelete[i]) + fSelection.removeRange(fSelection.getRangeAt(i)); + } + + // Remove listeners if no more highlights left + if (!fSelection.rangeCount) { + this._removeEditorListeners(editor); + } + }, + + /* + * nsIDocumentStateListener logic follows + * + * When attaching nsIEditActionListeners, there are no guarantees + * as to whether the findbar or the documents in the browser will get + * destructed first. This leads to the potential to either leak, or to + * hold on to a reference an editable element's editor for too long, + * preventing it from being destructed. + * + * However, when an editor's owning node is being destroyed, the editor + * sends out a DocumentWillBeDestroyed notification. We can use this to + * clean up our references to the object, to allow it to be destroyed in a + * timely fashion. + */ + + /** + * Unhook ourselves when one of our state listeners has been called. + * This can happen in 4 cases: + * 1) The document the editor belongs to is navigated away from, and + * the document is not being cached + * + * 2) The document the editor belongs to is expired from the cache + * + * 3) The tab containing the owning document is closed + * + * 4) The <input> or <textarea> that owns the editor is explicitly + * removed from the DOM + * + * @param the listener that was invoked + */ + _onEditorDestruction(aListener) { + // First find the index of the editor the given listener listens to. + // The listeners and editors arrays must always be in sync. + // The listener will be in our array of cached listeners, as this + // method could not have been called otherwise. + let idx = 0; + while (this._stateListeners[idx] != aListener) { + idx++; + } + + // Unhook both listeners + this._unhookListenersAtIndex(idx); + }, + + /** + * Creates a unique document state listener for an editor. + * + * It is not possible to simply have the findbar implement the + * listener interface itself, as it wouldn't have sufficient information + * to work out which editor was being destroyed. Therefore, we create new + * listeners on the fly, and cache them in sync with the editors they + * listen to. + */ + _createStateListener() { + return { + findbar: this, + + QueryInterface: function(iid) { + if (iid.equals(Ci.nsIDocumentStateListener) || + iid.equals(Ci.nsISupports)) + return this; + + throw Components.results.NS_ERROR_NO_INTERFACE; + }, + + NotifyDocumentWillBeDestroyed: function() { + this.findbar._onEditorDestruction(this); + }, + + // Unimplemented + notifyDocumentCreated: function() {}, + notifyDocumentStateChanged: function(aDirty) {} + }; + } +}; diff --git a/toolkit/modules/FinderIterator.jsm b/toolkit/modules/FinderIterator.jsm new file mode 100644 index 0000000000..15404b0129 --- /dev/null +++ b/toolkit/modules/FinderIterator.jsm @@ -0,0 +1,657 @@ +/* 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 = ["FinderIterator"]; + +const { interfaces: Ci, classes: Cc, utils: Cu } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NLP", "resource://gre/modules/NLP.jsm"); + +const kDebug = false; +const kIterationSizeMax = 100; +const kTimeoutPref = "findbar.iteratorTimeout"; + +/** + * FinderIterator singleton. See the documentation for the `start()` method to + * learn more. + */ +this.FinderIterator = { + _currentParams: null, + _listeners: new Map(), + _catchingUp: new Set(), + _previousParams: null, + _previousRanges: [], + _spawnId: 0, + _timeout: Services.prefs.getIntPref(kTimeoutPref), + _timer: null, + ranges: [], + running: false, + + // Expose `kIterationSizeMax` to the outside world for unit tests to use. + get kIterationSizeMax() { return kIterationSizeMax }, + + get params() { + if (!this._currentParams && !this._previousParams) + return null; + return Object.assign({}, this._currentParams || this._previousParams); + }, + + /** + * Start iterating the active Finder docShell, using the options below. When + * it already started at the request of another consumer, we first yield the + * results we already collected before continuing onward to yield fresh results. + * We make sure to pause every `kIterationSizeMax` iterations to make sure we + * don't block the host process too long. In the case of a break like this, we + * yield `undefined`, instead of a range. + * Upon re-entrance after a break, we check if `stop()` was called during the + * break and if so, we stop iterating. + * Results are also passed to the `listener.onIteratorRangeFound` callback + * method, along with a flag that specifies if the result comes from the cache + * or is fresh. The callback also adheres to the `limit` flag. + * The returned promise is resolved when 1) the limit is reached, 2) when all + * the ranges have been found or 3) when `stop()` is called whilst iterating. + * + * @param {Number} [options.allowDistance] Allowed edit distance between the + * current word and `options.word` + * when the iterator is already running + * @param {Boolean} options.caseSensitive Whether to search in case sensitive + * mode + * @param {Boolean} options.entireWord Whether to search in entire-word mode + * @param {Finder} options.finder Currently active Finder instance + * @param {Number} [options.limit] Limit the amount of results to be + * passed back. Optional, defaults to no + * limit. + * @param {Boolean} [options.linksOnly] Only yield ranges that are inside a + * hyperlink (used by QuickFind). + * Optional, defaults to `false`. + * @param {Object} options.listener Listener object that implements the + * following callback functions: + * - onIteratorRangeFound({nsIDOMRange} range); + * - onIteratorReset(); + * - onIteratorRestart({Object} iterParams); + * - onIteratorStart({Object} iterParams); + * @param {Boolean} [options.useCache] Whether to allow results already + * present in the cache or demand fresh. + * Optional, defaults to `false`. + * @param {String} options.word Word to search for + * @return {Promise} + */ + start({ allowDistance, caseSensitive, entireWord, finder, limit, linksOnly, listener, useCache, word }) { + // Take care of default values for non-required options. + if (typeof allowDistance != "number") + allowDistance = 0; + if (typeof limit != "number") + limit = -1; + if (typeof linksOnly != "boolean") + linksOnly = false; + if (typeof useCache != "boolean") + useCache = false; + + // Validate the options. + if (typeof caseSensitive != "boolean") + throw new Error("Missing required option 'caseSensitive'"); + if (typeof entireWord != "boolean") + throw new Error("Missing required option 'entireWord'"); + if (!finder) + throw new Error("Missing required option 'finder'"); + if (!word) + throw new Error("Missing required option 'word'"); + if (typeof listener != "object" || !listener.onIteratorRangeFound) + throw new TypeError("Missing valid, required option 'listener'"); + + // If the listener was added before, make sure the promise is resolved before + // we replace it with another. + if (this._listeners.has(listener)) { + let { onEnd } = this._listeners.get(listener); + if (onEnd) + onEnd(); + } + + let window = finder._getWindow(); + let resolver; + let promise = new Promise(resolve => resolver = resolve); + let iterParams = { caseSensitive, entireWord, linksOnly, useCache, window, word }; + + this._listeners.set(listener, { limit, onEnd: resolver }); + + // If we're not running anymore and we're requesting the previous result, use it. + if (!this.running && this._previousResultAvailable(iterParams)) { + this._yieldPreviousResult(listener, window); + return promise; + } + + if (this.running) { + // Double-check if we're not running the iterator with a different set of + // parameters, otherwise report an error with the most common reason. + if (!this._areParamsEqual(this._currentParams, iterParams, allowDistance)) { + if (kDebug) { + Cu.reportError(`We're currently iterating over '${this._currentParams.word}', not '${word}'\n` + + new Error().stack); + } + this._listeners.delete(listener); + resolver(); + return promise; + } + + // if we're still running, yield the set we have built up this far. + this._yieldIntermediateResult(listener, window); + + return promise; + } + + // Start! + this.running = true; + this._currentParams = iterParams; + this._findAllRanges(finder, ++this._spawnId); + + return promise; + }, + + /** + * Stop the currently running iterator as soon as possible and optionally cache + * the result for later. + * + * @param {Boolean} [cachePrevious] Whether to save the result for later. + * Optional. + */ + stop(cachePrevious = false) { + if (!this.running) + return; + + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + if (this._runningFindResolver) { + this._runningFindResolver(); + this._runningFindResolver = null; + } + + if (cachePrevious) { + this._previousRanges = [].concat(this.ranges); + this._previousParams = Object.assign({}, this._currentParams); + } else { + this._previousRanges = []; + this._previousParams = null; + } + + this._catchingUp.clear(); + this._currentParams = null; + this.ranges = []; + this.running = false; + + for (let [, { onEnd }] of this._listeners) + onEnd(); + }, + + /** + * Stops the iteration that currently running, if it is, and start a new one + * with the exact same params as before. + * + * @param {Finder} finder Currently active Finder instance + */ + restart(finder) { + // Capture current iterator params before we stop the show. + let iterParams = this.params; + if (!iterParams) + return; + this.stop(); + + // Restart manually. + this.running = true; + this._currentParams = iterParams; + + this._findAllRanges(finder, ++this._spawnId); + this._notifyListeners("restart", iterParams); + }, + + /** + * Reset the internal state of the iterator. Typically this would be called + * when the docShell is not active anymore, which makes the current and cached + * previous result invalid. + * If the iterator is running, it will be stopped as soon as possible. + */ + reset() { + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + if (this._runningFindResolver) { + this._runningFindResolver(); + this._runningFindResolver = null; + } + + this._catchingUp.clear(); + this._currentParams = this._previousParams = null; + this._previousRanges = []; + this.ranges = []; + this.running = false; + + this._notifyListeners("reset"); + for (let [, { onEnd }] of this._listeners) + onEnd(); + this._listeners.clear(); + }, + + /** + * Check if the currently running iterator parameters are the same as the ones + * passed through the arguments. When `true`, we can keep it running as-is and + * the consumer should stop the iterator when `false`. + * + * @param {Boolean} options.caseSensitive Whether to search in case sensitive + * mode + * @param {Boolean} options.entireWord Whether to search in entire-word mode + * @param {Boolean} options.linksOnly Whether to search for the word to be + * present in links only + * @param {String} options.word The word being searched for + * @return {Boolean} + */ + continueRunning({ caseSensitive, entireWord, linksOnly, word }) { + return (this.running && + this._currentParams.caseSensitive === caseSensitive && + this._currentParams.entireWord === entireWord && + this._currentParams.linksOnly === linksOnly && + this._currentParams.word == word); + }, + + /** + * The default mode of operation of the iterator is to not accept duplicate + * listeners, resolve the promise of the older listeners and replace it with + * the new listener. + * Consumers may opt-out of this behavior by using this check and not call + * start(). + * + * @param {Object} paramSet Property bag with the same signature as you would + * pass into `start()` + * @return {Boolean} + */ + isAlreadyRunning(paramSet) { + return (this.running && + this._areParamsEqual(this._currentParams, paramSet) && + this._listeners.has(paramSet.listener)); + }, + + /** + * Safely notify all registered listeners that an event has occurred. + * + * @param {String} callback Name of the callback to invoke + * @param {mixed} [params] Optional argument that will be passed to the + * callback + * @param {Iterable} [listeners] Set of listeners to notify. Optional, defaults + * to `this._listeners.keys()`. + */ + _notifyListeners(callback, params, listeners = this._listeners.keys()) { + callback = "onIterator" + callback.charAt(0).toUpperCase() + callback.substr(1); + for (let listener of listeners) { + try { + listener[callback](params); + } catch (ex) { + Cu.reportError("FinderIterator Error: " + ex); + } + } + }, + + /** + * Internal; check if an iteration request is available in the previous result + * that we cached. + * + * @param {Boolean} options.caseSensitive Whether to search in case sensitive + * mode + * @param {Boolean} options.entireWord Whether to search in entire-word mode + * @param {Boolean} options.linksOnly Whether to search for the word to be + * present in links only + * @param {Boolean} options.useCache Whether the consumer wants to use the + * cached previous result at all + * @param {String} options.word The word being searched for + * @return {Boolean} + */ + _previousResultAvailable({ caseSensitive, entireWord, linksOnly, useCache, word }) { + return !!(useCache && + this._areParamsEqual(this._previousParams, { caseSensitive, entireWord, linksOnly, word }) && + this._previousRanges.length); + }, + + /** + * Internal; compare if two sets of iterator parameters are equivalent. + * + * @param {Object} paramSet1 First set of params (left hand side) + * @param {Object} paramSet2 Second set of params (right hand side) + * @param {Number} [allowDistance] Allowed edit distance between the two words. + * Optional, defaults to '0', which means 'no + * distance'. + * @return {Boolean} + */ + _areParamsEqual(paramSet1, paramSet2, allowDistance = 0) { + return (!!paramSet1 && !!paramSet2 && + paramSet1.caseSensitive === paramSet2.caseSensitive && + paramSet1.entireWord === paramSet2.entireWord && + paramSet1.linksOnly === paramSet2.linksOnly && + paramSet1.window === paramSet2.window && + NLP.levenshtein(paramSet1.word, paramSet2.word) <= allowDistance); + }, + + /** + * Internal; iterate over a predefined set of ranges that have been collected + * before. + * Also here, we make sure to pause every `kIterationSizeMax` iterations to + * make sure we don't block the host process too long. In the case of a break + * like this, we yield `undefined`, instead of a range. + * + * @param {Object} listener Listener object + * @param {Array} rangeSource Set of ranges to iterate over + * @param {nsIDOMWindow} window The window object is only really used + * for access to `setTimeout` + * @param {Boolean} [withPause] Whether to pause after each `kIterationSizeMax` + * number of ranges yielded. Optional, defaults + * to `true`. + * @yield {nsIDOMRange} + */ + _yieldResult: function* (listener, rangeSource, window, withPause = true) { + // We keep track of the number of iterations to allow a short pause between + // every `kIterationSizeMax` number of iterations. + let iterCount = 0; + let { limit, onEnd } = this._listeners.get(listener); + let ranges = rangeSource.slice(0, limit > -1 ? limit : undefined); + for (let range of ranges) { + try { + range.startContainer; + } catch (ex) { + // Don't yield dead objects, so use the escape hatch. + if (ex.message.includes("dead object")) + return; + } + + // Pass a flag that is `true` when we're returning the result from a + // cached previous iteration. + listener.onIteratorRangeFound(range, !this.running); + yield range; + + if (withPause && ++iterCount >= kIterationSizeMax) { + iterCount = 0; + // Make sure to save the current limit for later. + this._listeners.set(listener, { limit, onEnd }); + // Sleep for the rest of this cycle. + yield new Promise(resolve => window.setTimeout(resolve, 0)); + // After a sleep, the set of ranges may have updated. + ranges = rangeSource.slice(0, limit > -1 ? limit : undefined); + } + + if (limit !== -1 && --limit === 0) { + // We've reached our limit; no need to do more work. + this._listeners.delete(listener); + onEnd(); + return; + } + } + + // Save the updated limit globally. + this._listeners.set(listener, { limit, onEnd }); + }, + + /** + * Internal; iterate over the set of previously found ranges. Meanwhile it'll + * mark the listener as 'catching up', meaning it will not receive fresh + * results from a running iterator. + * + * @param {Object} listener Listener object + * @param {nsIDOMWindow} window The window object is only really used + * for access to `setTimeout` + * @yield {nsIDOMRange} + */ + _yieldPreviousResult: Task.async(function* (listener, window) { + this._notifyListeners("start", this.params, [listener]); + this._catchingUp.add(listener); + yield* this._yieldResult(listener, this._previousRanges, window); + this._catchingUp.delete(listener); + let { onEnd } = this._listeners.get(listener); + if (onEnd) + onEnd(); + }), + + /** + * Internal; iterate over the set of already found ranges. Meanwhile it'll + * mark the listener as 'catching up', meaning it will not receive fresh + * results from the running iterator. + * + * @param {Object} listener Listener object + * @param {nsIDOMWindow} window The window object is only really used + * for access to `setTimeout` + * @yield {nsIDOMRange} + */ + _yieldIntermediateResult: Task.async(function* (listener, window) { + this._notifyListeners("start", this.params, [listener]); + this._catchingUp.add(listener); + yield* this._yieldResult(listener, this.ranges, window, false); + this._catchingUp.delete(listener); + }), + + /** + * Internal; see the documentation of the start() method above. + * + * @param {Finder} finder Currently active Finder instance + * @param {Number} spawnId Since `stop()` is synchronous and this method + * is not, this identifier is used to learn if + * it's supposed to still continue after a pause. + * @yield {nsIDOMRange} + */ + _findAllRanges: Task.async(function* (finder, spawnId) { + if (this._timeout) { + if (this._timer) + clearTimeout(this._timer); + if (this._runningFindResolver) + this._runningFindResolver(); + + let timeout = this._timeout; + let searchTerm = this._currentParams.word; + // Wait a little longer when the first or second character is typed into + // the findbar. + if (searchTerm.length == 1) + timeout *= 4; + else if (searchTerm.length == 2) + timeout *= 2; + yield new Promise(resolve => { + this._runningFindResolver = resolve; + this._timer = setTimeout(resolve, timeout); + }); + this._timer = this._runningFindResolver = null; + // During the timeout, we could have gotten the signal to stop iterating. + // Make sure we do here. + if (!this.running || spawnId !== this._spawnId) + return; + } + + this._notifyListeners("start", this.params); + + let { linksOnly, window, word } = this._currentParams; + // First we collect all frames we need to search through, whilst making sure + // that the parent window gets dibs. + let frames = [window].concat(this._collectFrames(window, finder)); + let iterCount = 0; + for (let frame of frames) { + for (let range of this._iterateDocument(this._currentParams, frame)) { + // Between iterations, for example after a sleep of one cycle, we could + // have gotten the signal to stop iterating. Make sure we do here. + if (!this.running || spawnId !== this._spawnId) + return; + + // Deal with links-only mode here. + if (linksOnly && !this._rangeStartsInLink(range)) + continue; + + this.ranges.push(range); + + // Call each listener with the range we just found. + for (let [listener, { limit, onEnd }] of this._listeners) { + if (this._catchingUp.has(listener)) + continue; + + listener.onIteratorRangeFound(range); + + if (limit !== -1 && --limit === 0) { + // We've reached our limit; no need to do more work for this listener. + this._listeners.delete(listener); + onEnd(); + continue; + } + + // Save the updated limit globally. + this._listeners.set(listener, { limit, onEnd }); + } + + yield range; + + if (++iterCount >= kIterationSizeMax) { + iterCount = 0; + // Sleep for the rest of this cycle. + yield new Promise(resolve => window.setTimeout(resolve, 0)); + } + } + } + + // When the iterating has finished, make sure we reset and save the state + // properly. + this.stop(true); + }), + + /** + * Internal; basic wrapper around nsIFind that provides a generator yielding + * a range each time an occurence of `word` string is found. + * + * @param {Boolean} options.caseSensitive Whether to search in case + * sensitive mode + * @param {Boolean} options.entireWord Whether to search in entire-word + * mode + * @param {String} options.word The word to search for + * @param {nsIDOMWindow} window The window to search in + * @yield {nsIDOMRange} + */ + _iterateDocument: function* ({ caseSensitive, entireWord, word }, window) { + let doc = window.document; + let body = (doc instanceof Ci.nsIDOMHTMLDocument && doc.body) ? + doc.body : doc.documentElement; + + if (!body) + return; + + let searchRange = doc.createRange(); + searchRange.selectNodeContents(body); + + let startPt = searchRange.cloneRange(); + startPt.collapse(true); + + let endPt = searchRange.cloneRange(); + endPt.collapse(false); + + let retRange = null; + + let nsIFind = Cc["@mozilla.org/embedcomp/rangefind;1"] + .createInstance() + .QueryInterface(Ci.nsIFind); + nsIFind.caseSensitive = caseSensitive; + nsIFind.entireWord = entireWord; + + while ((retRange = nsIFind.Find(word, searchRange, startPt, endPt))) { + yield retRange; + startPt = retRange.cloneRange(); + startPt.collapse(false); + } + }, + + /** + * Internal; helper method for the iterator that recursively collects all + * visible (i)frames inside a window. + * + * @param {nsIDOMWindow} window The window to extract the (i)frames from + * @param {Finder} finder The Finder instance + * @return {Array} Stack of frames to iterate over + */ + _collectFrames(window, finder) { + let frames = []; + if (!("frames" in window) || !window.frames.length) + return frames; + + // Casting `window.frames` to an Iterator doesn't work, so we're stuck with + // a plain, old for-loop. + for (let i = 0, l = window.frames.length; i < l; ++i) { + let frame = window.frames[i]; + // Don't count matches in hidden frames. + let frameEl = frame && frame.frameElement; + if (!frameEl) + continue; + // Construct a range around the frame element to check its visiblity. + let range = window.document.createRange(); + range.setStart(frameEl, 0); + range.setEnd(frameEl, 0); + if (!finder._fastFind.isRangeVisible(range, this._getDocShell(range), true)) + continue; + // All conditions pass, so push the current frame and its children on the + // stack. + frames.push(frame, ...this._collectFrames(frame, finder)); + } + + return frames; + }, + + /** + * Internal; helper method to extract the docShell reference from a Window or + * Range object. + * + * @param {nsIDOMRange} windowOrRange Window object to query. May also be a + * Range, from which the owner window will + * be queried. + * @return {nsIDocShell} + */ + _getDocShell(windowOrRange) { + let window = windowOrRange; + // Ranges may also be passed in, so fetch its window. + if (windowOrRange instanceof Ci.nsIDOMRange) + window = windowOrRange.startContainer.ownerDocument.defaultView; + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + }, + + /** + * Internal; determines whether a range is inside a link. + * + * @param {nsIDOMRange} range the range to check + * @return {Boolean} True if the range starts in a link + */ + _rangeStartsInLink(range) { + let isInsideLink = false; + let node = range.startContainer; + + if (node.nodeType == node.ELEMENT_NODE) { + if (node.hasChildNodes) { + let childNode = node.item(range.startOffset); + if (childNode) + node = childNode; + } + } + + const XLink_NS = "http://www.w3.org/1999/xlink"; + const HTMLAnchorElement = (node.ownerDocument || node).defaultView.HTMLAnchorElement; + do { + if (node instanceof HTMLAnchorElement) { + isInsideLink = node.hasAttribute("href"); + break; + } else if (typeof node.hasAttributeNS == "function" && + node.hasAttributeNS(XLink_NS, "href")) { + isInsideLink = (node.getAttributeNS(XLink_NS, "type") == "simple"); + break; + } + + node = node.parentNode; + } while (node); + + return isInsideLink; + } +}; diff --git a/toolkit/modules/FormLikeFactory.jsm b/toolkit/modules/FormLikeFactory.jsm new file mode 100644 index 0000000000..45f25187c6 --- /dev/null +++ b/toolkit/modules/FormLikeFactory.jsm @@ -0,0 +1,166 @@ +/* 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 = ["FormLikeFactory"]; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +/** + * A factory to generate FormLike objects that represent a set of related fields + * which aren't necessarily marked up with a <form> element. FormLike's emulate + * the properties of an HTMLFormElement which are relevant to form tasks. + */ +let FormLikeFactory = { + _propsFromForm: [ + "action", + "autocomplete", + "ownerDocument", + ], + + /** + * Create a FormLike object from a <form>. + * + * @param {HTMLFormElement} aForm + * @return {FormLike} + * @throws Error if aForm isn't an HTMLFormElement + */ + createFromForm(aForm) { + if (!(aForm instanceof Ci.nsIDOMHTMLFormElement)) { + throw new Error("createFromForm: aForm must be a nsIDOMHTMLFormElement"); + } + + let formLike = { + elements: [...aForm.elements], + rootElement: aForm, + }; + + for (let prop of this._propsFromForm) { + formLike[prop] = aForm[prop]; + } + + this._addToJSONProperty(formLike); + + return formLike; + }, + + /** + * Create a FormLike object from an <input> in a document. + * + * If the field is in a <form>, construct the FormLike from the form. + * Otherwise, create a FormLike with a rootElement (wrapper) according to + * heuristics. Currently all <input> not in a <form> are one FormLike but this + * shouldn't be relied upon as the heuristics may change to detect multiple + * "forms" (e.g. registration and login) on one page with a <form>. + * + * Note that two FormLikes created from the same field won't return the same FormLike object. + * Use the `rootElement` property on the FormLike as a key instead. + * + * @param {HTMLInputElement} aField - a field in a document + * @return {FormLike} + * @throws Error if aField isn't a password or username field in a document + */ + createFromField(aField) { + if (!(aField instanceof Ci.nsIDOMHTMLInputElement) || + !aField.ownerDocument) { + throw new Error("createFromField requires a field in a document"); + } + + let rootElement = this.findRootForField(aField); + if (rootElement instanceof Ci.nsIDOMHTMLFormElement) { + return this.createFromForm(rootElement); + } + + let doc = aField.ownerDocument; + let elements = []; + for (let el of rootElement.querySelectorAll("input")) { + // Exclude elements inside the rootElement that are already in a <form> as + // they will be handled by their own FormLike. + if (!el.form) { + elements.push(el); + } + } + let formLike = { + action: doc.baseURI, + autocomplete: "on", + elements, + ownerDocument: doc, + rootElement, + }; + + this._addToJSONProperty(formLike); + return formLike; + }, + + /** + * Determine the Element that encapsulates the related fields. For example, if + * a page contains a login form and a checkout form which are "submitted" + * separately, and the username field is passed in, ideally this would return + * an ancestor Element of the username and password fields which doesn't + * include any of the checkout fields. + * + * @param {HTMLInputElement} aField - a field in a document + * @return {HTMLElement} - the root element surrounding related fields + */ + findRootForField(aField) { + if (aField.form) { + return aField.form; + } + + return aField.ownerDocument.documentElement; + }, + + /** + * Add a `toJSON` property to a FormLike so logging which ends up going + * through dump doesn't include usless garbage from DOM objects. + */ + _addToJSONProperty(aFormLike) { + function prettyElementOutput(aElement) { + let idText = aElement.id ? "#" + aElement.id : ""; + let classText = ""; + for (let className of aElement.classList) { + classText += "." + className; + } + return `<${aElement.nodeName + idText + classText}>`; + } + + Object.defineProperty(aFormLike, "toJSON", { + value: () => { + let cleansed = {}; + for (let key of Object.keys(aFormLike)) { + let value = aFormLike[key]; + let cleansedValue = value; + + switch (key) { + case "elements": { + cleansedValue = []; + for (let element of value) { + cleansedValue.push(prettyElementOutput(element)); + } + break; + } + + case "ownerDocument": { + cleansedValue = { + location: { + href: value.location.href, + }, + }; + break; + } + + case "rootElement": { + cleansedValue = prettyElementOutput(value); + break; + } + } + + cleansed[key] = cleansedValue; + } + return cleansed; + } + }); + }, +}; diff --git a/toolkit/modules/GMPInstallManager.jsm b/toolkit/modules/GMPInstallManager.jsm new file mode 100644 index 0000000000..b5987ca55c --- /dev/null +++ b/toolkit/modules/GMPInstallManager.jsm @@ -0,0 +1,523 @@ +/* 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 = []; + +const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu, manager: Cm} = + Components; +// 1 day default +const DEFAULT_SECONDS_BETWEEN_CHECKS = 60 * 60 * 24; + +var GMPInstallFailureReason = { + GMP_INVALID: 1, + GMP_HIDDEN: 2, + GMP_DISABLED: 3, + GMP_UPDATE_DISABLED: 4, +}; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/GMPUtils.jsm"); +Cu.import("resource://gre/modules/addons/ProductAddonChecker.jsm"); + +this.EXPORTED_SYMBOLS = ["GMPInstallManager", "GMPExtractor", "GMPDownloader", + "GMPAddon"]; + +// Shared code for suppressing bad cert dialogs +XPCOMUtils.defineLazyGetter(this, "gCertUtils", function() { + let temp = { }; + Cu.import("resource://gre/modules/CertUtils.jsm", temp); + return temp; +}); + +XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils", + "resource://gre/modules/UpdateUtils.jsm"); + +function getScopedLogger(prefix) { + // `PARENT_LOGGER_ID.` being passed here effectively links this logger + // to the parentLogger. + return Log.repository.getLoggerWithMessagePrefix("Toolkit.GMP", prefix + " "); +} + +/** + * Provides an easy API for downloading and installing GMP Addons + */ +function GMPInstallManager() { +} +/** + * Temp file name used for downloading + */ +GMPInstallManager.prototype = { + /** + * Obtains a URL with replacement of vars + */ + _getURL: function() { + let log = getScopedLogger("GMPInstallManager._getURL"); + // Use the override URL if it is specified. The override URL is just like + // the normal URL but it does not check the cert. + let url = GMPPrefs.get(GMPPrefs.KEY_URL_OVERRIDE); + if (url) { + log.info("Using override url: " + url); + } else { + url = GMPPrefs.get(GMPPrefs.KEY_URL); + log.info("Using url: " + url); + } + + url = UpdateUtils.formatUpdateURL(url); + + log.info("Using url (with replacement): " + url); + return url; + }, + /** + * Performs an addon check. + * @return a promise which will be resolved or rejected. + * The promise is resolved with an object with properties: + * gmpAddons: array of GMPAddons + * usedFallback: whether the data was collected from online or + * from fallback data within the build + * The promise is rejected with an object with properties: + * target: The XHR request object + * status: The HTTP status code + * type: Sometimes specifies type of rejection + */ + checkForAddons: function() { + let log = getScopedLogger("GMPInstallManager.checkForAddons"); + if (this._deferred) { + log.error("checkForAddons already called"); + return Promise.reject({type: "alreadycalled"}); + } + this._deferred = Promise.defer(); + let url = this._getURL(); + + let allowNonBuiltIn = true; + let certs = null; + if (!Services.prefs.prefHasUserValue(GMPPrefs.KEY_URL_OVERRIDE)) { + allowNonBuiltIn = !GMPPrefs.get(GMPPrefs.KEY_CERT_REQUIREBUILTIN, true); + if (GMPPrefs.get(GMPPrefs.KEY_CERT_CHECKATTRS, true)) { + certs = gCertUtils.readCertPrefs(GMPPrefs.KEY_CERTS_BRANCH); + } + } + + let addonPromise = ProductAddonChecker + .getProductAddonList(url, allowNonBuiltIn, certs); + + addonPromise.then(res => { + if (!res || !res.gmpAddons) { + this._deferred.resolve({gmpAddons: []}); + } + else { + res.gmpAddons = res.gmpAddons.map(a => new GMPAddon(a)); + this._deferred.resolve(res); + } + delete this._deferred; + }, (ex) => { + this._deferred.reject(ex); + delete this._deferred; + }); + + return this._deferred.promise; + }, + /** + * Installs the specified addon and calls a callback when done. + * @param gmpAddon The GMPAddon object to install + * @return a promise which will be resolved or rejected + * The promise will resolve with an array of paths that were extracted + * The promise will reject with an error object: + * target: The XHR request object + * status: The HTTP status code + * type: A string to represent the type of error + * downloaderr, verifyerr or previouserrorencountered + */ + installAddon: function(gmpAddon) { + if (this._deferred) { + log.error("previous error encountered"); + return Promise.reject({type: "previouserrorencountered"}); + } + this.gmpDownloader = new GMPDownloader(gmpAddon); + return this.gmpDownloader.start(); + }, + _getTimeSinceLastCheck: function() { + let now = Math.round(Date.now() / 1000); + // Default to 0 here because `now - 0` will be returned later if that case + // is hit. We want a large value so a check will occur. + let lastCheck = GMPPrefs.get(GMPPrefs.KEY_UPDATE_LAST_CHECK, 0); + // Handle clock jumps, return now since we want it to represent + // a lot of time has passed since the last check. + if (now < lastCheck) { + return now; + } + return now - lastCheck; + }, + get _isEMEEnabled() { + return GMPPrefs.get(GMPPrefs.KEY_EME_ENABLED, true); + }, + _isAddonEnabled: function(aAddon) { + return GMPPrefs.get(GMPPrefs.KEY_PLUGIN_ENABLED, true, aAddon); + }, + _isAddonUpdateEnabled: function(aAddon) { + return this._isAddonEnabled(aAddon) && + GMPPrefs.get(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, true, aAddon); + }, + _updateLastCheck: function() { + let now = Math.round(Date.now() / 1000); + GMPPrefs.set(GMPPrefs.KEY_UPDATE_LAST_CHECK, now); + }, + _versionchangeOccurred: function() { + let savedBuildID = GMPPrefs.get(GMPPrefs.KEY_BUILDID, null); + let buildID = Services.appinfo.platformBuildID; + if (savedBuildID == buildID) { + return false; + } + GMPPrefs.set(GMPPrefs.KEY_BUILDID, buildID); + return true; + }, + /** + * Wrapper for checkForAddons and installAddon. + * Will only install if not already installed and will log the results. + * This will only install/update the OpenH264 and EME plugins + * @return a promise which will be resolved if all addons could be installed + * successfully, rejected otherwise. + */ + simpleCheckAndInstall: Task.async(function*() { + let log = getScopedLogger("GMPInstallManager.simpleCheckAndInstall"); + + if (this._versionchangeOccurred()) { + log.info("A version change occurred. Ignoring " + + "media.gmp-manager.lastCheck to check immediately for " + + "new or updated GMPs."); + } else { + let secondsBetweenChecks = + GMPPrefs.get(GMPPrefs.KEY_SECONDS_BETWEEN_CHECKS, + DEFAULT_SECONDS_BETWEEN_CHECKS) + let secondsSinceLast = this._getTimeSinceLastCheck(); + log.info("Last check was: " + secondsSinceLast + + " seconds ago, minimum seconds: " + secondsBetweenChecks); + if (secondsBetweenChecks > secondsSinceLast) { + log.info("Will not check for updates."); + return {status: "too-frequent-no-check"}; + } + } + + try { + let {usedFallback, gmpAddons} = yield this.checkForAddons(); + this._updateLastCheck(); + log.info("Found " + gmpAddons.length + " addons advertised."); + let addonsToInstall = gmpAddons.filter(function(gmpAddon) { + log.info("Found addon: " + gmpAddon.toString()); + + if (!gmpAddon.isValid) { + log.info("Addon |" + gmpAddon.id + "| is invalid."); + return false; + } + + if (GMPUtils.isPluginHidden(gmpAddon)) { + log.info("Addon |" + gmpAddon.id + "| has been hidden."); + return false; + } + + if (gmpAddon.isInstalled) { + log.info("Addon |" + gmpAddon.id + "| already installed."); + return false; + } + + // Do not install from fallback if already installed as it + // may be a downgrade + if (usedFallback && gmpAddon.isUpdate) { + log.info("Addon |" + gmpAddon.id + "| not installing updates based " + + "on fallback."); + return false; + } + + let addonUpdateEnabled = false; + if (GMP_PLUGIN_IDS.indexOf(gmpAddon.id) >= 0) { + if (!this._isAddonEnabled(gmpAddon.id)) { + log.info("GMP |" + gmpAddon.id + "| has been disabled; skipping check."); + } else if (!this._isAddonUpdateEnabled(gmpAddon.id)) { + log.info("Auto-update is off for " + gmpAddon.id + + ", skipping check."); + } else { + addonUpdateEnabled = true; + } + } else { + // Currently, we only support installs of OpenH264 and EME plugins. + log.info("Auto-update is off for unknown plugin '" + gmpAddon.id + + "', skipping check."); + } + + return addonUpdateEnabled; + }, this); + + if (!addonsToInstall.length) { + log.info("No new addons to install, returning"); + return {status: "nothing-new-to-install"}; + } + + let installResults = []; + let failureEncountered = false; + for (let addon of addonsToInstall) { + try { + yield this.installAddon(addon); + installResults.push({ + id: addon.id, + result: "succeeded", + }); + } catch (e) { + failureEncountered = true; + installResults.push({ + id: addon.id, + result: "failed", + }); + } + } + if (failureEncountered) { + throw {status: "failed", + results: installResults}; + } + return {status: "succeeded", + results: installResults}; + } catch (e) { + log.error("Could not check for addons", e); + throw e; + } + }), + + /** + * Makes sure everything is cleaned up + */ + uninit: function() { + let log = getScopedLogger("GMPInstallManager.uninit"); + if (this._request) { + log.info("Aborting request"); + this._request.abort(); + } + if (this._deferred) { + log.info("Rejecting deferred"); + this._deferred.reject({type: "uninitialized"}); + } + log.info("Done cleanup"); + }, + + /** + * If set to true, specifies to leave the temporary downloaded zip file. + * This is useful for tests. + */ + overrideLeaveDownloadedZip: false, +}; + +/** + * Used to construct a single GMP addon + * GMPAddon objects are returns from GMPInstallManager.checkForAddons + * GMPAddon objects can also be used in calls to GMPInstallManager.installAddon + * + * @param addon The ProductAddonChecker `addon` object + */ +function GMPAddon(addon) { + let log = getScopedLogger("GMPAddon.constructor"); + for (let name of Object.keys(addon)) { + this[name] = addon[name]; + } + log.info ("Created new addon: " + this.toString()); +} + +GMPAddon.prototype = { + /** + * Returns a string representation of the addon + */ + toString: function() { + return this.id + " (" + + "isValid: " + this.isValid + + ", isInstalled: " + this.isInstalled + + ", hashFunction: " + this.hashFunction+ + ", hashValue: " + this.hashValue + + (this.size !== undefined ? ", size: " + this.size : "" ) + + ")"; + }, + /** + * If all the fields aren't specified don't consider this addon valid + * @return true if the addon is parsed and valid + */ + get isValid() { + return this.id && this.URL && this.version && + this.hashFunction && !!this.hashValue; + }, + get isInstalled() { + return this.version && + GMPPrefs.get(GMPPrefs.KEY_PLUGIN_VERSION, "", this.id) === this.version; + }, + get isEME() { + return this.id == "gmp-widevinecdm" || this.id.indexOf("gmp-eme-") == 0; + }, + /** + * @return true if the addon has been previously installed and this is + * a new version, if this is a fresh install return false + */ + get isUpdate() { + return this.version && + GMPPrefs.get(GMPPrefs.KEY_PLUGIN_VERSION, false, this.id); + }, +}; +/** + * Constructs a GMPExtractor object which is used to extract a GMP zip + * into the specified location. (Which typically leties per platform) + * @param zipPath The path on disk of the zip file to extract + */ +function GMPExtractor(zipPath, installToDirPath) { + this.zipPath = zipPath; + this.installToDirPath = installToDirPath; +} +GMPExtractor.prototype = { + /** + * Obtains a list of all the entries in a zipfile in the format of *.*. + * This also includes files inside directories. + * + * @param zipReader the nsIZipReader to check + * @return An array of string name entries which can be used + * in nsIZipReader.extract + */ + _getZipEntries: function(zipReader) { + let entries = []; + let enumerator = zipReader.findEntries("*.*"); + while (enumerator.hasMore()) { + entries.push(enumerator.getNext()); + } + return entries; + }, + /** + * Installs the this.zipPath contents into the directory used to store GMP + * addons for the current platform. + * + * @return a promise which will be resolved or rejected + * See GMPInstallManager.installAddon for resolve/rejected info + */ + install: function() { + try { + let log = getScopedLogger("GMPExtractor.install"); + this._deferred = Promise.defer(); + log.info("Installing " + this.zipPath + "..."); + // Get the input zip file + let zipFile = Cc["@mozilla.org/file/local;1"]. + createInstance(Ci.nsIFile); + zipFile.initWithPath(this.zipPath); + + // Initialize a zipReader and obtain the entries + var zipReader = Cc["@mozilla.org/libjar/zip-reader;1"]. + createInstance(Ci.nsIZipReader); + zipReader.open(zipFile) + let entries = this._getZipEntries(zipReader); + let extractedPaths = []; + + let destDir = Cc["@mozilla.org/file/local;1"]. + createInstance(Ci.nsILocalFile); + destDir.initWithPath(this.installToDirPath); + // Make sure the destination exists + if (!destDir.exists()) { + destDir.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8)); + } + + // Extract each of the entries + entries.forEach(entry => { + // We don't need these types of files + if (entry.includes("__MACOSX") || + entry == "_metadata/verified_contents.json" || + entry == "imgs/icon-128x128.png") { + return; + } + let outFile = destDir.clone(); + // Do not extract into directories. Extract all files to the same + // directory. DO NOT use |OS.Path.basename()| here, as in Windows it + // does not work properly with forward slashes (which we must use here). + let outBaseName = entry.slice(entry.lastIndexOf("/") + 1); + outFile.appendRelativePath(outBaseName); + + zipReader.extract(entry, outFile); + extractedPaths.push(outFile.path); + // Ensure files are writable and executable. Otherwise we may be unable to + // execute or uninstall them. + outFile.permissions |= parseInt("0700", 8); + log.info(entry + " was successfully extracted to: " + + outFile.path); + }); + zipReader.close(); + if (!GMPInstallManager.overrideLeaveDownloadedZip) { + zipFile.remove(false); + } + + log.info(this.zipPath + " was installed successfully"); + this._deferred.resolve(extractedPaths); + } catch (e) { + if (zipReader) { + zipReader.close(); + } + this._deferred.reject({ + target: this, + status: e, + type: "exception" + }); + } + return this._deferred.promise; + } +}; + + +/** + * Constructs an object which downloads and initiates an install of + * the specified GMPAddon object. + * @param gmpAddon The addon to install. + */ +function GMPDownloader(gmpAddon) +{ + this._gmpAddon = gmpAddon; +} + +GMPDownloader.prototype = { + /** + * Starts the download process for an addon. + * @return a promise which will be resolved or rejected + * See GMPInstallManager.installAddon for resolve/rejected info + */ + start: function() { + let log = getScopedLogger("GMPDownloader"); + let gmpAddon = this._gmpAddon; + + if (!gmpAddon.isValid) { + log.info("gmpAddon is not valid, will not continue"); + return Promise.reject({ + target: this, + status: status, + type: "downloaderr" + }); + } + + return ProductAddonChecker.downloadAddon(gmpAddon).then((zipPath) => { + let path = OS.Path.join(OS.Constants.Path.profileDir, + gmpAddon.id, + gmpAddon.version); + log.info("install to directory path: " + path); + let gmpInstaller = new GMPExtractor(zipPath, path); + let installPromise = gmpInstaller.install(); + return installPromise.then(extractedPaths => { + // Success, set the prefs + let now = Math.round(Date.now() / 1000); + GMPPrefs.set(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, now, gmpAddon.id); + // Remember our ABI, so that if the profile is migrated to another + // platform or from 32 -> 64 bit, we notice and don't try to load the + // unexecutable plugin library. + GMPPrefs.set(GMPPrefs.KEY_PLUGIN_ABI, UpdateUtils.ABI, gmpAddon.id); + // Setting the version pref signals installation completion to consumers, + // if you need to set other prefs etc. do it before this. + GMPPrefs.set(GMPPrefs.KEY_PLUGIN_VERSION, gmpAddon.version, + gmpAddon.id); + return extractedPaths; + }); + }); + }, +}; diff --git a/toolkit/modules/GMPUtils.jsm b/toolkit/modules/GMPUtils.jsm new file mode 100644 index 0000000000..9e41a7a614 --- /dev/null +++ b/toolkit/modules/GMPUtils.jsm @@ -0,0 +1,208 @@ +/* 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"; + +const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu, manager: Cm} = + Components; + +this.EXPORTED_SYMBOLS = [ "EME_ADOBE_ID", + "GMP_PLUGIN_IDS", + "GMPPrefs", + "GMPUtils", + "OPEN_H264_ID", + "WIDEVINE_ID" ]; + +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/AppConstants.jsm"); + +// GMP IDs +const OPEN_H264_ID = "gmp-gmpopenh264"; +const EME_ADOBE_ID = "gmp-eme-adobe"; +const WIDEVINE_ID = "gmp-widevinecdm"; +const GMP_PLUGIN_IDS = [ OPEN_H264_ID, EME_ADOBE_ID, WIDEVINE_ID ]; + +var GMPPluginUnsupportedReason = { + NOT_WINDOWS: 1, + WINDOWS_VERSION: 2, +}; + +var GMPPluginHiddenReason = { + UNSUPPORTED: 1, + EME_DISABLED: 2, +}; + +this.GMPUtils = { + /** + * Checks whether or not a given plugin is hidden. Hidden plugins are neither + * downloaded nor displayed in the addons manager. + * @param aPlugin + * The plugin to check. + */ + isPluginHidden: function(aPlugin) { + if (this._is32bitModeMacOS()) { + // GMPs are hidden on MacOS when running in 32 bit mode. + // See bug 1291537. + return true; + } + if (!aPlugin.isEME) { + return false; + } + + if (!this._isPluginSupported(aPlugin) || + !this._isPluginVisible(aPlugin)) { + return true; + } + + if (!GMPPrefs.get(GMPPrefs.KEY_EME_ENABLED, true)) { + return true; + } + + return false; + }, + + /** + * Checks whether or not a given plugin is supported by the current OS. + * @param aPlugin + * The plugin to check. + */ + _isPluginSupported: function(aPlugin) { + if (this._isPluginForceSupported(aPlugin)) { + return true; + } + if (aPlugin.id == EME_ADOBE_ID) { + // Windows Vista and later only supported by Adobe EME. + return AppConstants.isPlatformAndVersionAtLeast("win", "6"); + } else if (aPlugin.id == WIDEVINE_ID) { + // The Widevine plugin is available for Windows versions Vista and later, + // Mac OSX, and Linux. + return AppConstants.isPlatformAndVersionAtLeast("win", "6") || + AppConstants.platform == "macosx" || + AppConstants.platform == "linux"; + } + + return true; + }, + + _is32bitModeMacOS: function() { + if (AppConstants.platform != "macosx") { + return false; + } + return Services.appinfo.XPCOMABI.split("-")[0] == "x86"; + }, + + /** + * Checks whether or not a given plugin is visible in the addons manager + * UI and the "enable DRM" notification box. This can be used to test + * plugins that aren't yet turned on in the mozconfig. + * @param aPlugin + * The plugin to check. + */ + _isPluginVisible: function(aPlugin) { + return GMPPrefs.get(GMPPrefs.KEY_PLUGIN_VISIBLE, false, aPlugin.id); + }, + + /** + * Checks whether or not a given plugin is forced-supported. This is used + * in automated tests to override the checks that prevent GMPs running on an + * unsupported platform. + * @param aPlugin + * The plugin to check. + */ + _isPluginForceSupported: function(aPlugin) { + return GMPPrefs.get(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, false, aPlugin.id); + }, +}; + +/** + * Manages preferences for GMP addons + */ +this.GMPPrefs = { + KEY_EME_ENABLED: "media.eme.enabled", + KEY_PLUGIN_ENABLED: "media.{0}.enabled", + KEY_PLUGIN_LAST_UPDATE: "media.{0}.lastUpdate", + KEY_PLUGIN_VERSION: "media.{0}.version", + KEY_PLUGIN_AUTOUPDATE: "media.{0}.autoupdate", + KEY_PLUGIN_VISIBLE: "media.{0}.visible", + KEY_PLUGIN_ABI: "media.{0}.abi", + KEY_PLUGIN_FORCE_SUPPORTED: "media.{0}.forceSupported", + KEY_URL: "media.gmp-manager.url", + KEY_URL_OVERRIDE: "media.gmp-manager.url.override", + KEY_CERT_CHECKATTRS: "media.gmp-manager.cert.checkAttributes", + KEY_CERT_REQUIREBUILTIN: "media.gmp-manager.cert.requireBuiltIn", + KEY_UPDATE_LAST_CHECK: "media.gmp-manager.lastCheck", + KEY_SECONDS_BETWEEN_CHECKS: "media.gmp-manager.secondsBetweenChecks", + KEY_UPDATE_ENABLED: "media.gmp-manager.updateEnabled", + KEY_APP_DISTRIBUTION: "distribution.id", + KEY_APP_DISTRIBUTION_VERSION: "distribution.version", + KEY_BUILDID: "media.gmp-manager.buildID", + KEY_CERTS_BRANCH: "media.gmp-manager.certs.", + KEY_PROVIDER_ENABLED: "media.gmp-provider.enabled", + KEY_LOG_BASE: "media.gmp.log.", + KEY_LOGGING_LEVEL: "media.gmp.log.level", + KEY_LOGGING_DUMP: "media.gmp.log.dump", + + /** + * Obtains the specified preference in relation to the specified plugin. + * @param aKey The preference key value to use. + * @param aDefaultValue The default value if no preference exists. + * @param aPlugin The plugin to scope the preference to. + * @return The obtained preference value, or the defaultValue if none exists. + */ + get: function(aKey, aDefaultValue, aPlugin) { + if (aKey === this.KEY_APP_DISTRIBUTION || + aKey === this.KEY_APP_DISTRIBUTION_VERSION) { + let prefValue = "default"; + try { + prefValue = Services.prefs.getDefaultBranch(null).getCharPref(aKey); + } catch (e) { + // use default when pref not found + } + return prefValue; + } + return Preferences.get(this.getPrefKey(aKey, aPlugin), aDefaultValue); + }, + + /** + * Sets the specified preference in relation to the specified plugin. + * @param aKey The preference key value to use. + * @param aVal The value to set. + * @param aPlugin The plugin to scope the preference to. + */ + set: function(aKey, aVal, aPlugin) { + Preferences.set(this.getPrefKey(aKey, aPlugin), aVal); + }, + + /** + * Checks whether or not the specified preference is set in relation to the + * specified plugin. + * @param aKey The preference key value to use. + * @param aPlugin The plugin to scope the preference to. + * @return true if the preference is set, false otherwise. + */ + isSet: function(aKey, aPlugin) { + return Preferences.isSet(this.getPrefKey(aKey, aPlugin)); + }, + + /** + * Resets the specified preference in relation to the specified plugin to its + * default. + * @param aKey The preference key value to use. + * @param aPlugin The plugin to scope the preference to. + */ + reset: function(aKey, aPlugin) { + Preferences.reset(this.getPrefKey(aKey, aPlugin)); + }, + + /** + * Scopes the specified preference key to the specified plugin. + * @param aKey The preference key value to use. + * @param aPlugin The plugin to scope the preference to. + * @return A preference key scoped to the specified plugin. + */ + getPrefKey: function(aKey, aPlugin) { + return aKey.replace("{0}", aPlugin || ""); + }, +}; diff --git a/toolkit/modules/Geometry.jsm b/toolkit/modules/Geometry.jsm new file mode 100644 index 0000000000..d2a4dadbaa --- /dev/null +++ b/toolkit/modules/Geometry.jsm @@ -0,0 +1,334 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["Point", "Rect"]; + +/** + * Simple Point class. + * + * Any method that takes an x and y may also take a point. + */ +this.Point = function Point(x, y) { + this.set(x, y); +} + +Point.prototype = { + clone: function clone() { + return new Point(this.x, this.y); + }, + + set: function set(x, y) { + this.x = x; + this.y = y; + return this; + }, + + equals: function equals(x, y) { + return this.x == x && this.y == y; + }, + + toString: function toString() { + return "(" + this.x + "," + this.y + ")"; + }, + + map: function map(f) { + this.x = f.call(this, this.x); + this.y = f.call(this, this.y); + return this; + }, + + add: function add(x, y) { + this.x += x; + this.y += y; + return this; + }, + + subtract: function subtract(x, y) { + this.x -= x; + this.y -= y; + return this; + }, + + scale: function scale(s) { + this.x *= s; + this.y *= s; + return this; + }, + + isZero: function() { + return this.x == 0 && this.y == 0; + } +}; + +(function() { + function takePointOrArgs(f) { + return function(arg1, arg2) { + if (arg2 === undefined) + return f.call(this, arg1.x, arg1.y); + return f.call(this, arg1, arg2); + }; + } + + for (let f of ['add', 'subtract', 'equals', 'set']) + Point.prototype[f] = takePointOrArgs(Point.prototype[f]); +})(); + + +/** + * Rect is a simple data structure for representation of a rectangle supporting + * many basic geometric operations. + * + * NOTE: Since its operations are closed, rectangles may be empty and will report + * non-positive widths and heights in that case. + */ + +this.Rect = function Rect(x, y, w, h) { + this.left = x; + this.top = y; + this.right = x + w; + this.bottom = y + h; +}; + +Rect.fromRect = function fromRect(r) { + return new Rect(r.left, r.top, r.right - r.left, r.bottom - r.top); +}; + +Rect.prototype = { + get x() { return this.left; }, + get y() { return this.top; }, + get width() { return this.right - this.left; }, + get height() { return this.bottom - this.top; }, + set x(v) { + let diff = this.left - v; + this.left = v; + this.right -= diff; + }, + set y(v) { + let diff = this.top - v; + this.top = v; + this.bottom -= diff; + }, + set width(v) { this.right = this.left + v; }, + set height(v) { this.bottom = this.top + v; }, + + isEmpty: function isEmpty() { + return this.left >= this.right || this.top >= this.bottom; + }, + + setRect: function(x, y, w, h) { + this.left = x; + this.top = y; + this.right = x+w; + this.bottom = y+h; + + return this; + }, + + setBounds: function(l, t, r, b) { + this.top = t; + this.left = l; + this.bottom = b; + this.right = r; + + return this; + }, + + equals: function equals(other) { + return other != null && + (this.isEmpty() && other.isEmpty() || + this.top == other.top && + this.left == other.left && + this.bottom == other.bottom && + this.right == other.right); + }, + + clone: function clone() { + return new Rect(this.left, this.top, this.right - this.left, this.bottom - this.top); + }, + + center: function center() { + if (this.isEmpty()) + throw "Empty rectangles do not have centers"; + return new Point(this.left + (this.right - this.left) / 2, + this.top + (this.bottom - this.top) / 2); + }, + + copyFrom: function(other) { + this.top = other.top; + this.left = other.left; + this.bottom = other.bottom; + this.right = other.right; + + return this; + }, + + translate: function(x, y) { + this.left += x; + this.right += x; + this.top += y; + this.bottom += y; + + return this; + }, + + toString: function() { + return "[" + this.x + "," + this.y + "," + this.width + "," + this.height + "]"; + }, + + /** return a new rect that is the union of that one and this one */ + union: function(other) { + return this.clone().expandToContain(other); + }, + + contains: function(other) { + if (other.isEmpty()) return true; + if (this.isEmpty()) return false; + + return (other.left >= this.left && + other.right <= this.right && + other.top >= this.top && + other.bottom <= this.bottom); + }, + + intersect: function(other) { + return this.clone().restrictTo(other); + }, + + intersects: function(other) { + if (this.isEmpty() || other.isEmpty()) + return false; + + let x1 = Math.max(this.left, other.left); + let x2 = Math.min(this.right, other.right); + let y1 = Math.max(this.top, other.top); + let y2 = Math.min(this.bottom, other.bottom); + return x1 < x2 && y1 < y2; + }, + + /** Restrict area of this rectangle to the intersection of both rectangles. */ + restrictTo: function restrictTo(other) { + if (this.isEmpty() || other.isEmpty()) + return this.setRect(0, 0, 0, 0); + + let x1 = Math.max(this.left, other.left); + let x2 = Math.min(this.right, other.right); + let y1 = Math.max(this.top, other.top); + let y2 = Math.min(this.bottom, other.bottom); + // If width or height is 0, the intersection was empty. + return this.setRect(x1, y1, Math.max(0, x2 - x1), Math.max(0, y2 - y1)); + }, + + /** Expand this rectangle to the union of both rectangles. */ + expandToContain: function expandToContain(other) { + if (this.isEmpty()) return this.copyFrom(other); + if (other.isEmpty()) return this; + + let l = Math.min(this.left, other.left); + let r = Math.max(this.right, other.right); + let t = Math.min(this.top, other.top); + let b = Math.max(this.bottom, other.bottom); + return this.setRect(l, t, r-l, b-t); + }, + + /** + * Expands to the smallest rectangle that contains original rectangle and is bounded + * by lines with integer coefficients. + */ + expandToIntegers: function round() { + this.left = Math.floor(this.left); + this.top = Math.floor(this.top); + this.right = Math.ceil(this.right); + this.bottom = Math.ceil(this.bottom); + return this; + }, + + scale: function scale(xscl, yscl) { + this.left *= xscl; + this.right *= xscl; + this.top *= yscl; + this.bottom *= yscl; + return this; + }, + + map: function map(f) { + this.left = f.call(this, this.left); + this.top = f.call(this, this.top); + this.right = f.call(this, this.right); + this.bottom = f.call(this, this.bottom); + return this; + }, + + /** Ensure this rectangle is inside the other, if possible. Preserves w, h. */ + translateInside: function translateInside(other) { + let offsetX = 0; + if (this.left <= other.left) + offsetX = other.left - this.left; + else if (this.right > other.right) + offsetX = other.right - this.right; + + let offsetY = 0; + if (this.top <= other.top) + offsetY = other.top - this.top; + else if (this.bottom > other.bottom) + offsetY = other.bottom - this.bottom; + + return this.translate(offsetX, offsetY); + }, + + /** Subtract other area from this. Returns array of rects whose union is this-other. */ + subtract: function subtract(other) { + let r = new Rect(0, 0, 0, 0); + let result = []; + other = other.intersect(this); + if (other.isEmpty()) + return [this.clone()]; + + // left strip + r.setBounds(this.left, this.top, other.left, this.bottom); + if (!r.isEmpty()) + result.push(r.clone()); + // inside strip + r.setBounds(other.left, this.top, other.right, other.top); + if (!r.isEmpty()) + result.push(r.clone()); + r.setBounds(other.left, other.bottom, other.right, this.bottom); + if (!r.isEmpty()) + result.push(r.clone()); + // right strip + r.setBounds(other.right, this.top, this.right, this.bottom); + if (!r.isEmpty()) + result.push(r.clone()); + + return result; + }, + + /** + * Blends two rectangles together. + * @param rect Rectangle to blend this one with + * @param scalar Ratio from 0 (returns a clone of this rect) to 1 (clone of rect). + * @return New blended rectangle. + */ + blend: function blend(rect, scalar) { + return new Rect( + this.left + (rect.left - this.left ) * scalar, + this.top + (rect.top - this.top ) * scalar, + this.width + (rect.width - this.width ) * scalar, + this.height + (rect.height - this.height) * scalar); + }, + + /** + * Grows or shrinks the rectangle while keeping the center point. + * Accepts single multipler, or separate for both axes. + */ + inflate: function inflate(xscl, yscl) { + let xAdj = (this.width * xscl - this.width) / 2; + let s = (arguments.length > 1) ? yscl : xscl; + let yAdj = (this.height * s - this.height) / 2; + this.left -= xAdj; + this.right += xAdj; + this.top -= yAdj; + this.bottom += yAdj; + return this; + } +}; diff --git a/toolkit/modules/Http.jsm b/toolkit/modules/Http.jsm new file mode 100644 index 0000000000..3b9daa3b08 --- /dev/null +++ b/toolkit/modules/Http.jsm @@ -0,0 +1,100 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["httpRequest", "percentEncode"]; + +const {classes: Cc, interfaces: Ci} = Components; + +// Strictly follow RFC 3986 when encoding URI components. +// Accepts a unescaped string and returns the URI encoded string for use in +// an HTTP request. +function percentEncode(aString) { + return encodeURIComponent(aString).replace(/[!'()]/g, escape).replace(/\*/g, "%2A"); +} + +/* + * aOptions can have a variety of fields: + * headers, an array of headers + * postData, this can be: + * a string: send it as is + * an array of parameters: encode as form values + * null/undefined: no POST data. + * method, GET, POST or PUT (this is set automatically if postData exists). + * onLoad, a function handle to call when the load is complete, it takes two + * parameters: the responseText and the XHR object. + * onError, a function handle to call when an error occcurs, it takes three + * parameters: the error, the responseText and the XHR object. + * logger, an object that implements the debug and log methods (e.g. log.jsm). + * + * Headers or post data are given as an array of arrays, for each each inner + * array the first value is the key and the second is the value, e.g. + * [["key1", "value1"], ["key2", "value2"]]. + */ +function httpRequest(aUrl, aOptions) { + let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(Ci.nsIXMLHttpRequest); + xhr.mozBackgroundRequest = true; // no error dialogs + xhr.open(aOptions.method || (aOptions.postData ? "POST" : "GET"), aUrl); + xhr.channel.loadFlags = Ci.nsIChannel.LOAD_ANONYMOUS | // don't send cookies + Ci.nsIChannel.LOAD_BYPASS_CACHE | + Ci.nsIChannel.INHIBIT_CACHING; + xhr.onerror = function(aProgressEvent) { + if (aOptions.onError) { + // adapted from toolkit/mozapps/extensions/nsBlocklistService.js + let request = aProgressEvent.target; + let status; + try { + // may throw (local file or timeout) + status = request.status; + } + catch (e) { + request = request.channel.QueryInterface(Ci.nsIRequest); + status = request.status; + } + // When status is 0 we don't have a valid channel. + let statusText = status ? request.statusText : "offline"; + aOptions.onError(statusText, null, this); + } + }; + xhr.onload = function(aRequest) { + try { + let target = aRequest.target; + if (aOptions.logger) + aOptions.logger.debug("Received response: " + target.responseText); + if (target.status < 200 || target.status >= 300) { + let errorText = target.responseText; + if (!errorText || /<(ht|\?x)ml\b/i.test(errorText)) + errorText = target.statusText; + throw target.status + " - " + errorText; + } + if (aOptions.onLoad) + aOptions.onLoad(target.responseText, this); + } catch (e) { + if (aOptions.onError) + aOptions.onError(e, aRequest.target.responseText, this); + } + }; + + if (aOptions.headers) { + aOptions.headers.forEach(function(header) { + xhr.setRequestHeader(header[0], header[1]); + }); + } + + // Handle adding postData as defined above. + let POSTData = aOptions.postData || null; + if (POSTData && Array.isArray(POSTData)) { + xhr.setRequestHeader("Content-Type", + "application/x-www-form-urlencoded; charset=utf-8"); + POSTData = POSTData.map(p => p[0] + "=" + percentEncode(p[1])) + .join("&"); + } + + if (aOptions.logger) { + aOptions.logger.log("sending request to " + aUrl + " (POSTData = " + + POSTData + ")"); + } + xhr.send(POSTData); + return xhr; +} diff --git a/toolkit/modules/InlineSpellChecker.jsm b/toolkit/modules/InlineSpellChecker.jsm new file mode 100644 index 0000000000..de89f73a0d --- /dev/null +++ b/toolkit/modules/InlineSpellChecker.jsm @@ -0,0 +1,593 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = [ "InlineSpellChecker", + "SpellCheckHelper" ]; +var gLanguageBundle; +var gRegionBundle; +const MAX_UNDO_STACK_DEPTH = 1; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +this.InlineSpellChecker = function InlineSpellChecker(aEditor) { + this.init(aEditor); + this.mAddedWordStack = []; // We init this here to preserve it between init/uninit calls +} + +InlineSpellChecker.prototype = { + // Call this function to initialize for a given editor + init: function(aEditor) + { + this.uninit(); + this.mEditor = aEditor; + try { + this.mInlineSpellChecker = this.mEditor.getInlineSpellChecker(true); + // note: this might have been NULL if there is no chance we can spellcheck + } catch (e) { + this.mInlineSpellChecker = null; + } + }, + + initFromRemote: function(aSpellInfo) + { + if (this.mRemote) + throw new Error("Unexpected state"); + this.uninit(); + + if (!aSpellInfo) + return; + this.mInlineSpellChecker = this.mRemote = new RemoteSpellChecker(aSpellInfo); + this.mOverMisspelling = aSpellInfo.overMisspelling; + this.mMisspelling = aSpellInfo.misspelling; + }, + + // call this to clear state + uninit: function() + { + if (this.mRemote) { + this.mRemote.uninit(); + this.mRemote = null; + } + + this.mEditor = null; + this.mInlineSpellChecker = null; + this.mOverMisspelling = false; + this.mMisspelling = ""; + this.mMenu = null; + this.mSpellSuggestions = []; + this.mSuggestionItems = []; + this.mDictionaryMenu = null; + this.mDictionaryNames = []; + this.mDictionaryItems = []; + this.mWordNode = null; + }, + + // for each UI event, you must call this function, it will compute the + // word the cursor is over + initFromEvent: function(rangeParent, rangeOffset) + { + this.mOverMisspelling = false; + + if (!rangeParent || !this.mInlineSpellChecker) + return; + + var selcon = this.mEditor.selectionController; + var spellsel = selcon.getSelection(selcon.SELECTION_SPELLCHECK); + if (spellsel.rangeCount == 0) + return; // easy case - no misspellings + + var range = this.mInlineSpellChecker.getMisspelledWord(rangeParent, + rangeOffset); + if (! range) + return; // not over a misspelled word + + this.mMisspelling = range.toString(); + this.mOverMisspelling = true; + this.mWordNode = rangeParent; + this.mWordOffset = rangeOffset; + }, + + // returns false if there should be no spellchecking UI enabled at all, true + // means that you can at least give the user the ability to turn it on. + get canSpellCheck() + { + // inline spell checker objects will be created only if there are actual + // dictionaries available + if (this.mRemote) + return this.mRemote.canSpellCheck; + return this.mInlineSpellChecker != null; + }, + + get initialSpellCheckPending() { + if (this.mRemote) { + return this.mRemote.spellCheckPending; + } + return !!(this.mInlineSpellChecker && + !this.mInlineSpellChecker.spellChecker && + this.mInlineSpellChecker.spellCheckPending); + }, + + // Whether spellchecking is enabled in the current box + get enabled() + { + if (this.mRemote) + return this.mRemote.enableRealTimeSpell; + return (this.mInlineSpellChecker && + this.mInlineSpellChecker.enableRealTimeSpell); + }, + set enabled(isEnabled) + { + if (this.mRemote) + this.mRemote.setSpellcheckUserOverride(isEnabled); + else if (this.mInlineSpellChecker) + this.mEditor.setSpellcheckUserOverride(isEnabled); + }, + + // returns true if the given event is over a misspelled word + get overMisspelling() + { + return this.mOverMisspelling; + }, + + // this prepends up to "maxNumber" suggestions at the given menu position + // for the word under the cursor. Returns the number of suggestions inserted. + addSuggestionsToMenu: function(menu, insertBefore, maxNumber) + { + if (!this.mRemote && (!this.mInlineSpellChecker || !this.mOverMisspelling)) + return 0; // nothing to do + + var spellchecker = this.mRemote || this.mInlineSpellChecker.spellChecker; + try { + if (!this.mRemote && !spellchecker.CheckCurrentWord(this.mMisspelling)) + return 0; // word seems not misspelled after all (?) + } catch (e) { + return 0; + } + + this.mMenu = menu; + this.mSpellSuggestions = []; + this.mSuggestionItems = []; + for (var i = 0; i < maxNumber; i ++) { + var suggestion = spellchecker.GetSuggestedWord(); + if (! suggestion.length) + break; + this.mSpellSuggestions.push(suggestion); + + var item = menu.ownerDocument.createElement("menuitem"); + this.mSuggestionItems.push(item); + item.setAttribute("label", suggestion); + item.setAttribute("value", suggestion); + // this function thing is necessary to generate a callback with the + // correct binding of "val" (the index in this loop). + var callback = function(me, val) { return function(evt) { me.replaceMisspelling(val); } }; + item.addEventListener("command", callback(this, i), true); + item.setAttribute("class", "spell-suggestion"); + menu.insertBefore(item, insertBefore); + } + return this.mSpellSuggestions.length; + }, + + // undoes the work of addSuggestionsToMenu for the same menu + // (call from popup hiding) + clearSuggestionsFromMenu: function() + { + for (var i = 0; i < this.mSuggestionItems.length; i ++) { + this.mMenu.removeChild(this.mSuggestionItems[i]); + } + this.mSuggestionItems = []; + }, + + sortDictionaryList: function(list) { + var sortedList = []; + for (var i = 0; i < list.length; i ++) { + sortedList.push({"id": list[i], + "label": this.getDictionaryDisplayName(list[i])}); + } + sortedList.sort(function(a, b) { + if (a.label < b.label) + return -1; + if (a.label > b.label) + return 1; + return 0; + }); + + return sortedList; + }, + + // returns the number of dictionary languages. If insertBefore is NULL, this + // does an append to the given menu + addDictionaryListToMenu: function(menu, insertBefore) + { + this.mDictionaryMenu = menu; + this.mDictionaryNames = []; + this.mDictionaryItems = []; + + if (!this.enabled) + return 0; + + var list; + var curlang = ""; + if (this.mRemote) { + list = this.mRemote.dictionaryList; + curlang = this.mRemote.currentDictionary; + } + else if (this.mInlineSpellChecker) { + var spellchecker = this.mInlineSpellChecker.spellChecker; + var o1 = {}, o2 = {}; + spellchecker.GetDictionaryList(o1, o2); + list = o1.value; + var listcount = o2.value; + try { + curlang = spellchecker.GetCurrentDictionary(); + } catch (e) {} + } + + var sortedList = this.sortDictionaryList(list); + + for (var i = 0; i < sortedList.length; i ++) { + this.mDictionaryNames.push(sortedList[i].id); + var item = menu.ownerDocument.createElement("menuitem"); + item.setAttribute("id", "spell-check-dictionary-" + sortedList[i].id); + item.setAttribute("label", sortedList[i].label); + item.setAttribute("type", "radio"); + this.mDictionaryItems.push(item); + if (curlang == sortedList[i].id) { + item.setAttribute("checked", "true"); + } else { + var callback = function(me, val, dictName) { + return function(evt) { + me.selectDictionary(val); + // Notify change of dictionary, especially for Thunderbird, + // which is otherwise not notified any more. + var view = menu.ownerDocument.defaultView; + var spellcheckChangeEvent = new view.CustomEvent( + "spellcheck-changed", {detail: { dictionary: dictName}}); + menu.ownerDocument.dispatchEvent(spellcheckChangeEvent); + } + }; + item.addEventListener("command", callback(this, i, sortedList[i].id), true); + } + if (insertBefore) + menu.insertBefore(item, insertBefore); + else + menu.appendChild(item); + } + return list.length; + }, + + // Formats a valid BCP 47 language tag based on available localized names. + getDictionaryDisplayName: function(dictionaryName) { + try { + // Get the display name for this dictionary. + let languageTagMatch = /^([a-z]{2,3}|[a-z]{4}|[a-z]{5,8})(?:[-_]([a-z]{4}))?(?:[-_]([A-Z]{2}|[0-9]{3}))?((?:[-_](?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*)(?:[-_][a-wy-z0-9](?:[-_][a-z0-9]{2,8})+)*(?:[-_]x(?:[-_][a-z0-9]{1,8})+)?$/i; + var [languageTag, languageSubtag, scriptSubtag, regionSubtag, variantSubtags] = dictionaryName.match(languageTagMatch); + } catch (e) { + // If we weren't given a valid language tag, just use the raw dictionary name. + return dictionaryName; + } + + if (!gLanguageBundle) { + // Create the bundles for language and region names. + var bundleService = Components.classes["@mozilla.org/intl/stringbundle;1"] + .getService(Components.interfaces.nsIStringBundleService); + gLanguageBundle = bundleService.createBundle( + "chrome://global/locale/languageNames.properties"); + gRegionBundle = bundleService.createBundle( + "chrome://global/locale/regionNames.properties"); + } + + var displayName = ""; + + // Language subtag will normally be 2 or 3 letters, but could be up to 8. + try { + displayName += gLanguageBundle.GetStringFromName(languageSubtag.toLowerCase()); + } catch (e) { + displayName += languageSubtag.toLowerCase(); // Fall back to raw language subtag. + } + + // Region subtag will be 2 letters or 3 digits. + if (regionSubtag) { + displayName += " ("; + + try { + displayName += gRegionBundle.GetStringFromName(regionSubtag.toLowerCase()); + } catch (e) { + displayName += regionSubtag.toUpperCase(); // Fall back to raw region subtag. + } + + displayName += ")"; + } + + // Script subtag will be 4 letters. + if (scriptSubtag) { + displayName += " / "; + + // XXX: See bug 666662 and bug 666731 for full implementation. + displayName += scriptSubtag; // Fall back to raw script subtag. + } + + // Each variant subtag will be 4 to 8 chars. + if (variantSubtags) + // XXX: See bug 666662 and bug 666731 for full implementation. + displayName += " (" + variantSubtags.substr(1).split(/[-_]/).join(" / ") + ")"; // Collapse multiple variants. + + return displayName; + }, + + // undoes the work of addDictionaryListToMenu for the menu + // (call on popup hiding) + clearDictionaryListFromMenu: function() + { + for (var i = 0; i < this.mDictionaryItems.length; i ++) { + this.mDictionaryMenu.removeChild(this.mDictionaryItems[i]); + } + this.mDictionaryItems = []; + }, + + // callback for selecting a dictionary + selectDictionary: function(index) + { + if (this.mRemote) { + this.mRemote.selectDictionary(index); + return; + } + if (! this.mInlineSpellChecker || index < 0 || index >= this.mDictionaryNames.length) + return; + var spellchecker = this.mInlineSpellChecker.spellChecker; + spellchecker.SetCurrentDictionary(this.mDictionaryNames[index]); + this.mInlineSpellChecker.spellCheckRange(null); // causes recheck + }, + + // callback for selecting a suggested replacement + replaceMisspelling: function(index) + { + if (this.mRemote) { + this.mRemote.replaceMisspelling(index); + return; + } + if (! this.mInlineSpellChecker || ! this.mOverMisspelling) + return; + if (index < 0 || index >= this.mSpellSuggestions.length) + return; + this.mInlineSpellChecker.replaceWord(this.mWordNode, this.mWordOffset, + this.mSpellSuggestions[index]); + }, + + // callback for enabling or disabling spellchecking + toggleEnabled: function() + { + if (this.mRemote) + this.mRemote.toggleEnabled(); + else + this.mEditor.setSpellcheckUserOverride(!this.mInlineSpellChecker.enableRealTimeSpell); + }, + + // callback for adding the current misspelling to the user-defined dictionary + addToDictionary: function() + { + // Prevent the undo stack from growing over the max depth + if (this.mAddedWordStack.length == MAX_UNDO_STACK_DEPTH) + this.mAddedWordStack.shift(); + + this.mAddedWordStack.push(this.mMisspelling); + if (this.mRemote) + this.mRemote.addToDictionary(); + else { + this.mInlineSpellChecker.addWordToDictionary(this.mMisspelling); + } + }, + // callback for removing the last added word to the dictionary LIFO fashion + undoAddToDictionary: function() + { + if (this.mAddedWordStack.length > 0) + { + var word = this.mAddedWordStack.pop(); + if (this.mRemote) + this.mRemote.undoAddToDictionary(word); + else + this.mInlineSpellChecker.removeWordFromDictionary(word); + } + }, + canUndo : function() + { + // Return true if we have words on the stack + return (this.mAddedWordStack.length > 0); + }, + ignoreWord: function() + { + if (this.mRemote) + this.mRemote.ignoreWord(); + else + this.mInlineSpellChecker.ignoreWord(this.mMisspelling); + } +}; + +var SpellCheckHelper = { + // Set when over a non-read-only <textarea> or editable <input>. + EDITABLE: 0x1, + + // Set when over an <input> element of any type. + INPUT: 0x2, + + // Set when over any <textarea>. + TEXTAREA: 0x4, + + // Set when over any text-entry <input>. + TEXTINPUT: 0x8, + + // Set when over an <input> that can be used as a keyword field. + KEYWORD: 0x10, + + // Set when over an element that otherwise would not be considered + // "editable" but is because content editable is enabled for the document. + CONTENTEDITABLE: 0x20, + + // Set when over an <input type="number"> or other non-text field. + NUMERIC: 0x40, + + // Set when over an <input type="password"> field. + PASSWORD: 0x80, + + isTargetAKeywordField(aNode, window) { + if (!(aNode instanceof window.HTMLInputElement)) + return false; + + var form = aNode.form; + if (!form || aNode.type == "password") + return false; + + var method = form.method.toUpperCase(); + + // These are the following types of forms we can create keywords for: + // + // method encoding type can create keyword + // GET * YES + // * YES + // POST YES + // POST application/x-www-form-urlencoded YES + // POST text/plain NO (a little tricky to do) + // POST multipart/form-data NO + // POST everything else YES + return (method == "GET" || method == "") || + (form.enctype != "text/plain") && (form.enctype != "multipart/form-data"); + }, + + // Returns the computed style attribute for the given element. + getComputedStyle(aElem, aProp) { + return aElem.ownerDocument + .defaultView + .getComputedStyle(aElem, "").getPropertyValue(aProp); + }, + + isEditable(element, window) { + var flags = 0; + if (element instanceof window.HTMLInputElement) { + flags |= this.INPUT; + + if (element.mozIsTextField(false) || element.type == "number") { + flags |= this.TEXTINPUT; + + if (element.type == "number") { + flags |= this.NUMERIC; + } + + // Allow spellchecking UI on all text and search inputs. + if (!element.readOnly && + (element.type == "text" || element.type == "search")) { + flags |= this.EDITABLE; + } + if (this.isTargetAKeywordField(element, window)) + flags |= this.KEYWORD; + if (element.type == "password") { + flags |= this.PASSWORD; + } + } + } else if (element instanceof window.HTMLTextAreaElement) { + flags |= this.TEXTINPUT | this.TEXTAREA; + if (!element.readOnly) { + flags |= this.EDITABLE; + } + } + + if (!(flags & this.EDITABLE)) { + var win = element.ownerDocument.defaultView; + if (win) { + var isEditable = false; + try { + var editingSession = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIEditingSession); + if (editingSession.windowIsEditable(win) && + this.getComputedStyle(element, "-moz-user-modify") == "read-write") { + isEditable = true; + } + } + catch (ex) { + // If someone built with composer disabled, we can't get an editing session. + } + + if (isEditable) + flags |= this.CONTENTEDITABLE; + } + } + + return flags; + }, +}; + +function RemoteSpellChecker(aSpellInfo) { + this._spellInfo = aSpellInfo; + this._suggestionGenerator = null; +} + +RemoteSpellChecker.prototype = { + get canSpellCheck() { return this._spellInfo.canSpellCheck; }, + get spellCheckPending() { return this._spellInfo.initialSpellCheckPending; }, + get overMisspelling() { return this._spellInfo.overMisspelling; }, + get enableRealTimeSpell() { return this._spellInfo.enableRealTimeSpell; }, + + GetSuggestedWord() { + if (!this._suggestionGenerator) { + this._suggestionGenerator = (function*(spellInfo) { + for (let i of spellInfo.spellSuggestions) + yield i; + })(this._spellInfo); + } + + let next = this._suggestionGenerator.next(); + if (next.done) { + this._suggestionGenerator = null; + return ""; + } + return next.value; + }, + + get currentDictionary() { return this._spellInfo.currentDictionary }, + get dictionaryList() { return this._spellInfo.dictionaryList.slice(); }, + + selectDictionary(index) { + this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:selectDictionary", + { index }); + }, + + replaceMisspelling(index) { + this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:replaceMisspelling", + { index }); + }, + + toggleEnabled() { this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:toggleEnabled", {}); }, + addToDictionary() { + // This is really ugly. There is an nsISpellChecker somewhere in the + // parent that corresponds to our current element's spell checker in the + // child, but it's hard to access it. However, we know that + // addToDictionary adds the word to the singleton personal dictionary, so + // we just do that here. + // NB: We also rely on the fact that we only ever pass an empty string in + // as the "lang". + + let dictionary = Cc["@mozilla.org/spellchecker/personaldictionary;1"] + .getService(Ci.mozIPersonalDictionary); + dictionary.addWord(this._spellInfo.misspelling, ""); + + this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:recheck", {}); + }, + undoAddToDictionary(word) { + let dictionary = Cc["@mozilla.org/spellchecker/personaldictionary;1"] + .getService(Ci.mozIPersonalDictionary); + dictionary.removeWord(word, ""); + + this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:recheck", {}); + }, + ignoreWord() { + let dictionary = Cc["@mozilla.org/spellchecker/personaldictionary;1"] + .getService(Ci.mozIPersonalDictionary); + dictionary.ignoreWord(this._spellInfo.misspelling); + + this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:recheck", {}); + }, + uninit() { this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:uninit", {}); } +}; diff --git a/toolkit/modules/InlineSpellCheckerContent.jsm b/toolkit/modules/InlineSpellCheckerContent.jsm new file mode 100644 index 0000000000..efb24d7238 --- /dev/null +++ b/toolkit/modules/InlineSpellCheckerContent.jsm @@ -0,0 +1,141 @@ +/* vim: set ts=2 sw=2 sts=2 tw=80: */ +/* 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"; + +var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +var { SpellCheckHelper } = Cu.import("resource://gre/modules/InlineSpellChecker.jsm"); + +this.EXPORTED_SYMBOLS = [ "InlineSpellCheckerContent" ] + +var InlineSpellCheckerContent = { + _spellChecker: null, + _manager: null, + + initContextMenu(event, editFlags, messageManager) { + this._manager = messageManager; + + let spellChecker; + if (!(editFlags & (SpellCheckHelper.TEXTAREA | SpellCheckHelper.INPUT))) { + // Get the editor off the window. + let win = event.target.ownerDocument.defaultView; + let editingSession = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIEditingSession); + spellChecker = this._spellChecker = + new InlineSpellChecker(editingSession.getEditorForWindow(win)); + } else { + // Use the element's editor. + spellChecker = this._spellChecker = + new InlineSpellChecker(event.target.QueryInterface(Ci.nsIDOMNSEditableElement).editor); + } + + this._spellChecker.initFromEvent(event.rangeParent, event.rangeOffset) + + this._addMessageListeners(); + + if (!spellChecker.canSpellCheck) { + return { canSpellCheck: false, + initialSpellCheckPending: true, + enableRealTimeSpell: false }; + } + + if (!spellChecker.mInlineSpellChecker.enableRealTimeSpell) { + return { canSpellCheck: true, + initialSpellCheckPending: spellChecker.initialSpellCheckPending, + enableRealTimeSpell: false }; + } + + let dictionaryList = {}; + let realSpellChecker = spellChecker.mInlineSpellChecker.spellChecker; + realSpellChecker.GetDictionaryList(dictionaryList, {}); + + // The original list we get is in random order. We need our list to be + // sorted by display names. + dictionaryList = spellChecker.sortDictionaryList(dictionaryList.value).map((obj) => { + return obj.id; + }); + spellChecker.mDictionaryNames = dictionaryList; + + return { canSpellCheck: spellChecker.canSpellCheck, + initialSpellCheckPending: spellChecker.initialSpellCheckPending, + enableRealTimeSpell: spellChecker.enabled, + overMisspelling: spellChecker.overMisspelling, + misspelling: spellChecker.mMisspelling, + spellSuggestions: this._generateSpellSuggestions(), + currentDictionary: spellChecker.mInlineSpellChecker.spellChecker.GetCurrentDictionary(), + dictionaryList: dictionaryList }; + }, + + uninitContextMenu() { + for (let i of this._messages) + this._manager.removeMessageListener(i, this); + + this._manager = null; + this._spellChecker = null; + }, + + _generateSpellSuggestions() { + let spellChecker = this._spellChecker.mInlineSpellChecker.spellChecker; + try { + spellChecker.CheckCurrentWord(this._spellChecker.mMisspelling); + } catch (e) { + return []; + } + + let suggestions = new Array(5); + for (let i = 0; i < 5; ++i) { + suggestions[i] = spellChecker.GetSuggestedWord(); + if (suggestions[i].length === 0) { + suggestions.length = i; + break; + } + } + + this._spellChecker.mSpellSuggestions = suggestions; + return suggestions; + }, + + _messages: [ + "InlineSpellChecker:selectDictionary", + "InlineSpellChecker:replaceMisspelling", + "InlineSpellChecker:toggleEnabled", + + "InlineSpellChecker:recheck", + + "InlineSpellChecker:uninit" + ], + + _addMessageListeners() { + for (let i of this._messages) + this._manager.addMessageListener(i, this); + }, + + receiveMessage(msg) { + switch (msg.name) { + case "InlineSpellChecker:selectDictionary": + this._spellChecker.selectDictionary(msg.data.index); + break; + + case "InlineSpellChecker:replaceMisspelling": + this._spellChecker.replaceMisspelling(msg.data.index); + break; + + case "InlineSpellChecker:toggleEnabled": + this._spellChecker.toggleEnabled(); + break; + + case "InlineSpellChecker:recheck": + this._spellChecker.mInlineSpellChecker.enableRealTimeSpell = true; + break; + + case "InlineSpellChecker:uninit": + this.uninitContextMenu(); + break; + } + } +}; diff --git a/toolkit/modules/Integration.jsm b/toolkit/modules/Integration.jsm new file mode 100644 index 0000000000..d648d80ea1 --- /dev/null +++ b/toolkit/modules/Integration.jsm @@ -0,0 +1,283 @@ +/* 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/. */ + +/* + * Implements low-overhead integration between components of the application. + * This may have different uses depending on the component, including: + * + * - Providing product-specific implementations registered at startup. + * - Using alternative implementations during unit tests. + * - Allowing add-ons to change specific behaviors. + * + * Components may define one or more integration points, each defined by a + * root integration object whose properties and methods are the public interface + * and default implementation of the integration point. For example: + * + * const DownloadIntegration = { + * getTemporaryDirectory() { + * return "/tmp/"; + * }, + * + * getTemporaryFile(name) { + * return this.getTemporaryDirectory() + name; + * }, + * }; + * + * Other parts of the application may register overrides for some or all of the + * defined properties and methods. The component defining the integration point + * does not have to be loaded at this stage, because the name of the integration + * point is the only information required. For example, if the integration point + * is called "downloads": + * + * Integration.downloads.register(base => ({ + * getTemporaryDirectory() { + * return base.getTemporaryDirectory.call(this) + "subdir/"; + * }, + * })); + * + * When the component defining the integration point needs to call a method on + * the integration object, instead of using it directly the component would use + * the "getCombined" method to retrieve an object that includes all overrides. + * For example: + * + * let combined = Integration.downloads.getCombined(DownloadIntegration); + * Assert.is(combined.getTemporaryFile("file"), "/tmp/subdir/file"); + * + * Overrides can be registered at startup or at any later time, so each call to + * "getCombined" may return a different object. The simplest way to create a + * reference to the combined object that stays updated to the latest version is + * to define the root object in a JSM and use the "defineModuleGetter" method. + * + * *** Registration *** + * + * Since the interface is not declared formally, the registrations can happen + * at startup without loading the component, so they do not affect performance. + * + * Hovever, this module does not provide a startup registry, this means that the + * code that registers and implements the override must be loaded at startup. + * + * If performance for the override code is a concern, you can take advantage of + * the fact that the function used to create the override is called lazily, and + * include only a stub loader for the final code in an existing startup module. + * + * The registration of overrides should be repeated for each process where the + * relevant integration methods will be called. + * + * *** Accessing base methods and properties *** + * + * Overrides are included in the prototype chain of the combined object in the + * same order they were registered, where the first is closest to the root. + * + * When defining overrides, you do not need to set the "__proto__" property of + * the objects you create, because their properties and methods are moved to a + * new object with the correct prototype. If you do, however, you can call base + * properties and methods using the "super" keyword. For example: + * + * Integration.downloads.register(base => ({ + * __proto__: base, + * getTemporaryDirectory() { + * return super.getTemporaryDirectory() + "subdir/"; + * }, + * })); + * + * *** State handling *** + * + * Storing state directly on the combined integration object using the "this" + * reference is not recommended. When a new integration is registered, own + * properties stored on the old combined object are copied to the new combined + * object using a shallow copy, but the "this" reference for new invocations + * of the methods will be different. + * + * If the root object defines a property that always points to the same object, + * for example a "state" property, you can safely use it across registrations. + * + * Integration overrides provided by restartless add-ons should not use the + * "this" reference to store state, to avoid conflicts with other add-ons. + * + * *** Interaction with XPCOM *** + * + * Providing the combined object as an argument to any XPCOM method will + * generate a console error message, and will throw an exception where possible. + * For example, you cannot register observers directly on the combined object. + * This helps preventing mistakes due to the fact that the combined object + * reference changes when new integration overrides are registered. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "Integration", +]; + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * Maps integration point names to IntegrationPoint objects. + */ +const gIntegrationPoints = new Map(); + +/** + * This Proxy object creates IntegrationPoint objects using their name as key. + * The objects will be the same for the duration of the process. For example: + * + * Integration.downloads.register(...); + * Integration["addon-provided-integration"].register(...); + */ +this.Integration = new Proxy({}, { + get(target, name) { + let integrationPoint = gIntegrationPoints.get(name); + if (!integrationPoint) { + integrationPoint = new IntegrationPoint(); + gIntegrationPoints.set(name, integrationPoint); + } + return integrationPoint; + }, +}); + +/** + * Individual integration point for which overrides can be registered. + */ +this.IntegrationPoint = function () { + this._overrideFns = new Set(); + this._combined = { + QueryInterface: function() { + let ex = new Components.Exception( + "Integration objects should not be used with XPCOM because" + + " they change when new overrides are registered.", + Cr.NS_ERROR_NO_INTERFACE); + Cu.reportError(ex); + throw ex; + }, + }; +} + +this.IntegrationPoint.prototype = { + /** + * Ordered set of registered functions defining integration overrides. + */ + _overrideFns: null, + + /** + * Combined integration object. When this reference changes, properties + * defined directly on this object are copied to the new object. + * + * Initially, the only property of this object is a "QueryInterface" method + * that throws an exception, to prevent misuse as a permanent XPCOM listener. + */ + _combined: null, + + /** + * Indicates whether the integration object is current based on the list of + * registered integration overrides. + */ + _combinedIsCurrent: false, + + /** + * Registers new overrides for the integration methods. For example: + * + * Integration.nameOfIntegrationPoint.register(base => ({ + * asyncMethod: Task.async(function* () { + * return yield base.asyncMethod.apply(this, arguments); + * }), + * })); + * + * @param overrideFn + * Function returning an object defining the methods that should be + * overridden. Its only parameter is an object that contains the base + * implementation of all the available methods. + * + * @note The override function is called every time the list of registered + * override functions changes. Thus, it should not have any side + * effects or do any other initialization. + */ + register(overrideFn) { + this._overrideFns.add(overrideFn); + this._combinedIsCurrent = false; + }, + + /** + * Removes a previously registered integration override. + * + * Overrides don't usually need to be unregistered, unless they are added by a + * restartless add-on, in which case they should be unregistered when the + * add-on is disabled or uninstalled. + * + * @param overrideFn + * This must be the same function object passed to "register". + */ + unregister(overrideFn) { + this._overrideFns.delete(overrideFn); + this._combinedIsCurrent = false; + }, + + /** + * Retrieves the dynamically generated object implementing the integration + * methods. Platform-specific code and add-ons can override methods of this + * object using the "register" method. + */ + getCombined(root) { + if (this._combinedIsCurrent) { + return this._combined; + } + + // In addition to enumerating all the registered integration overrides in + // order, we want to keep any state that was previously stored in the + // combined object using the "this" reference in integration methods. + let overrideFnArray = [...this._overrideFns, () => this._combined]; + + let combined = root; + for (let overrideFn of overrideFnArray) { + try { + // Obtain a new set of methods from the next override function in the + // list, specifying the current combined object as the base argument. + let override = overrideFn.call(null, combined); + + // Retrieve a list of property descriptors from the returned object, and + // use them to build a new combined object whose prototype points to the + // previous combined object. + let descriptors = {}; + for (let name of Object.getOwnPropertyNames(override)) { + descriptors[name] = Object.getOwnPropertyDescriptor(override, name); + } + combined = Object.create(combined, descriptors); + } catch (ex) { + // Any error will result in the current override being skipped. + Cu.reportError(ex); + } + } + + this._combinedIsCurrent = true; + return this._combined = combined; + }, + + /** + * Defines a getter to retrieve the dynamically generated object implementing + * the integration methods, loading the root implementation lazily from the + * specified JSM module. For example: + * + * Integration.test.defineModuleGetter(this, "TestIntegration", + * "resource://testing-common/TestIntegration.jsm"); + * + * @param targetObject + * The object on which the lazy getter will be defined. + * @param name + * The name of the getter to define. + * @param moduleUrl + * The URL used to obtain the module. + * @param symbol [optional] + * The name of the symbol exported by the module. This can be omitted + * if the name of the exported symbol is equal to the getter name. + */ + defineModuleGetter(targetObject, name, moduleUrl, symbol) { + let moduleHolder = {}; + XPCOMUtils.defineLazyModuleGetter(moduleHolder, name, moduleUrl, symbol); + Object.defineProperty(targetObject, name, { + get: () => this.getCombined(moduleHolder[name]), + configurable: true, + enumerable: true, + }); + }, +}; diff --git a/toolkit/modules/JSONFile.jsm b/toolkit/modules/JSONFile.jsm new file mode 100644 index 0000000000..2926dca786 --- /dev/null +++ b/toolkit/modules/JSONFile.jsm @@ -0,0 +1,266 @@ +/* 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/. */ + +/** + * Handles serialization of the data and persistence into a file. + * + * This modules handles the raw data stored in JavaScript serializable objects, + * and contains no special validation or query logic, that is handled entirely + * by "storage.js" instead. + * + * The data can be manipulated only after it has been loaded from disk. The + * load process can happen asynchronously, through the "load" method, or + * synchronously, through "ensureDataReady". After any modification, the + * "saveSoon" method must be called to flush the data to disk asynchronously. + * + * The raw data should be manipulated synchronously, without waiting for the + * event loop or for promise resolution, so that the saved file is always + * consistent. This synchronous approach also simplifies the query and update + * logic. For example, it is possible to find an object and modify it + * immediately without caring whether other code modifies it in the meantime. + * + * An asynchronous shutdown observer makes sure that data is always saved before + * the browser is closed. The data cannot be modified during shutdown. + * + * The file is stored in JSON format, without indentation, using UTF-8 encoding. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "JSONFile", +]; + +// Globals + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask", + "resource://gre/modules/DeferredTask.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); + +XPCOMUtils.defineLazyGetter(this, "gTextDecoder", function () { + return new TextDecoder(); +}); + +XPCOMUtils.defineLazyGetter(this, "gTextEncoder", function () { + return new TextEncoder(); +}); + +const FileInputStream = + Components.Constructor("@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", "init"); + +/** + * Delay between a change to the data and the related save operation. + */ +const kSaveDelayMs = 1500; + +// JSONFile + +/** + * Handles serialization of the data and persistence into a file. + * + * @param config An object containing following members: + * - path: String containing the file path where data should be saved. + * - dataPostProcessor: Function triggered when data is just loaded. The + * data object will be passed as the first argument + * and should be returned no matter it's modified or + * not. Its failure leads to the failure of load() + * and ensureDataReady(). + * - saveDelayMs: Number indicating the delay (in milliseconds) between a + * change to the data and the related save operation. The + * default value will be applied if omitted. + */ +function JSONFile(config) { + this.path = config.path; + + if (typeof config.dataPostProcessor === "function") { + this._dataPostProcessor = config.dataPostProcessor; + } + + if (config.saveDelayMs === undefined) { + config.saveDelayMs = kSaveDelayMs; + } + this._saver = new DeferredTask(() => this._save(), config.saveDelayMs); + + AsyncShutdown.profileBeforeChange.addBlocker("JSON store: writing data", + () => this._saver.finalize()); +} + +JSONFile.prototype = { + /** + * String containing the file path where data should be saved. + */ + path: "", + + /** + * True when data has been loaded. + */ + dataReady: false, + + /** + * DeferredTask that handles the save operation. + */ + _saver: null, + + /** + * Internal data object. + */ + _data: null, + + /** + * Serializable object containing the data. This is populated directly with + * the data loaded from the file, and is saved without modifications. + * + * The raw data should be manipulated synchronously, without waiting for the + * event loop or for promise resolution, so that the saved file is always + * consistent. + */ + get data() { + if (!this.dataReady) { + throw new Error("Data is not ready."); + } + return this._data; + }, + + /** + * Loads persistent data from the file to memory. + * + * @return {Promise} + * @resolves When the operation finished successfully. + * @rejects JavaScript exception when dataPostProcessor fails. It never fails + * if there is no dataPostProcessor. + */ + load: Task.async(function* () { + let data = {}; + + try { + let bytes = yield OS.File.read(this.path); + + // If synchronous loading happened in the meantime, exit now. + if (this.dataReady) { + return; + } + + data = JSON.parse(gTextDecoder.decode(bytes)); + } catch (ex) { + // If an exception occurred because the file did not exist, we should + // just start with new data. Other errors may indicate that the file is + // corrupt, thus we move it to a backup location before allowing it to + // be overwritten by an empty file. + if (!(ex instanceof OS.File.Error && ex.becauseNoSuchFile)) { + Cu.reportError(ex); + + // Move the original file to a backup location, ignoring errors. + try { + let openInfo = yield OS.File.openUnique(this.path + ".corrupt", + { humanReadable: true }); + yield openInfo.file.close(); + yield OS.File.move(this.path, openInfo.path); + } catch (e2) { + Cu.reportError(e2); + } + } + + // In some rare cases it's possible for data to have been added to + // our database between the call to OS.File.read and when we've been + // notified that there was a problem with it. In that case, leave the + // synchronously-added data alone. + if (this.dataReady) { + return; + } + } + + this._processLoadedData(data); + }), + + /** + * Loads persistent data from the file to memory, synchronously. An exception + * can be thrown only if dataPostProcessor exists and fails. + */ + ensureDataReady() { + if (this.dataReady) { + return; + } + + let data = {}; + + try { + // This reads the file and automatically detects the UTF-8 encoding. + let inputStream = new FileInputStream(new FileUtils.File(this.path), + FileUtils.MODE_RDONLY, + FileUtils.PERMS_FILE, 0); + try { + let json = Cc["@mozilla.org/dom/json;1"].createInstance(Ci.nsIJSON); + data = json.decodeFromStream(inputStream, inputStream.available()); + } finally { + inputStream.close(); + } + } catch (ex) { + // If an exception occurred because the file did not exist, we should just + // start with new data. Other errors may indicate that the file is + // corrupt, thus we move it to a backup location before allowing it to be + // overwritten by an empty file. + if (!(ex instanceof Components.Exception && + ex.result == Cr.NS_ERROR_FILE_NOT_FOUND)) { + Cu.reportError(ex); + // Move the original file to a backup location, ignoring errors. + try { + let originalFile = new FileUtils.File(this.path); + let backupFile = originalFile.clone(); + backupFile.leafName += ".corrupt"; + backupFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, + FileUtils.PERMS_FILE); + backupFile.remove(false); + originalFile.moveTo(backupFile.parent, backupFile.leafName); + } catch (e2) { + Cu.reportError(e2); + } + } + } + + this._processLoadedData(data); + }, + + /** + * Called when the data changed, this triggers asynchronous serialization. + */ + saveSoon() { + return this._saver.arm(); + }, + + /** + * Saves persistent data from memory to the file. + * + * If an error occurs, the previous file is not deleted. + * + * @return {Promise} + * @resolves When the operation finished successfully. + * @rejects JavaScript exception. + */ + _save: Task.async(function* () { + // Create or overwrite the file. + let bytes = gTextEncoder.encode(JSON.stringify(this._data)); + yield OS.File.writeAtomic(this.path, bytes, + { tmpPath: this.path + ".tmp" }); + }), + + /** + * Synchronously work on the data just loaded into memory. + */ + _processLoadedData(data) { + this._data = this._dataPostProcessor ? this._dataPostProcessor(data) : data; + this.dataReady = true; + }, +}; diff --git a/toolkit/modules/LightweightThemeConsumer.jsm b/toolkit/modules/LightweightThemeConsumer.jsm new file mode 100644 index 0000000000..1dd4c976a6 --- /dev/null +++ b/toolkit/modules/LightweightThemeConsumer.jsm @@ -0,0 +1,180 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["LightweightThemeConsumer"]; + +const {utils: Cu} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/AppConstants.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeImageOptimizer", + "resource://gre/modules/addons/LightweightThemeImageOptimizer.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +this.LightweightThemeConsumer = + function LightweightThemeConsumer(aDocument) { + this._doc = aDocument; + this._win = aDocument.defaultView; + this._footerId = aDocument.documentElement.getAttribute("lightweightthemesfooter"); + + if (PrivateBrowsingUtils.isWindowPrivate(this._win) && + !PrivateBrowsingUtils.permanentPrivateBrowsing) { + return; + } + + let screen = this._win.screen; + this._lastScreenWidth = screen.width; + this._lastScreenHeight = screen.height; + + Services.obs.addObserver(this, "lightweight-theme-styling-update", false); + + var temp = {}; + Cu.import("resource://gre/modules/LightweightThemeManager.jsm", temp); + this._update(temp.LightweightThemeManager.currentThemeForDisplay); + this._win.addEventListener("resize", this); +} + +LightweightThemeConsumer.prototype = { + _lastData: null, + _lastScreenWidth: null, + _lastScreenHeight: null, + // Whether the active lightweight theme should be shown on the window. + _enabled: true, + // Whether a lightweight theme is enabled. + _active: false, + + enable: function() { + this._enabled = true; + this._update(this._lastData); + }, + + disable: function() { + // Dance to keep the data, but reset the applied styles: + let lastData = this._lastData + this._update(null); + this._enabled = false; + this._lastData = lastData; + }, + + getData: function() { + return this._enabled ? Cu.cloneInto(this._lastData, this._win) : null; + }, + + observe: function (aSubject, aTopic, aData) { + if (aTopic != "lightweight-theme-styling-update") + return; + + this._update(JSON.parse(aData)); + }, + + handleEvent: function (aEvent) { + let {width, height} = this._win.screen; + + if (this._lastScreenWidth != width || this._lastScreenHeight != height) { + this._lastScreenWidth = width; + this._lastScreenHeight = height; + if (!this._active) + return; + this._update(this._lastData); + Services.obs.notifyObservers(this._win, "lightweight-theme-optimized", + JSON.stringify(this._lastData)); + } + }, + + destroy: function () { + if (!PrivateBrowsingUtils.isWindowPrivate(this._win) || + PrivateBrowsingUtils.permanentPrivateBrowsing) { + Services.obs.removeObserver(this, "lightweight-theme-styling-update"); + + this._win.removeEventListener("resize", this); + } + + this._win = this._doc = null; + }, + + _update: function (aData) { + if (!aData) { + aData = { headerURL: "", footerURL: "", textcolor: "", accentcolor: "" }; + this._lastData = aData; + } else { + this._lastData = aData; + aData = LightweightThemeImageOptimizer.optimize(aData, this._win.screen); + } + if (!this._enabled) + return; + + let root = this._doc.documentElement; + let active = !!aData.headerURL; + let stateChanging = (active != this._active); + + // We need to clear these either way: either because the theme is being removed, + // or because we are applying a new theme and the data might be bogus CSS, + // so if we don't reset first, it'll keep the old value. + root.style.removeProperty("color"); + root.style.removeProperty("background-color"); + if (active) { + root.style.color = aData.textcolor || "black"; + root.style.backgroundColor = aData.accentcolor || "white"; + let [r, g, b] = _parseRGB(this._doc.defaultView.getComputedStyle(root, "").color); + let luminance = 0.2125 * r + 0.7154 * g + 0.0721 * b; + root.setAttribute("lwthemetextcolor", luminance <= 110 ? "dark" : "bright"); + root.setAttribute("lwtheme", "true"); + } else { + root.removeAttribute("lwthemetextcolor"); + root.removeAttribute("lwtheme"); + } + + this._active = active; + + _setImage(root, active, aData.headerURL); + if (this._footerId) { + let footer = this._doc.getElementById(this._footerId); + footer.style.backgroundColor = active ? aData.accentcolor || "white" : ""; + _setImage(footer, active, aData.footerURL); + if (active && aData.footerURL) + footer.setAttribute("lwthemefooter", "true"); + else + footer.removeAttribute("lwthemefooter"); + } + + // On OS X, we extend the lightweight theme into the titlebar, which means setting + // the chromemargin attribute. Some XUL applications already draw in the titlebar, + // so we need to save the chromemargin value before we overwrite it with the value + // that lets us draw in the titlebar. We stash this value on the root attribute so + // that XUL applications have the ability to invalidate the saved value. + if (AppConstants.platform == "macosx" && stateChanging) { + if (!root.hasAttribute("chromemargin-nonlwtheme")) { + root.setAttribute("chromemargin-nonlwtheme", root.getAttribute("chromemargin")); + } + + if (active) { + root.setAttribute("chromemargin", "0,-1,-1,-1"); + } else { + let defaultChromemargin = root.getAttribute("chromemargin-nonlwtheme"); + if (defaultChromemargin) { + root.setAttribute("chromemargin", defaultChromemargin); + } else { + root.removeAttribute("chromemargin"); + } + } + } + Services.obs.notifyObservers(this._win, "lightweight-theme-window-updated", + JSON.stringify(aData)); + } +} + +function _setImage(aElement, aActive, aURL) { + aElement.style.backgroundImage = + (aActive && aURL) ? 'url("' + aURL.replace(/"/g, '\\"') + '")' : ""; +} + +function _parseRGB(aColorString) { + var rgb = aColorString.match(/^rgba?\((\d+), (\d+), (\d+)/); + rgb.shift(); + return rgb.map(x => parseInt(x)); +} diff --git a/toolkit/modules/LoadContextInfo.jsm b/toolkit/modules/LoadContextInfo.jsm new file mode 100644 index 0000000000..59c5fa620d --- /dev/null +++ b/toolkit/modules/LoadContextInfo.jsm @@ -0,0 +1,15 @@ +/* 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/. */ + +/** + * This jsm is here only for compatibility. Extension developers may use it + * to build nsILoadContextInfo to pass down to HTTP cache APIs. Originally + * it was possible to implement nsILoadContextInfo in JS. But now it turned + * out to be a built-in class only, so we need a component (service) as + * a factory to build nsILoadContextInfo in a JS code. + */ + +this.EXPORTED_SYMBOLS = ["LoadContextInfo"]; +this.LoadContextInfo = Components.classes["@mozilla.org/load-context-info-factory;1"] + .getService(Components.interfaces.nsILoadContextInfoFactory); diff --git a/toolkit/modules/Locale.jsm b/toolkit/modules/Locale.jsm new file mode 100644 index 0000000000..886de9e3a1 --- /dev/null +++ b/toolkit/modules/Locale.jsm @@ -0,0 +1,93 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["Locale"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); + +const PREF_MATCH_OS_LOCALE = "intl.locale.matchOS"; +const PREF_SELECTED_LOCALE = "general.useragent.locale"; + +this.Locale = { + /** + * Gets the currently selected locale for display. + * @return the selected locale or "en-US" if none is selected + */ + getLocale() { + if (Preferences.get(PREF_MATCH_OS_LOCALE, false)) + return Services.locale.getLocaleComponentForUserAgent(); + try { + let locale = Preferences.get(PREF_SELECTED_LOCALE, null, Ci.nsIPrefLocalizedString); + if (locale) + return locale; + } + catch (e) {} + return Preferences.get(PREF_SELECTED_LOCALE, "en-US"); + }, + + /** + * Selects the closest matching locale from a list of locales. + * + * @param aLocales + * An array of locales + * @return the best match for the currently selected locale + */ + findClosestLocale(aLocales) { + let appLocale = this.getLocale(); + + // Holds the best matching localized resource + var bestmatch = null; + // The number of locale parts it matched with + var bestmatchcount = 0; + // The number of locale parts in the match + var bestpartcount = 0; + + var matchLocales = [appLocale.toLowerCase()]; + /* If the current locale is English then it will find a match if there is + a valid match for en-US so no point searching that locale too. */ + if (matchLocales[0].substring(0, 3) != "en-") + matchLocales.push("en-us"); + + for (let locale of matchLocales) { + var lparts = locale.split("-"); + for (let localized of aLocales) { + for (let found of localized.locales) { + found = found.toLowerCase(); + // Exact match is returned immediately + if (locale == found) + return localized; + + var fparts = found.split("-"); + /* If we have found a possible match and this one isn't any longer + then we dont need to check further. */ + if (bestmatch && fparts.length < bestmatchcount) + continue; + + // Count the number of parts that match + var maxmatchcount = Math.min(fparts.length, lparts.length); + var matchcount = 0; + while (matchcount < maxmatchcount && + fparts[matchcount] == lparts[matchcount]) + matchcount++; + + /* If we matched more than the last best match or matched the same and + this locale is less specific than the last best match. */ + if (matchcount > bestmatchcount || + (matchcount == bestmatchcount && fparts.length < bestpartcount)) { + bestmatch = localized; + bestmatchcount = matchcount; + bestpartcount = fparts.length; + } + } + } + // If we found a valid match for this locale return it + if (bestmatch) + return bestmatch; + } + return null; + }, +}; diff --git a/toolkit/modules/Log.jsm b/toolkit/modules/Log.jsm new file mode 100644 index 0000000000..6b741ff9e6 --- /dev/null +++ b/toolkit/modules/Log.jsm @@ -0,0 +1,969 @@ +/* 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 = ["Log"]; + +const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components; + +const ONE_BYTE = 1; +const ONE_KILOBYTE = 1024 * ONE_BYTE; +const ONE_MEGABYTE = 1024 * ONE_KILOBYTE; + +const STREAM_SEGMENT_SIZE = 4096; +const PR_UINT32_MAX = 0xffffffff; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +const INTERNAL_FIELDS = new Set(["_level", "_message", "_time", "_namespace"]); + + +/* + * Dump a message everywhere we can if we have a failure. + */ +function dumpError(text) { + dump(text + "\n"); + Cu.reportError(text); +} + +this.Log = { + Level: { + Fatal: 70, + Error: 60, + Warn: 50, + Info: 40, + Config: 30, + Debug: 20, + Trace: 10, + All: -1, // We don't want All to be falsy. + Desc: { + 70: "FATAL", + 60: "ERROR", + 50: "WARN", + 40: "INFO", + 30: "CONFIG", + 20: "DEBUG", + 10: "TRACE", + "-1": "ALL", + }, + Numbers: { + "FATAL": 70, + "ERROR": 60, + "WARN": 50, + "INFO": 40, + "CONFIG": 30, + "DEBUG": 20, + "TRACE": 10, + "ALL": -1, + } + }, + + get repository() { + delete Log.repository; + Log.repository = new LoggerRepository(); + return Log.repository; + }, + set repository(value) { + delete Log.repository; + Log.repository = value; + }, + + LogMessage: LogMessage, + Logger: Logger, + LoggerRepository: LoggerRepository, + + Formatter: Formatter, + BasicFormatter: BasicFormatter, + MessageOnlyFormatter: MessageOnlyFormatter, + StructuredFormatter: StructuredFormatter, + + Appender: Appender, + DumpAppender: DumpAppender, + ConsoleAppender: ConsoleAppender, + StorageStreamAppender: StorageStreamAppender, + + FileAppender: FileAppender, + BoundedFileAppender: BoundedFileAppender, + + ParameterFormatter: ParameterFormatter, + // Logging helper: + // let logger = Log.repository.getLogger("foo"); + // logger.info(Log.enumerateInterfaces(someObject).join(",")); + enumerateInterfaces: function Log_enumerateInterfaces(aObject) { + let interfaces = []; + + for (i in Ci) { + try { + aObject.QueryInterface(Ci[i]); + interfaces.push(i); + } + catch (ex) {} + } + + return interfaces; + }, + + // Logging helper: + // let logger = Log.repository.getLogger("foo"); + // logger.info(Log.enumerateProperties(someObject).join(",")); + enumerateProperties: function (aObject, aExcludeComplexTypes) { + let properties = []; + + for (p in aObject) { + try { + if (aExcludeComplexTypes && + (typeof(aObject[p]) == "object" || typeof(aObject[p]) == "function")) + continue; + properties.push(p + " = " + aObject[p]); + } + catch (ex) { + properties.push(p + " = " + ex); + } + } + + return properties; + }, + + _formatError: function _formatError(e) { + let result = e.toString(); + if (e.fileName) { + result += " (" + e.fileName; + if (e.lineNumber) { + result += ":" + e.lineNumber; + } + if (e.columnNumber) { + result += ":" + e.columnNumber; + } + result += ")"; + } + return result + " " + Log.stackTrace(e); + }, + + // This is for back compatibility with services/common/utils.js; we duplicate + // some of the logic in ParameterFormatter + exceptionStr: function exceptionStr(e) { + if (!e) { + return "" + e; + } + if (e instanceof Ci.nsIException) { + return e.toString() + " " + Log.stackTrace(e); + } + else if (isError(e)) { + return Log._formatError(e); + } + // else + let message = e.message ? e.message : e; + return message + " " + Log.stackTrace(e); + }, + + stackTrace: function stackTrace(e) { + // Wrapped nsIException + if (e.location) { + let frame = e.location; + let output = []; + while (frame) { + // Works on frames or exceptions, munges file:// URIs to shorten the paths + // FIXME: filename munging is sort of hackish, might be confusing if + // there are multiple extensions with similar filenames + let str = "<file:unknown>"; + + let file = frame.filename || frame.fileName; + if (file) { + str = file.replace(/^(?:chrome|file):.*?([^\/\.]+\.\w+)$/, "$1"); + } + + if (frame.lineNumber) { + str += ":" + frame.lineNumber; + } + + if (frame.name) { + str = frame.name + "()@" + str; + } + + if (str) { + output.push(str); + } + frame = frame.caller; + } + return "Stack trace: " + output.join(" < "); + } + // Standard JS exception + if (e.stack) { + return "JS Stack trace: " + Task.Debugging.generateReadableStack(e.stack).trim() + .replace(/\n/g, " < ").replace(/@[^@]*?([^\/\.]+\.\w+:)/g, "@$1"); + } + + return "No traceback available"; + } +}; + +/* + * LogMessage + * Encapsulates a single log event's data + */ +function LogMessage(loggerName, level, message, params) { + this.loggerName = loggerName; + this.level = level; + /* + * Special case to handle "log./level/(object)", for example logging a caught exception + * without providing text or params like: catch(e) { logger.warn(e) } + * Treating this as an empty text with the object in the 'params' field causes the + * object to be formatted properly by BasicFormatter. + */ + if (!params && message && (typeof(message) == "object") && + (typeof(message.valueOf()) != "string")) { + this.message = null; + this.params = message; + } else { + // If the message text is empty, or a string, or a String object, normal handling + this.message = message; + this.params = params; + } + + // The _structured field will correspond to whether this message is to + // be interpreted as a structured message. + this._structured = this.params && this.params.action; + this.time = Date.now(); +} +LogMessage.prototype = { + get levelDesc() { + if (this.level in Log.Level.Desc) + return Log.Level.Desc[this.level]; + return "UNKNOWN"; + }, + + toString: function LogMsg_toString() { + let msg = "LogMessage [" + this.time + " " + this.level + " " + + this.message; + if (this.params) { + msg += " " + JSON.stringify(this.params); + } + return msg + "]" + } +}; + +/* + * Logger + * Hierarchical version. Logs to all appenders, assigned or inherited + */ + +function Logger(name, repository) { + if (!repository) + repository = Log.repository; + this._name = name; + this.children = []; + this.ownAppenders = []; + this.appenders = []; + this._repository = repository; +} +Logger.prototype = { + get name() { + return this._name; + }, + + _level: null, + get level() { + if (this._level != null) + return this._level; + if (this.parent) + return this.parent.level; + dumpError("Log warning: root logger configuration error: no level defined"); + return Log.Level.All; + }, + set level(level) { + this._level = level; + }, + + _parent: null, + get parent() { + return this._parent; + }, + set parent(parent) { + if (this._parent == parent) { + return; + } + // Remove ourselves from parent's children + if (this._parent) { + let index = this._parent.children.indexOf(this); + if (index != -1) { + this._parent.children.splice(index, 1); + } + } + this._parent = parent; + parent.children.push(this); + this.updateAppenders(); + }, + + updateAppenders: function updateAppenders() { + if (this._parent) { + let notOwnAppenders = this._parent.appenders.filter(function(appender) { + return this.ownAppenders.indexOf(appender) == -1; + }, this); + this.appenders = notOwnAppenders.concat(this.ownAppenders); + } else { + this.appenders = this.ownAppenders.slice(); + } + + // Update children's appenders. + for (let i = 0; i < this.children.length; i++) { + this.children[i].updateAppenders(); + } + }, + + addAppender: function Logger_addAppender(appender) { + if (this.ownAppenders.indexOf(appender) != -1) { + return; + } + this.ownAppenders.push(appender); + this.updateAppenders(); + }, + + removeAppender: function Logger_removeAppender(appender) { + let index = this.ownAppenders.indexOf(appender); + if (index == -1) { + return; + } + this.ownAppenders.splice(index, 1); + this.updateAppenders(); + }, + + /** + * Logs a structured message object. + * + * @param action + * (string) A message action, one of a set of actions known to the + * log consumer. + * @param params + * (object) Parameters to be included in the message. + * If _level is included as a key and the corresponding value + * is a number or known level name, the message will be logged + * at the indicated level. If _message is included as a key, the + * value is used as the descriptive text for the message. + */ + logStructured: function (action, params) { + if (!action) { + throw "An action is required when logging a structured message."; + } + if (!params) { + this.log(this.level, undefined, {"action": action}); + return; + } + if (typeof(params) != "object") { + throw "The params argument is required to be an object."; + } + + let level = params._level; + if (level) { + let ulevel = level.toUpperCase(); + if (ulevel in Log.Level.Numbers) { + level = Log.Level.Numbers[ulevel]; + } + } else { + level = this.level; + } + + params.action = action; + this.log(level, params._message, params); + }, + + log: function (level, string, params) { + if (this.level > level) + return; + + // Hold off on creating the message object until we actually have + // an appender that's responsible. + let message; + let appenders = this.appenders; + for (let appender of appenders) { + if (appender.level > level) { + continue; + } + if (!message) { + message = new LogMessage(this._name, level, string, params); + } + appender.append(message); + } + }, + + fatal: function (string, params) { + this.log(Log.Level.Fatal, string, params); + }, + error: function (string, params) { + this.log(Log.Level.Error, string, params); + }, + warn: function (string, params) { + this.log(Log.Level.Warn, string, params); + }, + info: function (string, params) { + this.log(Log.Level.Info, string, params); + }, + config: function (string, params) { + this.log(Log.Level.Config, string, params); + }, + debug: function (string, params) { + this.log(Log.Level.Debug, string, params); + }, + trace: function (string, params) { + this.log(Log.Level.Trace, string, params); + } +}; + +/* + * LoggerRepository + * Implements a hierarchy of Loggers + */ + +function LoggerRepository() {} +LoggerRepository.prototype = { + _loggers: {}, + + _rootLogger: null, + get rootLogger() { + if (!this._rootLogger) { + this._rootLogger = new Logger("root", this); + this._rootLogger.level = Log.Level.All; + } + return this._rootLogger; + }, + set rootLogger(logger) { + throw "Cannot change the root logger"; + }, + + _updateParents: function LogRep__updateParents(name) { + let pieces = name.split('.'); + let cur, parent; + + // find the closest parent + // don't test for the logger name itself, as there's a chance it's already + // there in this._loggers + for (let i = 0; i < pieces.length - 1; i++) { + if (cur) + cur += '.' + pieces[i]; + else + cur = pieces[i]; + if (cur in this._loggers) + parent = cur; + } + + // if we didn't assign a parent above, there is no parent + if (!parent) + this._loggers[name].parent = this.rootLogger; + else + this._loggers[name].parent = this._loggers[parent]; + + // trigger updates for any possible descendants of this logger + for (let logger in this._loggers) { + if (logger != name && logger.indexOf(name) == 0) + this._updateParents(logger); + } + }, + + /** + * Obtain a named Logger. + * + * The returned Logger instance for a particular name is shared among + * all callers. In other words, if two consumers call getLogger("foo"), + * they will both have a reference to the same object. + * + * @return Logger + */ + getLogger: function (name) { + if (name in this._loggers) + return this._loggers[name]; + this._loggers[name] = new Logger(name, this); + this._updateParents(name); + return this._loggers[name]; + }, + + /** + * Obtain a Logger that logs all string messages with a prefix. + * + * A common pattern is to have separate Logger instances for each instance + * of an object. But, you still want to distinguish between each instance. + * Since Log.repository.getLogger() returns shared Logger objects, + * monkeypatching one Logger modifies them all. + * + * This function returns a new object with a prototype chain that chains + * up to the original Logger instance. The new prototype has log functions + * that prefix content to each message. + * + * @param name + * (string) The Logger to retrieve. + * @param prefix + * (string) The string to prefix each logged message with. + */ + getLoggerWithMessagePrefix: function (name, prefix) { + let log = this.getLogger(name); + + let proxy = Object.create(log); + proxy.log = (level, string, params) => log.log(level, prefix + string, params); + return proxy; + }, +}; + +/* + * Formatters + * These massage a LogMessage into whatever output is desired. + * BasicFormatter and StructuredFormatter are implemented here. + */ + +// Abstract formatter +function Formatter() {} +Formatter.prototype = { + format: function Formatter_format(message) {} +}; + +// Basic formatter that doesn't do anything fancy. +function BasicFormatter(dateFormat) { + if (dateFormat) { + this.dateFormat = dateFormat; + } + this.parameterFormatter = new ParameterFormatter(); +} +BasicFormatter.prototype = { + __proto__: Formatter.prototype, + + /** + * Format the text of a message with optional parameters. + * If the text contains ${identifier}, replace that with + * the value of params[identifier]; if ${}, replace that with + * the entire params object. If no params have been substituted + * into the text, format the entire object and append that + * to the message. + */ + formatText: function (message) { + let params = message.params; + if (typeof(params) == "undefined") { + return message.message || ""; + } + // Defensive handling of non-object params + // We could add a special case for NSRESULT values here... + let pIsObject = (typeof(params) == 'object' || typeof(params) == 'function'); + + // if we have params, try and find substitutions. + if (this.parameterFormatter) { + // have we successfully substituted any parameters into the message? + // in the log message + let subDone = false; + let regex = /\$\{(\S*)\}/g; + let textParts = []; + if (message.message) { + textParts.push(message.message.replace(regex, (_, sub) => { + // ${foo} means use the params['foo'] + if (sub) { + if (pIsObject && sub in message.params) { + subDone = true; + return this.parameterFormatter.format(message.params[sub]); + } + return '${' + sub + '}'; + } + // ${} means use the entire params object. + subDone = true; + return this.parameterFormatter.format(message.params); + })); + } + if (!subDone) { + // There were no substitutions in the text, so format the entire params object + let rest = this.parameterFormatter.format(message.params); + if (rest !== null && rest != "{}") { + textParts.push(rest); + } + } + return textParts.join(': '); + } + return undefined; + }, + + format: function BF_format(message) { + return message.time + "\t" + + message.loggerName + "\t" + + message.levelDesc + "\t" + + this.formatText(message); + } +}; + +/** + * A formatter that only formats the string message component. + */ +function MessageOnlyFormatter() { +} +MessageOnlyFormatter.prototype = Object.freeze({ + __proto__: Formatter.prototype, + + format: function (message) { + return message.message; + }, +}); + +// Structured formatter that outputs JSON based on message data. +// This formatter will format unstructured messages by supplying +// default values. +function StructuredFormatter() { } +StructuredFormatter.prototype = { + __proto__: Formatter.prototype, + + format: function (logMessage) { + let output = { + _time: logMessage.time, + _namespace: logMessage.loggerName, + _level: logMessage.levelDesc + }; + + for (let key in logMessage.params) { + output[key] = logMessage.params[key]; + } + + if (!output.action) { + output.action = "UNKNOWN"; + } + + if (!output._message && logMessage.message) { + output._message = logMessage.message; + } + + return JSON.stringify(output); + } +} + +/** + * Test an object to see if it is a Mozilla JS Error. + */ +function isError(aObj) { + return (aObj && typeof(aObj) == 'object' && "name" in aObj && "message" in aObj && + "fileName" in aObj && "lineNumber" in aObj && "stack" in aObj); +} + +/* + * Parameter Formatters + * These massage an object used as a parameter for a LogMessage into + * a string representation of the object. + */ + +function ParameterFormatter() { + this._name = "ParameterFormatter" +} +ParameterFormatter.prototype = { + format: function(ob) { + try { + if (ob === undefined) { + return "undefined"; + } + if (ob === null) { + return "null"; + } + // Pass through primitive types and objects that unbox to primitive types. + if ((typeof(ob) != "object" || typeof(ob.valueOf()) != "object") && + typeof(ob) != "function") { + return ob; + } + if (ob instanceof Ci.nsIException) { + return ob.toString() + " " + Log.stackTrace(ob); + } + else if (isError(ob)) { + return Log._formatError(ob); + } + // Just JSONify it. Filter out our internal fields and those the caller has + // already handled. + return JSON.stringify(ob, (key, val) => { + if (INTERNAL_FIELDS.has(key)) { + return undefined; + } + return val; + }); + } + catch (e) { + dumpError("Exception trying to format object for log message: " + Log.exceptionStr(e)); + } + // Fancy formatting failed. Just toSource() it - but even this may fail! + try { + return ob.toSource(); + } catch (_) { } + try { + return "" + ob; + } catch (_) { + return "[object]" + } + } +} + +/* + * Appenders + * These can be attached to Loggers to log to different places + * Simply subclass and override doAppend to implement a new one + */ + +function Appender(formatter) { + this._name = "Appender"; + this._formatter = formatter? formatter : new BasicFormatter(); +} +Appender.prototype = { + level: Log.Level.All, + + append: function App_append(message) { + if (message) { + this.doAppend(this._formatter.format(message)); + } + }, + toString: function App_toString() { + return this._name + " [level=" + this.level + + ", formatter=" + this._formatter + "]"; + }, + doAppend: function App_doAppend(formatted) {} +}; + +/* + * DumpAppender + * Logs to standard out + */ + +function DumpAppender(formatter) { + Appender.call(this, formatter); + this._name = "DumpAppender"; +} +DumpAppender.prototype = { + __proto__: Appender.prototype, + + doAppend: function DApp_doAppend(formatted) { + dump(formatted + "\n"); + } +}; + +/* + * ConsoleAppender + * Logs to the javascript console + */ + +function ConsoleAppender(formatter) { + Appender.call(this, formatter); + this._name = "ConsoleAppender"; +} +ConsoleAppender.prototype = { + __proto__: Appender.prototype, + + // XXX this should be replaced with calls to the Browser Console + append: function App_append(message) { + if (message) { + let m = this._formatter.format(message); + if (message.level > Log.Level.Warn) { + Cu.reportError(m); + return; + } + this.doAppend(m); + } + }, + + doAppend: function CApp_doAppend(formatted) { + Cc["@mozilla.org/consoleservice;1"]. + getService(Ci.nsIConsoleService).logStringMessage(formatted); + } +}; + +/** + * Append to an nsIStorageStream + * + * This writes logging output to an in-memory stream which can later be read + * back as an nsIInputStream. It can be used to avoid expensive I/O operations + * during logging. Instead, one can periodically consume the input stream and + * e.g. write it to disk asynchronously. + */ +function StorageStreamAppender(formatter) { + Appender.call(this, formatter); + this._name = "StorageStreamAppender"; +} + +StorageStreamAppender.prototype = { + __proto__: Appender.prototype, + + _converterStream: null, // holds the nsIConverterOutputStream + _outputStream: null, // holds the underlying nsIOutputStream + + _ss: null, + + get outputStream() { + if (!this._outputStream) { + // First create a raw stream. We can bail out early if that fails. + this._outputStream = this.newOutputStream(); + if (!this._outputStream) { + return null; + } + + // Wrap the raw stream in an nsIConverterOutputStream. We can reuse + // the instance if we already have one. + if (!this._converterStream) { + this._converterStream = Cc["@mozilla.org/intl/converter-output-stream;1"] + .createInstance(Ci.nsIConverterOutputStream); + } + this._converterStream.init( + this._outputStream, "UTF-8", STREAM_SEGMENT_SIZE, + Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER); + } + return this._converterStream; + }, + + newOutputStream: function newOutputStream() { + let ss = this._ss = Cc["@mozilla.org/storagestream;1"] + .createInstance(Ci.nsIStorageStream); + ss.init(STREAM_SEGMENT_SIZE, PR_UINT32_MAX, null); + return ss.getOutputStream(0); + }, + + getInputStream: function getInputStream() { + if (!this._ss) { + return null; + } + return this._ss.newInputStream(0); + }, + + reset: function reset() { + if (!this._outputStream) { + return; + } + this.outputStream.close(); + this._outputStream = null; + this._ss = null; + }, + + doAppend: function (formatted) { + if (!formatted) { + return; + } + try { + this.outputStream.writeString(formatted + "\n"); + } catch (ex) { + if (ex.result == Cr.NS_BASE_STREAM_CLOSED) { + // The underlying output stream is closed, so let's open a new one + // and try again. + this._outputStream = null; + } try { + this.outputStream.writeString(formatted + "\n"); + } catch (ex) { + // Ah well, we tried, but something seems to be hosed permanently. + } + } + } +}; + +/** + * File appender + * + * Writes output to file using OS.File. + */ +function FileAppender(path, formatter) { + Appender.call(this, formatter); + this._name = "FileAppender"; + this._encoder = new TextEncoder(); + this._path = path; + this._file = null; + this._fileReadyPromise = null; + + // This is a promise exposed for testing/debugging the logger itself. + this._lastWritePromise = null; +} + +FileAppender.prototype = { + __proto__: Appender.prototype, + + _openFile: function () { + return Task.spawn(function* _openFile() { + try { + this._file = yield OS.File.open(this._path, + {truncate: true}); + } catch (err) { + if (err instanceof OS.File.Error) { + this._file = null; + } else { + throw err; + } + } + }.bind(this)); + }, + + _getFile: function() { + if (!this._fileReadyPromise) { + this._fileReadyPromise = this._openFile(); + } + + return this._fileReadyPromise; + }, + + doAppend: function (formatted) { + let array = this._encoder.encode(formatted + "\n"); + if (this._file) { + this._lastWritePromise = this._file.write(array); + } else { + this._lastWritePromise = this._getFile().then(_ => { + this._fileReadyPromise = null; + if (this._file) { + return this._file.write(array); + } + return undefined; + }); + } + }, + + reset: function () { + let fileClosePromise = this._file.close(); + return fileClosePromise.then(_ => { + this._file = null; + return OS.File.remove(this._path); + }); + } +}; + +/** + * Bounded File appender + * + * Writes output to file using OS.File. After the total message size + * (as defined by formatted.length) exceeds maxSize, existing messages + * will be discarded, and subsequent writes will be appended to a new log file. + */ +function BoundedFileAppender(path, formatter, maxSize=2*ONE_MEGABYTE) { + FileAppender.call(this, path, formatter); + this._name = "BoundedFileAppender"; + this._size = 0; + this._maxSize = maxSize; + this._closeFilePromise = null; +} + +BoundedFileAppender.prototype = { + __proto__: FileAppender.prototype, + + doAppend: function (formatted) { + if (!this._removeFilePromise) { + if (this._size < this._maxSize) { + this._size += formatted.length; + return FileAppender.prototype.doAppend.call(this, formatted); + } + this._removeFilePromise = this.reset(); + } + this._removeFilePromise.then(_ => { + this._removeFilePromise = null; + this.doAppend(formatted); + }); + return undefined; + }, + + reset: function () { + let fileClosePromise; + if (this._fileReadyPromise) { + // An attempt to open the file may still be in progress. + fileClosePromise = this._fileReadyPromise.then(_ => { + return this._file.close(); + }); + } else { + fileClosePromise = this._file.close(); + } + + return fileClosePromise.then(_ => { + this._size = 0; + this._file = null; + return OS.File.remove(this._path); + }); + } +}; + diff --git a/toolkit/modules/Memory.jsm b/toolkit/modules/Memory.jsm new file mode 100644 index 0000000000..bb8e331c64 --- /dev/null +++ b/toolkit/modules/Memory.jsm @@ -0,0 +1,77 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["Memory"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; +// How long we should wait for the Promise to resolve. +const TIMEOUT_INTERVAL = 2000; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); + +this.Memory = { + /** + * This function returns a Promise that resolves with an Object that + * describes basic memory usage for each content process and the parent + * process. + * @returns Promise + * @resolves JS Object + * An Object in the following format: + * { + * "parent": { + * uss: <int>, + * rss: <int>, + * }, + * <pid>: { + * uss: <int>, + * rss: <int>, + * }, + * ... + * } + */ + summary() { + if (!this._pendingPromise) { + this._pendingPromise = new Promise((resolve) => { + this._pendingResolve = resolve; + this._summaries = {}; + Services.ppmm.broadcastAsyncMessage("Memory:GetSummary"); + Services.ppmm.addMessageListener("Memory:Summary", this); + this._pendingTimeout = setTimeout(() => { this.finish(); }, TIMEOUT_INTERVAL); + }); + } + return this._pendingPromise; + }, + + receiveMessage(msg) { + if (msg.name != "Memory:Summary" || !this._pendingResolve) { + return; + } + this._summaries[msg.data.pid] = msg.data.summary; + // Now we check if we are done for all content processes. + // Services.ppmm.childCount is a count of how many processes currently + // exist that might respond to messages sent through the ppmm, including + // the parent process. So we subtract the parent process with the "- 1", + // and that’s how many content processes we’re waiting for. + if (Object.keys(this._summaries).length >= Services.ppmm.childCount - 1) { + this.finish(); + } + }, + + finish() { + // Code to gather the USS and RSS values for the parent process. This + // functions the same way as in process-content.js. + let memMgr = Cc["@mozilla.org/memory-reporter-manager;1"] + .getService(Ci.nsIMemoryReporterManager); + let rss = memMgr.resident; + let uss = memMgr.residentUnique; + this._summaries["Parent"] = { uss, rss }; + this._pendingResolve(this._summaries); + this._pendingResolve = null; + this._summaries = null; + this._pendingPromise = null; + clearTimeout(this._pendingTimeout); + Services.ppmm.removeMessageListener("Memory:Summary", this); + } +}; diff --git a/toolkit/modules/NLP.jsm b/toolkit/modules/NLP.jsm new file mode 100644 index 0000000000..f596ad9da9 --- /dev/null +++ b/toolkit/modules/NLP.jsm @@ -0,0 +1,76 @@ +/* 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 = ["NLP"]; + +/** + * NLP, which stands for Natural Language Processing, is a module that provides + * an entry point to various methods to interface with human language. + * + * At least, that's the goal. Eventually. Right now, the find toolbar only really + * needs the Levenshtein distance algorithm. + */ +this.NLP = { + /** + * Calculate the Levenshtein distance between two words. + * The implementation of this method was heavily inspired by + * http://locutus.io/php/strings/levenshtein/index.html + * License: MIT. + * + * @param {String} word1 Word to compare against + * @param {String} word2 Word that may be different + * @param {Number} costIns The cost to insert a character + * @param {Number} costRep The cost to replace a character + * @param {Number} costDel The cost to delete a character + * @return {Number} + */ + levenshtein(word1 = "", word2 = "", costIns = 1, costRep = 1, costDel = 1) { + if (word1 === word2) + return 0; + + let l1 = word1.length; + let l2 = word2.length; + if (!l1) + return l2 * costIns; + if (!l2) + return l1 * costDel; + + let p1 = new Array(l2 + 1) + let p2 = new Array(l2 + 1) + + let i1, i2, c0, c1, c2, tmp; + + for (i2 = 0; i2 <= l2; i2++) + p1[i2] = i2 * costIns; + + for (i1 = 0; i1 < l1; i1++) { + p2[0] = p1[0] + costDel; + + for (i2 = 0; i2 < l2; i2++) { + c0 = p1[i2] + ((word1[i1] === word2[i2]) ? 0 : costRep); + c1 = p1[i2 + 1] + costDel; + + if (c1 < c0) + c0 = c1; + + c2 = p2[i2] + costIns; + + if (c2 < c0) + c0 = c2; + + p2[i2 + 1] = c0; + } + + tmp = p1; + p1 = p2; + p2 = tmp; + } + + c0 = p1[l2]; + + return c0; + } +}; diff --git a/toolkit/modules/NewTabUtils.jsm b/toolkit/modules/NewTabUtils.jsm new file mode 100644 index 0000000000..df8dae89d8 --- /dev/null +++ b/toolkit/modules/NewTabUtils.jsm @@ -0,0 +1,1488 @@ +/* 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 = ["NewTabUtils"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PageThumbs", + "resource://gre/modules/PageThumbs.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "BinarySearch", + "resource://gre/modules/BinarySearch.jsm"); + +XPCOMUtils.defineLazyGetter(this, "gCryptoHash", function () { + return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); +}); + +XPCOMUtils.defineLazyGetter(this, "gUnicodeConverter", function () { + let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = 'utf8'; + return converter; +}); + +// Boolean preferences that control newtab content +const PREF_NEWTAB_ENABLED = "browser.newtabpage.enabled"; +const PREF_NEWTAB_ENHANCED = "browser.newtabpage.enhanced"; + +// The preference that tells the number of rows of the newtab grid. +const PREF_NEWTAB_ROWS = "browser.newtabpage.rows"; + +// The preference that tells the number of columns of the newtab grid. +const PREF_NEWTAB_COLUMNS = "browser.newtabpage.columns"; + +// The maximum number of results PlacesProvider retrieves from history. +const HISTORY_RESULTS_LIMIT = 100; + +// The maximum number of links Links.getLinks will return. +const LINKS_GET_LINKS_LIMIT = 100; + +// The gather telemetry topic. +const TOPIC_GATHER_TELEMETRY = "gather-telemetry"; + +/** + * Calculate the MD5 hash for a string. + * @param aValue + * The string to convert. + * @return The base64 representation of the MD5 hash. + */ +function toHash(aValue) { + let value = gUnicodeConverter.convertToByteArray(aValue); + gCryptoHash.init(gCryptoHash.MD5); + gCryptoHash.update(value, value.length); + return gCryptoHash.finish(true); +} + +/** + * Singleton that provides storage functionality. + */ +XPCOMUtils.defineLazyGetter(this, "Storage", function() { + return new LinksStorage(); +}); + +function LinksStorage() { + // Handle migration of data across versions. + try { + if (this._storedVersion < this._version) { + // This is either an upgrade, or version information is missing. + if (this._storedVersion < 1) { + // Version 1 moved data from DOM Storage to prefs. Since migrating from + // version 0 is no more supported, we just reportError a dataloss later. + throw new Error("Unsupported newTab storage version"); + } + // Add further migration steps here. + } + else { + // This is a downgrade. Since we cannot predict future, upgrades should + // be backwards compatible. We will set the version to the old value + // regardless, so, on next upgrade, the migration steps will run again. + // For this reason, they should also be able to run multiple times, even + // on top of an already up-to-date storage. + } + } catch (ex) { + // Something went wrong in the update process, we can't recover from here, + // so just clear the storage and start from scratch (dataloss!). + Components.utils.reportError( + "Unable to migrate the newTab storage to the current version. "+ + "Restarting from scratch.\n" + ex); + this.clear(); + } + + // Set the version to the current one. + this._storedVersion = this._version; +} + +LinksStorage.prototype = { + get _version() { + return 1; + }, + + get _prefs() { + return Object.freeze({ + pinnedLinks: "browser.newtabpage.pinned", + blockedLinks: "browser.newtabpage.blocked", + }); + }, + + get _storedVersion() { + if (this.__storedVersion === undefined) { + try { + this.__storedVersion = + Services.prefs.getIntPref("browser.newtabpage.storageVersion"); + } catch (ex) { + // The storage version is unknown, so either: + // - it's a new profile + // - it's a profile where versioning information got lost + // In this case we still run through all of the valid migrations, + // starting from 1, as if it was a downgrade. As previously stated the + // migrations should already support running on an updated store. + this.__storedVersion = 1; + } + } + return this.__storedVersion; + }, + set _storedVersion(aValue) { + Services.prefs.setIntPref("browser.newtabpage.storageVersion", aValue); + this.__storedVersion = aValue; + return aValue; + }, + + /** + * Gets the value for a given key from the storage. + * @param aKey The storage key (a string). + * @param aDefault A default value if the key doesn't exist. + * @return The value for the given key. + */ + get: function Storage_get(aKey, aDefault) { + let value; + try { + let prefValue = Services.prefs.getComplexValue(this._prefs[aKey], + Ci.nsISupportsString).data; + value = JSON.parse(prefValue); + } catch (e) {} + return value || aDefault; + }, + + /** + * Sets the storage value for a given key. + * @param aKey The storage key (a string). + * @param aValue The value to set. + */ + set: function Storage_set(aKey, aValue) { + // Page titles may contain unicode, thus use complex values. + let string = Cc["@mozilla.org/supports-string;1"] + .createInstance(Ci.nsISupportsString); + string.data = JSON.stringify(aValue); + Services.prefs.setComplexValue(this._prefs[aKey], Ci.nsISupportsString, + string); + }, + + /** + * Removes the storage value for a given key. + * @param aKey The storage key (a string). + */ + remove: function Storage_remove(aKey) { + Services.prefs.clearUserPref(this._prefs[aKey]); + }, + + /** + * Clears the storage and removes all values. + */ + clear: function Storage_clear() { + for (let key in this._prefs) { + this.remove(key); + } + } +}; + + +/** + * Singleton that serves as a registry for all open 'New Tab Page's. + */ +var AllPages = { + /** + * The array containing all active pages. + */ + _pages: [], + + /** + * Cached value that tells whether the New Tab Page feature is enabled. + */ + _enabled: null, + + /** + * Cached value that tells whether the New Tab Page feature is enhanced. + */ + _enhanced: null, + + /** + * Adds a page to the internal list of pages. + * @param aPage The page to register. + */ + register: function AllPages_register(aPage) { + this._pages.push(aPage); + this._addObserver(); + }, + + /** + * Removes a page from the internal list of pages. + * @param aPage The page to unregister. + */ + unregister: function AllPages_unregister(aPage) { + let index = this._pages.indexOf(aPage); + if (index > -1) + this._pages.splice(index, 1); + }, + + /** + * Returns whether the 'New Tab Page' is enabled. + */ + get enabled() { + if (this._enabled === null) + this._enabled = Services.prefs.getBoolPref(PREF_NEWTAB_ENABLED); + + return this._enabled; + }, + + /** + * Enables or disables the 'New Tab Page' feature. + */ + set enabled(aEnabled) { + if (this.enabled != aEnabled) + Services.prefs.setBoolPref(PREF_NEWTAB_ENABLED, !!aEnabled); + }, + + /** + * Returns whether the history tiles are enhanced. + */ + get enhanced() { + if (this._enhanced === null) + this._enhanced = Services.prefs.getBoolPref(PREF_NEWTAB_ENHANCED); + + return this._enhanced; + }, + + /** + * Enables or disables the enhancement of history tiles feature. + */ + set enhanced(aEnhanced) { + if (this.enhanced != aEnhanced) + Services.prefs.setBoolPref(PREF_NEWTAB_ENHANCED, !!aEnhanced); + }, + + /** + * Returns the number of registered New Tab Pages (i.e. the number of open + * about:newtab instances). + */ + get length() { + return this._pages.length; + }, + + /** + * Updates all currently active pages but the given one. + * @param aExceptPage The page to exclude from updating. + * @param aReason The reason for updating all pages. + */ + update(aExceptPage, aReason = "") { + for (let page of this._pages.slice()) { + if (aExceptPage != page) { + page.update(aReason); + } + } + }, + + /** + * Implements the nsIObserver interface to get notified when the preference + * value changes or when a new copy of a page thumbnail is available. + */ + observe: function AllPages_observe(aSubject, aTopic, aData) { + if (aTopic == "nsPref:changed") { + // Clear the cached value. + switch (aData) { + case PREF_NEWTAB_ENABLED: + this._enabled = null; + break; + case PREF_NEWTAB_ENHANCED: + this._enhanced = null; + break; + } + } + // and all notifications get forwarded to each page. + this._pages.forEach(function (aPage) { + aPage.observe(aSubject, aTopic, aData); + }, this); + }, + + /** + * Adds a preference and new thumbnail observer and turns itself into a + * no-op after the first invokation. + */ + _addObserver: function AllPages_addObserver() { + Services.prefs.addObserver(PREF_NEWTAB_ENABLED, this, true); + Services.prefs.addObserver(PREF_NEWTAB_ENHANCED, this, true); + Services.obs.addObserver(this, "page-thumbnail:create", true); + this._addObserver = function () {}; + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, + Ci.nsISupportsWeakReference]) +}; + +/** + * Singleton that keeps Grid preferences + */ +var GridPrefs = { + /** + * Cached value that tells the number of rows of newtab grid. + */ + _gridRows: null, + get gridRows() { + if (!this._gridRows) { + this._gridRows = Math.max(1, Services.prefs.getIntPref(PREF_NEWTAB_ROWS)); + } + + return this._gridRows; + }, + + /** + * Cached value that tells the number of columns of newtab grid. + */ + _gridColumns: null, + get gridColumns() { + if (!this._gridColumns) { + this._gridColumns = Math.max(1, Services.prefs.getIntPref(PREF_NEWTAB_COLUMNS)); + } + + return this._gridColumns; + }, + + + /** + * Initializes object. Adds a preference observer + */ + init: function GridPrefs_init() { + Services.prefs.addObserver(PREF_NEWTAB_ROWS, this, false); + Services.prefs.addObserver(PREF_NEWTAB_COLUMNS, this, false); + }, + + /** + * Implements the nsIObserver interface to get notified when the preference + * value changes. + */ + observe: function GridPrefs_observe(aSubject, aTopic, aData) { + if (aData == PREF_NEWTAB_ROWS) { + this._gridRows = null; + } else { + this._gridColumns = null; + } + + AllPages.update(); + } +}; + +GridPrefs.init(); + +/** + * Singleton that keeps track of all pinned links and their positions in the + * grid. + */ +var PinnedLinks = { + /** + * The cached list of pinned links. + */ + _links: null, + + /** + * The array of pinned links. + */ + get links() { + if (!this._links) + this._links = Storage.get("pinnedLinks", []); + + return this._links; + }, + + /** + * Pins a link at the given position. + * @param aLink The link to pin. + * @param aIndex The grid index to pin the cell at. + * @return true if link changes, false otherwise + */ + pin: function PinnedLinks_pin(aLink, aIndex) { + // Clear the link's old position, if any. + this.unpin(aLink); + + // change pinned link into a history link + let changed = this._makeHistoryLink(aLink); + this.links[aIndex] = aLink; + this.save(); + return changed; + }, + + /** + * Unpins a given link. + * @param aLink The link to unpin. + */ + unpin: function PinnedLinks_unpin(aLink) { + let index = this._indexOfLink(aLink); + if (index == -1) + return; + let links = this.links; + links[index] = null; + // trim trailing nulls + let i=links.length-1; + while (i >= 0 && links[i] == null) + i--; + links.splice(i +1); + this.save(); + }, + + /** + * Saves the current list of pinned links. + */ + save: function PinnedLinks_save() { + Storage.set("pinnedLinks", this.links); + }, + + /** + * Checks whether a given link is pinned. + * @params aLink The link to check. + * @return whether The link is pinned. + */ + isPinned: function PinnedLinks_isPinned(aLink) { + return this._indexOfLink(aLink) != -1; + }, + + /** + * Resets the links cache. + */ + resetCache: function PinnedLinks_resetCache() { + this._links = null; + }, + + /** + * Finds the index of a given link in the list of pinned links. + * @param aLink The link to find an index for. + * @return The link's index. + */ + _indexOfLink: function PinnedLinks_indexOfLink(aLink) { + for (let i = 0; i < this.links.length; i++) { + let link = this.links[i]; + if (link && link.url == aLink.url) + return i; + } + + // The given link is unpinned. + return -1; + }, + + /** + * Transforms link into a "history" link + * @param aLink The link to change + * @return true if link changes, false otherwise + */ + _makeHistoryLink: function PinnedLinks_makeHistoryLink(aLink) { + if (!aLink.type || aLink.type == "history") { + return false; + } + aLink.type = "history"; + // always remove targetedSite + delete aLink.targetedSite; + return true; + }, + + /** + * Replaces existing link with another link. + * @param aUrl The url of existing link + * @param aLink The replacement link + */ + replace: function PinnedLinks_replace(aUrl, aLink) { + let index = this._indexOfLink({url: aUrl}); + if (index == -1) { + return; + } + this.links[index] = aLink; + this.save(); + }, + +}; + +/** + * Singleton that keeps track of all blocked links in the grid. + */ +var BlockedLinks = { + /** + * A list of objects that are observing blocked link changes. + */ + _observers: [], + + /** + * The cached list of blocked links. + */ + _links: null, + + /** + * Registers an object that will be notified when the blocked links change. + */ + addObserver: function (aObserver) { + this._observers.push(aObserver); + }, + + /** + * The list of blocked links. + */ + get links() { + if (!this._links) + this._links = Storage.get("blockedLinks", {}); + + return this._links; + }, + + /** + * Blocks a given link. Adjusts siteMap accordingly, and notifies listeners. + * @param aLink The link to block. + */ + block: function BlockedLinks_block(aLink) { + this._callObservers("onLinkBlocked", aLink); + this.links[toHash(aLink.url)] = 1; + this.save(); + + // Make sure we unpin blocked links. + PinnedLinks.unpin(aLink); + }, + + /** + * Unblocks a given link. Adjusts siteMap accordingly, and notifies listeners. + * @param aLink The link to unblock. + */ + unblock: function BlockedLinks_unblock(aLink) { + if (this.isBlocked(aLink)) { + delete this.links[toHash(aLink.url)]; + this.save(); + this._callObservers("onLinkUnblocked", aLink); + } + }, + + /** + * Saves the current list of blocked links. + */ + save: function BlockedLinks_save() { + Storage.set("blockedLinks", this.links); + }, + + /** + * Returns whether a given link is blocked. + * @param aLink The link to check. + */ + isBlocked: function BlockedLinks_isBlocked(aLink) { + return (toHash(aLink.url) in this.links); + }, + + /** + * Checks whether the list of blocked links is empty. + * @return Whether the list is empty. + */ + isEmpty: function BlockedLinks_isEmpty() { + return Object.keys(this.links).length == 0; + }, + + /** + * Resets the links cache. + */ + resetCache: function BlockedLinks_resetCache() { + this._links = null; + }, + + _callObservers(methodName, ...args) { + for (let obs of this._observers) { + if (typeof(obs[methodName]) == "function") { + try { + obs[methodName](...args); + } catch (err) { + Cu.reportError(err); + } + } + } + } +}; + +/** + * Singleton that serves as the default link provider for the grid. It queries + * the history to retrieve the most frequently visited sites. + */ +var PlacesProvider = { + /** + * A count of how many batch updates are under way (batches may be nested, so + * we keep a counter instead of a simple bool). + **/ + _batchProcessingDepth: 0, + + /** + * A flag that tracks whether onFrecencyChanged was notified while a batch + * operation was in progress, to tell us whether to take special action after + * the batch operation completes. + **/ + _batchCalledFrecencyChanged: false, + + /** + * Set this to change the maximum number of links the provider will provide. + */ + maxNumLinks: HISTORY_RESULTS_LIMIT, + + /** + * Must be called before the provider is used. + */ + init: function PlacesProvider_init() { + PlacesUtils.history.addObserver(this, true); + }, + + /** + * Gets the current set of links delivered by this provider. + * @param aCallback The function that the array of links is passed to. + */ + getLinks: function PlacesProvider_getLinks(aCallback) { + let options = PlacesUtils.history.getNewQueryOptions(); + options.maxResults = this.maxNumLinks; + + // Sort by frecency, descending. + options.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_DESCENDING + + let links = []; + + let callback = { + handleResult: function (aResultSet) { + let row; + + while ((row = aResultSet.getNextRow())) { + let url = row.getResultByIndex(1); + if (LinkChecker.checkLoadURI(url)) { + let title = row.getResultByIndex(2); + let frecency = row.getResultByIndex(12); + let lastVisitDate = row.getResultByIndex(5); + links.push({ + url: url, + title: title, + frecency: frecency, + lastVisitDate: lastVisitDate, + type: "history", + }); + } + } + }, + + handleError: function (aError) { + // Should we somehow handle this error? + aCallback([]); + }, + + handleCompletion: function (aReason) { + // The Places query breaks ties in frecency by place ID descending, but + // that's different from how Links.compareLinks breaks ties, because + // compareLinks doesn't have access to place IDs. It's very important + // that the initial list of links is sorted in the same order imposed by + // compareLinks, because Links uses compareLinks to perform binary + // searches on the list. So, ensure the list is so ordered. + let i = 1; + let outOfOrder = []; + while (i < links.length) { + if (Links.compareLinks(links[i - 1], links[i]) > 0) + outOfOrder.push(links.splice(i, 1)[0]); + else + i++; + } + for (let link of outOfOrder) { + i = BinarySearch.insertionIndexOf(Links.compareLinks, links, link); + links.splice(i, 0, link); + } + + aCallback(links); + } + }; + + // Execute the query. + let query = PlacesUtils.history.getNewQuery(); + let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase); + db.asyncExecuteLegacyQueries([query], 1, options, callback); + }, + + /** + * Registers an object that will be notified when the provider's links change. + * @param aObserver An object with the following optional properties: + * * onLinkChanged: A function that's called when a single link + * changes. It's passed the provider and the link object. Only the + * link's `url` property is guaranteed to be present. If its `title` + * property is present, then its title has changed, and the + * property's value is the new title. If any sort properties are + * present, then its position within the provider's list of links may + * have changed, and the properties' values are the new sort-related + * values. Note that this link may not necessarily have been present + * in the lists returned from any previous calls to getLinks. + * * onManyLinksChanged: A function that's called when many links + * change at once. It's passed the provider. You should call + * getLinks to get the provider's new list of links. + */ + addObserver: function PlacesProvider_addObserver(aObserver) { + this._observers.push(aObserver); + }, + + _observers: [], + + /** + * Called by the history service. + */ + onBeginUpdateBatch: function() { + this._batchProcessingDepth += 1; + }, + + onEndUpdateBatch: function() { + this._batchProcessingDepth -= 1; + if (this._batchProcessingDepth == 0 && this._batchCalledFrecencyChanged) { + this.onManyFrecenciesChanged(); + this._batchCalledFrecencyChanged = false; + } + }, + + onDeleteURI: function PlacesProvider_onDeleteURI(aURI, aGUID, aReason) { + // let observers remove sensetive data associated with deleted visit + this._callObservers("onDeleteURI", { + url: aURI.spec, + }); + }, + + onClearHistory: function() { + this._callObservers("onClearHistory") + }, + + /** + * Called by the history service. + */ + onFrecencyChanged: function PlacesProvider_onFrecencyChanged(aURI, aNewFrecency, aGUID, aHidden, aLastVisitDate) { + // If something is doing a batch update of history entries we don't want + // to do lots of work for each record. So we just track the fact we need + // to call onManyFrecenciesChanged() once the batch is complete. + if (this._batchProcessingDepth > 0) { + this._batchCalledFrecencyChanged = true; + return; + } + // The implementation of the query in getLinks excludes hidden and + // unvisited pages, so it's important to exclude them here, too. + if (!aHidden && aLastVisitDate) { + this._callObservers("onLinkChanged", { + url: aURI.spec, + frecency: aNewFrecency, + lastVisitDate: aLastVisitDate, + type: "history", + }); + } + }, + + /** + * Called by the history service. + */ + onManyFrecenciesChanged: function PlacesProvider_onManyFrecenciesChanged() { + this._callObservers("onManyLinksChanged"); + }, + + /** + * Called by the history service. + */ + onTitleChanged: function PlacesProvider_onTitleChanged(aURI, aNewTitle, aGUID) { + this._callObservers("onLinkChanged", { + url: aURI.spec, + title: aNewTitle + }); + }, + + _callObservers: function PlacesProvider__callObservers(aMethodName, aArg) { + for (let obs of this._observers) { + if (obs[aMethodName]) { + try { + obs[aMethodName](this, aArg); + } catch (err) { + Cu.reportError(err); + } + } + } + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver, + Ci.nsISupportsWeakReference]), +}; + +/** + * Singleton that provides access to all links contained in the grid (including + * the ones that don't fit on the grid). A link is a plain object that looks + * like this: + * + * { + * url: "http://www.mozilla.org/", + * title: "Mozilla", + * frecency: 1337, + * lastVisitDate: 1394678824766431, + * } + */ +var Links = { + /** + * The maximum number of links returned by getLinks. + */ + maxNumLinks: LINKS_GET_LINKS_LIMIT, + + /** + * A mapping from each provider to an object { sortedLinks, siteMap, linkMap }. + * sortedLinks is the cached, sorted array of links for the provider. + * siteMap is a mapping from base domains to URL count associated with the domain. + * The count does not include blocked URLs. siteMap is used to look up a + * user's top sites that can be targeted with a suggested tile. + * linkMap is a Map from link URLs to link objects. + */ + _providers: new Map(), + + /** + * The properties of link objects used to sort them. + */ + _sortProperties: [ + "frecency", + "lastVisitDate", + "url", + ], + + /** + * List of callbacks waiting for the cache to be populated. + */ + _populateCallbacks: [], + + /** + * A list of objects that are observing links updates. + */ + _observers: [], + + /** + * Registers an object that will be notified when links updates. + */ + addObserver: function (aObserver) { + this._observers.push(aObserver); + }, + + /** + * Adds a link provider. + * @param aProvider The link provider. + */ + addProvider: function Links_addProvider(aProvider) { + this._providers.set(aProvider, null); + aProvider.addObserver(this); + }, + + /** + * Removes a link provider. + * @param aProvider The link provider. + */ + removeProvider: function Links_removeProvider(aProvider) { + if (!this._providers.delete(aProvider)) + throw new Error("Unknown provider"); + }, + + /** + * Populates the cache with fresh links from the providers. + * @param aCallback The callback to call when finished (optional). + * @param aForce When true, populates the cache even when it's already filled. + */ + populateCache: function Links_populateCache(aCallback, aForce) { + let callbacks = this._populateCallbacks; + + // Enqueue the current callback. + callbacks.push(aCallback); + + // There was a callback waiting already, thus the cache has not yet been + // populated. + if (callbacks.length > 1) + return; + + function executeCallbacks() { + while (callbacks.length) { + let callback = callbacks.shift(); + if (callback) { + try { + callback(); + } catch (e) { + // We want to proceed even if a callback fails. + } + } + } + } + + let numProvidersRemaining = this._providers.size; + for (let [provider, links] of this._providers) { + this._populateProviderCache(provider, () => { + if (--numProvidersRemaining == 0) + executeCallbacks(); + }, aForce); + } + + this._addObserver(); + }, + + /** + * Gets the current set of links contained in the grid. + * @return The links in the grid. + */ + getLinks: function Links_getLinks() { + let pinnedLinks = Array.slice(PinnedLinks.links); + let links = this._getMergedProviderLinks(); + + let sites = new Set(); + for (let link of pinnedLinks) { + if (link) + sites.add(NewTabUtils.extractSite(link.url)); + } + + // Filter blocked and pinned links and duplicate base domains. + links = links.filter(function (link) { + let site = NewTabUtils.extractSite(link.url); + if (site == null || sites.has(site)) + return false; + sites.add(site); + + return !BlockedLinks.isBlocked(link) && !PinnedLinks.isPinned(link); + }); + + // Try to fill the gaps between pinned links. + for (let i = 0; i < pinnedLinks.length && links.length; i++) + if (!pinnedLinks[i]) + pinnedLinks[i] = links.shift(); + + // Append the remaining links if any. + if (links.length) + pinnedLinks = pinnedLinks.concat(links); + + for (let link of pinnedLinks) { + if (link) { + link.baseDomain = NewTabUtils.extractSite(link.url); + } + } + return pinnedLinks; + }, + + /** + * Resets the links cache. + */ + resetCache: function Links_resetCache() { + for (let provider of this._providers.keys()) { + this._providers.set(provider, null); + } + }, + + /** + * Compares two links. + * @param aLink1 The first link. + * @param aLink2 The second link. + * @return A negative number if aLink1 is ordered before aLink2, zero if + * aLink1 and aLink2 have the same ordering, or a positive number if + * aLink1 is ordered after aLink2. + * + * @note compareLinks's this object is bound to Links below. + */ + compareLinks: function Links_compareLinks(aLink1, aLink2) { + for (let prop of this._sortProperties) { + if (!(prop in aLink1) || !(prop in aLink2)) + throw new Error("Comparable link missing required property: " + prop); + } + return aLink2.frecency - aLink1.frecency || + aLink2.lastVisitDate - aLink1.lastVisitDate || + aLink1.url.localeCompare(aLink2.url); + }, + + _incrementSiteMap: function(map, link) { + if (NewTabUtils.blockedLinks.isBlocked(link)) { + // Don't count blocked URLs. + return; + } + let site = NewTabUtils.extractSite(link.url); + map.set(site, (map.get(site) || 0) + 1); + }, + + _decrementSiteMap: function(map, link) { + if (NewTabUtils.blockedLinks.isBlocked(link)) { + // Blocked URLs are not included in map. + return; + } + let site = NewTabUtils.extractSite(link.url); + let previousURLCount = map.get(site); + if (previousURLCount === 1) { + map.delete(site); + } else { + map.set(site, previousURLCount - 1); + } + }, + + /** + * Update the siteMap cache based on the link given and whether we need + * to increment or decrement it. We do this by iterating over all stored providers + * to find which provider this link already exists in. For providers that + * have this link, we will adjust siteMap for them accordingly. + * + * @param aLink The link that will affect siteMap + * @param increment A boolean for whether to increment or decrement siteMap + */ + _adjustSiteMapAndNotify: function(aLink, increment=true) { + for (let [provider, cache] of this._providers) { + // We only update siteMap if aLink is already stored in linkMap. + if (cache.linkMap.get(aLink.url)) { + if (increment) { + this._incrementSiteMap(cache.siteMap, aLink); + continue; + } + this._decrementSiteMap(cache.siteMap, aLink); + } + } + this._callObservers("onLinkChanged", aLink); + }, + + onLinkBlocked: function(aLink) { + this._adjustSiteMapAndNotify(aLink, false); + }, + + onLinkUnblocked: function(aLink) { + this._adjustSiteMapAndNotify(aLink); + }, + + populateProviderCache: function(provider, callback) { + if (!this._providers.has(provider)) { + throw new Error("Can only populate provider cache for existing provider."); + } + + return this._populateProviderCache(provider, callback, false); + }, + + /** + * Calls getLinks on the given provider and populates our cache for it. + * @param aProvider The provider whose cache will be populated. + * @param aCallback The callback to call when finished. + * @param aForce When true, populates the provider's cache even when it's + * already filled. + */ + _populateProviderCache: function (aProvider, aCallback, aForce) { + let cache = this._providers.get(aProvider); + let createCache = !cache; + if (createCache) { + cache = { + // Start with a resolved promise. + populatePromise: new Promise(resolve => resolve()), + }; + this._providers.set(aProvider, cache); + } + // Chain the populatePromise so that calls are effectively queued. + cache.populatePromise = cache.populatePromise.then(() => { + return new Promise(resolve => { + if (!createCache && !aForce) { + aCallback(); + resolve(); + return; + } + aProvider.getLinks(links => { + // Filter out null and undefined links so we don't have to deal with + // them in getLinks when merging links from providers. + links = links.filter((link) => !!link); + cache.sortedLinks = links; + cache.siteMap = links.reduce((map, link) => { + this._incrementSiteMap(map, link); + return map; + }, new Map()); + cache.linkMap = links.reduce((map, link) => { + map.set(link.url, link); + return map; + }, new Map()); + aCallback(); + resolve(); + }); + }); + }); + }, + + /** + * Merges the cached lists of links from all providers whose lists are cached. + * @return The merged list. + */ + _getMergedProviderLinks: function Links__getMergedProviderLinks() { + // Build a list containing a copy of each provider's sortedLinks list. + let linkLists = []; + for (let provider of this._providers.keys()) { + if (!AllPages.enhanced && provider != PlacesProvider) { + // Only show history tiles if we're not in 'enhanced' mode. + continue; + } + let links = this._providers.get(provider); + if (links && links.sortedLinks) { + linkLists.push(links.sortedLinks.slice()); + } + } + + function getNextLink() { + let minLinks = null; + for (let links of linkLists) { + if (links.length && + (!minLinks || Links.compareLinks(links[0], minLinks[0]) < 0)) + minLinks = links; + } + return minLinks ? minLinks.shift() : null; + } + + let finalLinks = []; + for (let nextLink = getNextLink(); + nextLink && finalLinks.length < this.maxNumLinks; + nextLink = getNextLink()) { + finalLinks.push(nextLink); + } + + return finalLinks; + }, + + /** + * Called by a provider to notify us when a single link changes. + * @param aProvider The provider whose link changed. + * @param aLink The link that changed. If the link is new, it must have all + * of the _sortProperties. Otherwise, it may have as few or as + * many as is convenient. + * @param aIndex The current index of the changed link in the sortedLinks + cache in _providers. Defaults to -1 if the provider doesn't know the index + * @param aDeleted Boolean indicating if the provider has deleted the link. + */ + onLinkChanged: function Links_onLinkChanged(aProvider, aLink, aIndex=-1, aDeleted=false) { + if (!("url" in aLink)) + throw new Error("Changed links must have a url property"); + + let links = this._providers.get(aProvider); + if (!links) + // This is not an error, it just means that between the time the provider + // was added and the future time we call getLinks on it, it notified us of + // a change. + return; + + let { sortedLinks, siteMap, linkMap } = links; + let existingLink = linkMap.get(aLink.url); + let insertionLink = null; + let updatePages = false; + + if (existingLink) { + // Update our copy's position in O(lg n) by first removing it from its + // list. It's important to do this before modifying its properties. + if (this._sortProperties.some(prop => prop in aLink)) { + let idx = aIndex; + if (idx < 0) { + idx = this._indexOf(sortedLinks, existingLink); + } else if (this.compareLinks(aLink, sortedLinks[idx]) != 0) { + throw new Error("aLink should be the same as sortedLinks[idx]"); + } + + if (idx < 0) { + throw new Error("Link should be in _sortedLinks if in _linkMap"); + } + sortedLinks.splice(idx, 1); + + if (aDeleted) { + updatePages = true; + linkMap.delete(existingLink.url); + this._decrementSiteMap(siteMap, existingLink); + } else { + // Update our copy's properties. + Object.assign(existingLink, aLink); + + // Finally, reinsert our copy below. + insertionLink = existingLink; + } + } + // Update our copy's title in O(1). + if ("title" in aLink && aLink.title != existingLink.title) { + existingLink.title = aLink.title; + updatePages = true; + } + } + else if (this._sortProperties.every(prop => prop in aLink)) { + // Before doing the O(lg n) insertion below, do an O(1) check for the + // common case where the new link is too low-ranked to be in the list. + if (sortedLinks.length && sortedLinks.length == aProvider.maxNumLinks) { + let lastLink = sortedLinks[sortedLinks.length - 1]; + if (this.compareLinks(lastLink, aLink) < 0) { + return; + } + } + // Copy the link object so that changes later made to it by the caller + // don't affect our copy. + insertionLink = {}; + for (let prop in aLink) { + insertionLink[prop] = aLink[prop]; + } + linkMap.set(aLink.url, insertionLink); + this._incrementSiteMap(siteMap, aLink); + } + + if (insertionLink) { + let idx = this._insertionIndexOf(sortedLinks, insertionLink); + sortedLinks.splice(idx, 0, insertionLink); + if (sortedLinks.length > aProvider.maxNumLinks) { + let lastLink = sortedLinks.pop(); + linkMap.delete(lastLink.url); + this._decrementSiteMap(siteMap, lastLink); + } + updatePages = true; + } + + if (updatePages) { + AllPages.update(null, "links-changed"); + } + }, + + /** + * Called by a provider to notify us when many links change. + */ + onManyLinksChanged: function Links_onManyLinksChanged(aProvider) { + this._populateProviderCache(aProvider, () => { + AllPages.update(null, "links-changed"); + }, true); + }, + + _indexOf: function Links__indexOf(aArray, aLink) { + return this._binsearch(aArray, aLink, "indexOf"); + }, + + _insertionIndexOf: function Links__insertionIndexOf(aArray, aLink) { + return this._binsearch(aArray, aLink, "insertionIndexOf"); + }, + + _binsearch: function Links__binsearch(aArray, aLink, aMethod) { + return BinarySearch[aMethod](this.compareLinks, aArray, aLink); + }, + + /** + * Implements the nsIObserver interface to get notified about browser history + * sanitization. + */ + observe: function Links_observe(aSubject, aTopic, aData) { + // Make sure to update open about:newtab instances. If there are no opened + // pages we can just wait for the next new tab to populate the cache again. + if (AllPages.length && AllPages.enabled) + this.populateCache(function () { AllPages.update() }, true); + else + this.resetCache(); + }, + + _callObservers(methodName, ...args) { + for (let obs of this._observers) { + if (typeof(obs[methodName]) == "function") { + try { + obs[methodName](this, ...args); + } catch (err) { + Cu.reportError(err); + } + } + } + }, + + /** + * Adds a sanitization observer and turns itself into a no-op after the first + * invokation. + */ + _addObserver: function Links_addObserver() { + Services.obs.addObserver(this, "browser:purge-session-history", true); + this._addObserver = function () {}; + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, + Ci.nsISupportsWeakReference]) +}; + +Links.compareLinks = Links.compareLinks.bind(Links); + +/** + * Singleton used to collect telemetry data. + * + */ +var Telemetry = { + /** + * Initializes object. + */ + init: function Telemetry_init() { + Services.obs.addObserver(this, TOPIC_GATHER_TELEMETRY, false); + }, + + /** + * Collects data. + */ + _collect: function Telemetry_collect() { + let probes = [ + { histogram: "NEWTAB_PAGE_ENABLED", + value: AllPages.enabled }, + { histogram: "NEWTAB_PAGE_ENHANCED", + value: AllPages.enhanced }, + { histogram: "NEWTAB_PAGE_PINNED_SITES_COUNT", + value: PinnedLinks.links.length }, + { histogram: "NEWTAB_PAGE_BLOCKED_SITES_COUNT", + value: Object.keys(BlockedLinks.links).length } + ]; + + probes.forEach(function Telemetry_collect_forEach(aProbe) { + Services.telemetry.getHistogramById(aProbe.histogram) + .add(aProbe.value); + }); + }, + + /** + * Listens for gather telemetry topic. + */ + observe: function Telemetry_observe(aSubject, aTopic, aData) { + this._collect(); + } +}; + +/** + * Singleton that checks if a given link should be displayed on about:newtab + * or if we should rather not do it for security reasons. URIs that inherit + * their caller's principal will be filtered. + */ +var LinkChecker = { + _cache: {}, + + get flags() { + return Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL | + Ci.nsIScriptSecurityManager.DONT_REPORT_ERRORS; + }, + + checkLoadURI: function LinkChecker_checkLoadURI(aURI) { + if (!(aURI in this._cache)) + this._cache[aURI] = this._doCheckLoadURI(aURI); + + return this._cache[aURI]; + }, + + _doCheckLoadURI: function Links_doCheckLoadURI(aURI) { + try { + // about:newtab is currently privileged. In any case, it should be + // possible for tiles to point to pretty much everything - but not + // to stuff that inherits the system principal, so we check: + let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + Services.scriptSecurityManager. + checkLoadURIStrWithPrincipal(systemPrincipal, aURI, this.flags); + return true; + } catch (e) { + // We got a weird URI or one that would inherit the caller's principal. + return false; + } + } +}; + +var ExpirationFilter = { + init: function ExpirationFilter_init() { + PageThumbs.addExpirationFilter(this); + }, + + filterForThumbnailExpiration: + function ExpirationFilter_filterForThumbnailExpiration(aCallback) { + if (!AllPages.enabled) { + aCallback([]); + return; + } + + Links.populateCache(function () { + let urls = []; + + // Add all URLs to the list that we want to keep thumbnails for. + for (let link of Links.getLinks().slice(0, 25)) { + if (link && link.url) + urls.push(link.url); + } + + aCallback(urls); + }); + } +}; + +/** + * Singleton that provides the public API of this JSM. + */ +this.NewTabUtils = { + _initialized: false, + + /** + * Extract a "site" from a url in a way that multiple urls of a "site" returns + * the same "site." + * @param aUrl Url spec string + * @return The "site" string or null + */ + extractSite: function Links_extractSite(url) { + let host; + try { + // Note that nsIURI.asciiHost throws NS_ERROR_FAILURE for some types of + // URIs, including jar and moz-icon URIs. + host = Services.io.newURI(url, null, null).asciiHost; + } catch (ex) { + return null; + } + + // Strip off common subdomains of the same site (e.g., www, load balancer) + return host.replace(/^(m|mobile|www\d*)\./, ""); + }, + + init: function NewTabUtils_init() { + if (this.initWithoutProviders()) { + PlacesProvider.init(); + Links.addProvider(PlacesProvider); + BlockedLinks.addObserver(Links); + } + }, + + initWithoutProviders: function NewTabUtils_initWithoutProviders() { + if (!this._initialized) { + this._initialized = true; + ExpirationFilter.init(); + Telemetry.init(); + return true; + } + return false; + }, + + getProviderLinks: function(aProvider) { + let cache = Links._providers.get(aProvider); + if (cache && cache.sortedLinks) { + return cache.sortedLinks; + } + return []; + }, + + isTopSiteGivenProvider: function(aSite, aProvider) { + let cache = Links._providers.get(aProvider); + if (cache && cache.siteMap) { + return cache.siteMap.has(aSite); + } + return false; + }, + + isTopPlacesSite: function(aSite) { + return this.isTopSiteGivenProvider(aSite, PlacesProvider); + }, + + /** + * Restores all sites that have been removed from the grid. + */ + restore: function NewTabUtils_restore() { + Storage.clear(); + Links.resetCache(); + PinnedLinks.resetCache(); + BlockedLinks.resetCache(); + + Links.populateCache(function () { + AllPages.update(); + }, true); + }, + + /** + * Undoes all sites that have been removed from the grid and keep the pinned + * tabs. + * @param aCallback the callback method. + */ + undoAll: function NewTabUtils_undoAll(aCallback) { + Storage.remove("blockedLinks"); + Links.resetCache(); + BlockedLinks.resetCache(); + Links.populateCache(aCallback, true); + }, + + links: Links, + allPages: AllPages, + linkChecker: LinkChecker, + pinnedLinks: PinnedLinks, + blockedLinks: BlockedLinks, + gridPrefs: GridPrefs, + placesProvider: PlacesProvider +}; diff --git a/toolkit/modules/ObjectUtils.jsm b/toolkit/modules/ObjectUtils.jsm new file mode 100644 index 0000000000..8656b3113a --- /dev/null +++ b/toolkit/modules/ObjectUtils.jsm @@ -0,0 +1,176 @@ +/* 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/. */ + +// Portions of this file are originally from narwhal.js (http://narwhaljs.org) +// Copyright (c) 2009 Thomas Robinson <280north.com> +// MIT license: http://opensource.org/licenses/MIT + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "ObjectUtils" +]; + +const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +// Used only to cause test failures. +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); + +this.ObjectUtils = { + /** + * This tests objects & values for deep equality. + * + * We check using the most exact approximation of equality between two objects + * to keep the chance of false positives to a minimum. + * `JSON.stringify` is not designed to be used for this purpose; objects may + * have ambiguous `toJSON()` implementations that would influence the test. + * + * @param a (mixed) Object or value to be compared. + * @param b (mixed) Object or value to be compared. + * @return Boolean Whether the objects are deep equal. + */ + deepEqual: function(a, b) { + return _deepEqual(a, b); + }, + + /** + * A thin wrapper on an object, designed to prevent client code from + * accessing non-existent properties because of typos. + * + * // Without `strict` + * let foo = { myProperty: 1 }; + * foo.MyProperty; // undefined + * + * // With `strict` + * let strictFoo = ObjectUtils.strict(foo); + * strictFoo.myProperty; // 1 + * strictFoo.MyProperty; // TypeError: No such property "MyProperty" + * + * Note that `strict` has no effect in non-DEBUG mode. + */ + strict: function(obj) { + return _strict(obj); + } +}; + +// ... Start of previously MIT-licensed code. +// This deepEqual implementation is originally from narwhal.js (http://narwhaljs.org) +// Copyright (c) 2009 Thomas Robinson <280north.com> +// MIT license: http://opensource.org/licenses/MIT + +function _deepEqual(a, b) { + // The numbering below refers to sections in the CommonJS spec. + + // 7.1 All identical values are equivalent, as determined by ===. + if (a === b) { + return true; + // 7.2 If the b value is a Date object, the a value is + // equivalent if it is also a Date object that refers to the same time. + } + if (instanceOf(a, "Date") && instanceOf(b, "Date")) { + if (isNaN(a.getTime()) && isNaN(b.getTime())) + return true; + return a.getTime() === b.getTime(); + // 7.3 If the b value is a RegExp object, the a value is + // equivalent if it is also a RegExp object with the same source and + // properties (`global`, `multiline`, `lastIndex`, `ignoreCase`). + } + if (instanceOf(a, "RegExp") && instanceOf(b, "RegExp")) { + return a.source === b.source && + a.global === b.global && + a.multiline === b.multiline && + a.lastIndex === b.lastIndex && + a.ignoreCase === b.ignoreCase; + // 7.4 Other pairs that do not both pass typeof value == "object", + // equivalence is determined by ==. + } + if (typeof a != "object" && typeof b != "object") { + return a == b; + } + // 7.5 For all other Object pairs, including Array objects, equivalence is + // determined by having the same number of owned properties (as verified + // with Object.prototype.hasOwnProperty.call), the same set of keys + // (although not necessarily the same order), equivalent values for every + // corresponding key, and an identical 'prototype' property. Note: this + // accounts for both named and indexed properties on Arrays. + return objEquiv(a, b); +} + +function instanceOf(object, type) { + return Object.prototype.toString.call(object) == "[object " + type + "]"; +} + +function isUndefinedOrNull(value) { + return value === null || value === undefined; +} + +function isArguments(object) { + return instanceOf(object, "Arguments"); +} + +function objEquiv(a, b) { + if (isUndefinedOrNull(a) || isUndefinedOrNull(b)) { + return false; + } + // An identical 'prototype' property. + if ((a.prototype || undefined) != (b.prototype || undefined)) { + return false; + } + // Object.keys may be broken through screwy arguments passing. Converting to + // an array solves the problem. + if (isArguments(a)) { + if (!isArguments(b)) { + return false; + } + a = pSlice.call(a); + b = pSlice.call(b); + return _deepEqual(a, b); + } + let ka, kb; + try { + ka = Object.keys(a); + kb = Object.keys(b); + } catch (e) { + // Happens when one is a string literal and the other isn't + return false; + } + // Having the same number of owned properties (keys incorporates + // hasOwnProperty) + if (ka.length != kb.length) + return false; + // The same set of keys (although not necessarily the same order), + ka.sort(); + kb.sort(); + // Equivalent values for every corresponding key, and possibly expensive deep + // test + for (let key of ka) { + if (!_deepEqual(a[key], b[key])) { + return false; + } + } + return true; +} + +// ... End of previously MIT-licensed code. + +function _strict(obj) { + if (typeof obj != "object") { + throw new TypeError("Expected an object"); + } + + return new Proxy(obj, { + get: function(target, name) { + if (name in obj) { + return obj[name]; + } + + let error = new TypeError(`No such property: "${name}"`); + Promise.reject(error); // Cause an xpcshell/mochitest failure. + throw error; + } + }); +} diff --git a/toolkit/modules/PageMenu.jsm b/toolkit/modules/PageMenu.jsm new file mode 100644 index 0000000000..30dfbea429 --- /dev/null +++ b/toolkit/modules/PageMenu.jsm @@ -0,0 +1,320 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["PageMenuParent", "PageMenuChild"]; + +var {interfaces: Ci} = Components; + +this.PageMenu = function PageMenu() { +} + +PageMenu.prototype = { + PAGEMENU_ATTR: "pagemenu", + GENERATEDITEMID_ATTR: "generateditemid", + + _popup: null, + + // Only one of builder or browser will end up getting set. + _builder: null, + _browser: null, + + // Given a target node, get the context menu for it or its ancestor. + getContextMenu: function(aTarget) { + let pageMenu = null; + let target = aTarget; + while (target) { + let contextMenu = target.contextMenu; + if (contextMenu) { + return contextMenu; + } + target = target.parentNode; + } + + return null; + }, + + // Given a target node, generate a JSON object for any context menu + // associated with it, or null if there is no context menu. + maybeBuild: function(aTarget) { + let pageMenu = this.getContextMenu(aTarget); + if (!pageMenu) { + return null; + } + + pageMenu.QueryInterface(Components.interfaces.nsIHTMLMenu); + pageMenu.sendShowEvent(); + // the show event is not cancelable, so no need to check a result here + + this._builder = pageMenu.createBuilder(); + if (!this._builder) { + return null; + } + + pageMenu.build(this._builder); + + // This serializes then parses again, however this could be avoided in + // the single-process case with further improvement. + let menuString = this._builder.toJSONString(); + if (!menuString) { + return null; + } + + return JSON.parse(menuString); + }, + + // Given a JSON menu object and popup, add the context menu to the popup. + buildAndAttachMenuWithObject: function(aMenu, aBrowser, aPopup) { + if (!aMenu) { + return false; + } + + let insertionPoint = this.getInsertionPoint(aPopup); + if (!insertionPoint) { + return false; + } + + let fragment = aPopup.ownerDocument.createDocumentFragment(); + this.buildXULMenu(aMenu, fragment); + + let pos = insertionPoint.getAttribute(this.PAGEMENU_ATTR); + if (pos == "start") { + insertionPoint.insertBefore(fragment, + insertionPoint.firstChild); + } else if (pos.startsWith("#")) { + insertionPoint.insertBefore(fragment, insertionPoint.querySelector(pos)); + } else { + insertionPoint.appendChild(fragment); + } + + this._browser = aBrowser; + this._popup = aPopup; + + this._popup.addEventListener("command", this); + this._popup.addEventListener("popuphidden", this); + + return true; + }, + + // Construct the XUL menu structure for a given JSON object. + buildXULMenu: function(aNode, aElementForAppending) { + let document = aElementForAppending.ownerDocument; + + let children = aNode.children; + for (let child of children) { + let menuitem; + switch (child.type) { + case "menuitem": + if (!child.id) { + continue; // Ignore children without ids + } + + menuitem = document.createElement("menuitem"); + if (child.checkbox) { + menuitem.setAttribute("type", "checkbox"); + if (child.checked) { + menuitem.setAttribute("checked", "true"); + } + } + + if (child.label) { + menuitem.setAttribute("label", child.label); + } + if (child.icon) { + menuitem.setAttribute("image", child.icon); + menuitem.className = "menuitem-iconic"; + } + if (child.disabled) { + menuitem.setAttribute("disabled", true); + } + + break; + + case "separator": + menuitem = document.createElement("menuseparator"); + break; + + case "menu": + menuitem = document.createElement("menu"); + if (child.label) { + menuitem.setAttribute("label", child.label); + } + + let menupopup = document.createElement("menupopup"); + menuitem.appendChild(menupopup); + + this.buildXULMenu(child, menupopup); + break; + } + + menuitem.setAttribute(this.GENERATEDITEMID_ATTR, child.id ? child.id : 0); + aElementForAppending.appendChild(menuitem); + } + }, + + // Called when the generated menuitem is executed. + handleEvent: function(event) { + let type = event.type; + let target = event.target; + if (type == "command" && target.hasAttribute(this.GENERATEDITEMID_ATTR)) { + // If a builder is assigned, call click on it directly. Otherwise, this is + // likely a menu with data from another process, so send a message to the + // browser to execute the menuitem. + if (this._builder) { + this._builder.click(target.getAttribute(this.GENERATEDITEMID_ATTR)); + } + else if (this._browser) { + let win = target.ownerDocument.defaultView; + let windowUtils = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + this._browser.messageManager.sendAsyncMessage("ContextMenu:DoCustomCommand", { + generatedItemId: target.getAttribute(this.GENERATEDITEMID_ATTR), + handlingUserInput: windowUtils.isHandlingUserInput + }); + } + } else if (type == "popuphidden" && this._popup == target) { + this.removeGeneratedContent(this._popup); + + this._popup.removeEventListener("popuphidden", this); + this._popup.removeEventListener("command", this); + + this._popup = null; + this._builder = null; + this._browser = null; + } + }, + + // Get the first child of the given element with the given tag name. + getImmediateChild: function(element, tag) { + let child = element.firstChild; + while (child) { + if (child.localName == tag) { + return child; + } + child = child.nextSibling; + } + return null; + }, + + // Return the location where the generated items should be inserted into the + // given popup. They should be inserted as the next sibling of the returned + // element. + getInsertionPoint: function(aPopup) { + if (aPopup.hasAttribute(this.PAGEMENU_ATTR)) + return aPopup; + + let element = aPopup.firstChild; + while (element) { + if (element.localName == "menu") { + let popup = this.getImmediateChild(element, "menupopup"); + if (popup) { + let result = this.getInsertionPoint(popup); + if (result) { + return result; + } + } + } + element = element.nextSibling; + } + + return null; + }, + + // Remove the generated content from the given popup. + removeGeneratedContent: function(aPopup) { + let ungenerated = []; + ungenerated.push(aPopup); + + let count; + while (0 != (count = ungenerated.length)) { + let last = count - 1; + let element = ungenerated[last]; + ungenerated.splice(last, 1); + + let i = element.childNodes.length; + while (i-- > 0) { + let child = element.childNodes[i]; + if (!child.hasAttribute(this.GENERATEDITEMID_ATTR)) { + ungenerated.push(child); + continue; + } + element.removeChild(child); + } + } + } +} + +// This object is expected to be used from a parent process. +this.PageMenuParent = function PageMenuParent() { +} + +PageMenuParent.prototype = { + __proto__ : PageMenu.prototype, + + /* + * Given a target node and popup, add the context menu to the popup. This is + * intended to be called when a single process is used. This is equivalent to + * calling PageMenuChild.build and PageMenuParent.addToPopup in sequence. + * + * Returns true if custom menu items were present. + */ + buildAndAddToPopup: function(aTarget, aPopup) { + let menuObject = this.maybeBuild(aTarget); + if (!menuObject) { + return false; + } + + return this.buildAndAttachMenuWithObject(menuObject, null, aPopup); + }, + + /* + * Given a JSON menu object and popup, add the context menu to the popup. This + * is intended to be called when the child page is in a different process. + * aBrowser should be the browser containing the page the context menu is + * displayed for, which may be null. + * + * Returns true if custom menu items were present. + */ + addToPopup: function(aMenu, aBrowser, aPopup) { + return this.buildAndAttachMenuWithObject(aMenu, aBrowser, aPopup); + } +} + +// This object is expected to be used from a child process. +this.PageMenuChild = function PageMenuChild() { +} + +PageMenuChild.prototype = { + __proto__ : PageMenu.prototype, + + /* + * Given a target node, return a JSON object for the custom menu commands. The + * object will consist of a hierarchical structure of menus, menuitems or + * separators. Supported properties of each are: + * Menu: children, label, type="menu" + * Menuitems: checkbox, checked, disabled, icon, label, type="menuitem" + * Separators: type="separator" + * + * In addition, the id of each item will be used to identify the item + * when it is executed. The type will either be 'menu', 'menuitem' or + * 'separator'. The toplevel node will be a menu with a children property. The + * children property of a menu is an array of zero or more other items. + * + * If there is no menu associated with aTarget, null will be returned. + */ + build: function(aTarget) { + return this.maybeBuild(aTarget); + }, + + /* + * Given the id of a menu, execute the command associated with that menu. It + * is assumed that only one command will be executed so the builder is + * cleared afterwards. + */ + executeMenu: function(aId) { + if (this._builder) { + this._builder.click(aId); + this._builder = null; + } + } +} diff --git a/toolkit/modules/PageMetadata.jsm b/toolkit/modules/PageMetadata.jsm new file mode 100644 index 0000000000..820ec38eab --- /dev/null +++ b/toolkit/modules/PageMetadata.jsm @@ -0,0 +1,297 @@ +/* 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 = ["PageMetadata"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/microformat-shiv.js"); + +XPCOMUtils.defineLazyServiceGetter(this, "UnescapeService", + "@mozilla.org/feed-unescapehtml;1", + "nsIScriptableUnescapeHTML"); + + +/** + * Maximum number of images to discover in the document, when no preview images + * are explicitly specified by the metadata. + * @type {Number} + */ +const DISCOVER_IMAGES_MAX = 5; + + +/** + * Extract metadata and microformats from a HTML document. + * @type {Object} + */ +this.PageMetadata = { + /** + * Get all metadata from an HTML document. This includes: + * - URL + * - title + * - Metadata specified in <meta> tags, including OpenGraph data + * - Links specified in <link> tags (short, canonical, preview images, alternative) + * - Content that can be found in the page content that we consider useful metadata + * - Microformats + * + * @param {Document} document - Document to extract data from. + * @param {Element} [target] - Optional element to restrict microformats lookup to. + * @returns {Object} Object containing the various metadata, normalized to + * merge some common alternative names for metadata. + */ + getData(document, target = null) { + let result = { + url: this._validateURL(document, document.documentURI), + title: document.title, + previews: [], + }; + + // if pushState was used to change the url, most likely all meta data is + // invalid. This is the case with several major sites that rely on + // pushState. In that case, we'll only return uri and title. If document is + // via XHR or something, there is no view or history. + if (document.defaultView) { + let docshell = document.defaultView.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + let shentry = {}; + if (docshell.getCurrentSHEntry(shentry) && + shentry.value && shentry.value.URIWasModified) { + return result; + } + } + + this._getMetaData(document, result); + this._getLinkData(document, result); + this._getPageData(document, result); + result.microformats = this.getMicroformats(document, target); + + return result; + }, + + getMicroformats(document, target = null) { + if (target) { + return Microformats.getParent(target, {node: document}); + } + return Microformats.get({node: document}); + }, + + /** + * Get metadata as defined in <meta> tags. + * This adds properties to an existing result object. + * + * @param {Document} document - Document to extract data from. + * @param {Object} result - Existing result object to add properties to. + */ + _getMetaData(document, result) { + // Query for standardized meta data. + let elements = document.querySelectorAll("head > meta[property], head > meta[name]"); + if (elements.length < 1) { + return; + } + + for (let element of elements) { + let value = element.getAttribute("content") + if (!value) { + continue; + } + value = UnescapeService.unescape(value.trim()); + + let key = element.getAttribute("property") || element.getAttribute("name"); + if (!key) { + continue; + } + + // There are a wide array of possible meta tags, expressing articles, + // products, etc. so all meta tags are passed through but we touch up the + // most common attributes. + result[key] = value; + + switch (key) { + case "title": + case "og:title": { + // Only set the title if one hasn't already been obtained (e.g. from the + // document title element). + if (!result.title) { + result.title = value; + } + break; + } + + case "description": + case "og:description": { + result.description = value; + break; + } + + case "og:site_name": { + result.siteName = value; + break; + } + + case "medium": + case "og:type": { + result.medium = value; + break; + } + + case "og:video": { + let url = this._validateURL(document, value); + if (url) { + result.source = url; + } + break; + } + + case "og:url": { + let url = this._validateURL(document, value); + if (url) { + result.url = url; + } + break; + } + + case "og:image": { + let url = this._validateURL(document, value); + if (url) { + result.previews.push(url); + } + break; + } + } + } + }, + + /** + * Get metadata as defined in <link> tags. + * This adds properties to an existing result object. + * + * @param {Document} document - Document to extract data from. + * @param {Object} result - Existing result object to add properties to. + */ + _getLinkData: function(document, result) { + let elements = document.querySelectorAll("head > link[rel], head > link[id]"); + + for (let element of elements) { + let url = element.getAttribute("href"); + if (!url) { + continue; + } + url = this._validateURL(document, UnescapeService.unescape(url.trim())); + + let key = element.getAttribute("rel") || element.getAttribute("id"); + if (!key) { + continue; + } + + switch (key) { + case "shorturl": + case "shortlink": { + result.shortUrl = url; + break; + } + + case "canonicalurl": + case "canonical": { + result.url = url; + break; + } + + case "image_src": { + result.previews.push(url); + break; + } + + case "alternate": { + // Expressly for oembed support but we're liberal here and will let + // other alternate links through. oembed defines an href, supplied by + // the site, where you can fetch additional meta data about a page. + // We'll let the client fetch the oembed data themselves, but they + // need the data from this link. + if (!result.alternate) { + result.alternate = []; + } + + result.alternate.push({ + type: element.getAttribute("type"), + href: element.getAttribute("href"), + title: element.getAttribute("title") + }); + } + } + } + }, + + /** + * Scrape thought the page content for additional content that may be used to + * suppliment explicitly defined metadata. This includes: + * - First few images, when no preview image metadata is explicitly defined. + * + * This adds properties to an existing result object. + * + * @param {Document} document - Document to extract data from. + * @param {Object} result - Existing result object to add properties to. + */ + _getPageData(document, result) { + if (result.previews.length < 1) { + result.previews = this._getImageUrls(document); + } + }, + + /** + * Find the first few images in a document, for use as preview images. + * Will return upto DISCOVER_IMAGES_MAX number of images. + * + * @note This is not very clever. It does not (yet) check if any of the + * images may be appropriate as a preview image. + * + * @param {Document} document - Document to extract data from. + * @return {[string]} Array of URLs. + */ + _getImageUrls(document) { + let result = []; + let elements = document.querySelectorAll("img"); + + for (let element of elements) { + let src = element.getAttribute("src"); + if (src) { + result.push(this._validateURL(document, UnescapeService.unescape(src))); + + // We don't want a billion images. + // TODO: Move this magic number to a const. + if (result.length > DISCOVER_IMAGES_MAX) { + break; + } + } + } + + return result; + }, + + /** + * Validate a URL. This involves resolving the URL if it's relative to the + * document location, ensuring it's using an expected scheme, and stripping + * the userPass portion of the URL. + * + * @param {Document} document - Document to use as the root location for a relative URL. + * @param {string} url - URL to validate. + * @return {string} Result URL. + */ + _validateURL(document, url) { + let docURI = Services.io.newURI(document.documentURI, null, null); + let uri = Services.io.newURI(docURI.resolve(url), null, null); + + if (["http", "https"].indexOf(uri.scheme) < 0) { + return null; + } + + uri.userPass = ""; + + return uri.spec; + }, +}; diff --git a/toolkit/modules/PermissionsUtils.jsm b/toolkit/modules/PermissionsUtils.jsm new file mode 100644 index 0000000000..dfed76f0c3 --- /dev/null +++ b/toolkit/modules/PermissionsUtils.jsm @@ -0,0 +1,99 @@ +// 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/. + +this.EXPORTED_SYMBOLS = ["PermissionsUtils"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/BrowserUtils.jsm") + + +var gImportedPrefBranches = new Set(); + +function importPrefBranch(aPrefBranch, aPermission, aAction) { + let list = Services.prefs.getChildList(aPrefBranch, {}); + + for (let pref of list) { + let origins = ""; + try { + origins = Services.prefs.getCharPref(pref); + } catch (e) {} + + if (!origins) + continue; + + origins = origins.split(","); + + for (let origin of origins) { + let principals = []; + try { + principals = [ Services.scriptSecurityManager.createCodebasePrincipalFromOrigin(origin) ]; + } catch (e) { + // This preference used to contain a list of hosts. For back-compat + // reasons, we convert these hosts into http:// and https:// permissions + // on default ports. + try { + let httpURI = Services.io.newURI("http://" + origin, null, null); + let httpsURI = Services.io.newURI("https://" + origin, null, null); + + principals = [ + Services.scriptSecurityManager.createCodebasePrincipal(httpURI, {}), + Services.scriptSecurityManager.createCodebasePrincipal(httpsURI, {}) + ]; + } catch (e2) {} + } + + for (let principal of principals) { + try { + Services.perms.addFromPrincipal(principal, aPermission, aAction); + } catch (e) {} + } + } + + Services.prefs.setCharPref(pref, ""); + } +} + + +this.PermissionsUtils = { + /** + * Import permissions from perferences to the Permissions Manager. After being + * imported, all processed permissions will be set to an empty string. + * Perferences are only processed once during the application's + * lifetime - it's safe to call this multiple times without worrying about + * doing unnecessary work, as the preferences branch will only be processed + * the first time. + * + * @param aPrefBranch Preferences branch to import from. The preferences + * under this branch can specify whitelist (ALLOW_ACTION) + * or blacklist (DENY_ACTION) additions using perference + * names of the form: + * * <BRANCH>.whitelist.add.<ID> + * * <BRANCH>.blacklist.add.<ID> + * Where <ID> can be any valid preference name. + * The value is expected to be a comma separated list of + * host named. eg: + * * something.example.com + * * foo.exmaple.com,bar.example.com + * + * @param aPermission Permission name to be passsed to the Permissions + * Manager. + */ + importFromPrefs: function(aPrefBranch, aPermission) { + if (!aPrefBranch.endsWith(".")) + aPrefBranch += "."; + + // Ensure we only import this pref branch once. + if (gImportedPrefBranches.has(aPrefBranch)) + return; + + importPrefBranch(aPrefBranch + "whitelist.add", aPermission, + Services.perms.ALLOW_ACTION); + importPrefBranch(aPrefBranch + "blacklist.add", aPermission, + Services.perms.DENY_ACTION); + + gImportedPrefBranches.add(aPrefBranch); + } +}; diff --git a/toolkit/modules/PopupNotifications.jsm b/toolkit/modules/PopupNotifications.jsm new file mode 100644 index 0000000000..c509c39958 --- /dev/null +++ b/toolkit/modules/PopupNotifications.jsm @@ -0,0 +1,1337 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["PopupNotifications"]; + +var Cc = Components.classes, Ci = Components.interfaces, Cu = Components.utils; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); + +const NOTIFICATION_EVENT_DISMISSED = "dismissed"; +const NOTIFICATION_EVENT_REMOVED = "removed"; +const NOTIFICATION_EVENT_SHOWING = "showing"; +const NOTIFICATION_EVENT_SHOWN = "shown"; +const NOTIFICATION_EVENT_SWAPPING = "swapping"; + +const ICON_SELECTOR = ".notification-anchor-icon"; +const ICON_ATTRIBUTE_SHOWING = "showing"; +const ICON_ANCHOR_ATTRIBUTE = "popupnotificationanchor"; + +const PREF_SECURITY_DELAY = "security.notification_enable_delay"; + +// Enumerated values for the POPUP_NOTIFICATION_STATS telemetry histogram. +const TELEMETRY_STAT_OFFERED = 0; +const TELEMETRY_STAT_ACTION_1 = 1; +const TELEMETRY_STAT_ACTION_2 = 2; +const TELEMETRY_STAT_ACTION_3 = 3; +const TELEMETRY_STAT_ACTION_LAST = 4; +const TELEMETRY_STAT_DISMISSAL_CLICK_ELSEWHERE = 5; +const TELEMETRY_STAT_DISMISSAL_LEAVE_PAGE = 6; +const TELEMETRY_STAT_DISMISSAL_CLOSE_BUTTON = 7; +const TELEMETRY_STAT_DISMISSAL_NOT_NOW = 8; +const TELEMETRY_STAT_OPEN_SUBMENU = 10; +const TELEMETRY_STAT_LEARN_MORE = 11; + +const TELEMETRY_STAT_REOPENED_OFFSET = 20; + +var popupNotificationsMap = new WeakMap(); +var gNotificationParents = new WeakMap; + +function getAnchorFromBrowser(aBrowser, aAnchorID) { + let attrPrefix = aAnchorID ? aAnchorID.replace("notification-icon", "") : ""; + let anchor = aBrowser.getAttribute(attrPrefix + ICON_ANCHOR_ATTRIBUTE) || + aBrowser[attrPrefix + ICON_ANCHOR_ATTRIBUTE] || + aBrowser.getAttribute(ICON_ANCHOR_ATTRIBUTE) || + aBrowser[ICON_ANCHOR_ATTRIBUTE]; + if (anchor) { + if (anchor instanceof Ci.nsIDOMXULElement) { + return anchor; + } + return aBrowser.ownerDocument.getElementById(anchor); + } + return null; +} + +function getNotificationFromElement(aElement) { + // Need to find the associated notification object, which is a bit tricky + // since it isn't associated with the element directly - this is kind of + // gross and very dependent on the structure of the popupnotification + // binding's content. + let notificationEl; + let parent = aElement; + while (parent && (parent = aElement.ownerDocument.getBindingParent(parent))) + notificationEl = parent; + return notificationEl; +} + +/** + * Notification object describes a single popup notification. + * + * @see PopupNotifications.show() + */ +function Notification(id, message, anchorID, mainAction, secondaryActions, + browser, owner, options) { + this.id = id; + this.message = message; + this.anchorID = anchorID; + this.mainAction = mainAction; + this.secondaryActions = secondaryActions || []; + this.browser = browser; + this.owner = owner; + this.options = options || {}; + + this._dismissed = false; + // Will become a boolean when manually toggled by the user. + this._checkboxChecked = null; + this.wasDismissed = false; + this.recordedTelemetryStats = new Set(); + this.isPrivate = PrivateBrowsingUtils.isWindowPrivate( + this.browser.ownerDocument.defaultView); + this.timeCreated = this.owner.window.performance.now(); +} + +Notification.prototype = { + + id: null, + message: null, + anchorID: null, + mainAction: null, + secondaryActions: null, + browser: null, + owner: null, + options: null, + timeShown: null, + + /** + * Indicates whether the notification is currently dismissed. + */ + set dismissed(value) { + this._dismissed = value; + if (value) { + // Keep the dismissal into account when recording telemetry. + this.wasDismissed = true; + } + }, + get dismissed() { + return this._dismissed; + }, + + /** + * Removes the notification and updates the popup accordingly if needed. + */ + remove: function Notification_remove() { + this.owner.remove(this); + }, + + get anchorElement() { + let iconBox = this.owner.iconBox; + + let anchorElement = getAnchorFromBrowser(this.browser, this.anchorID); + if (!iconBox) + return anchorElement; + + if (!anchorElement && this.anchorID) + anchorElement = iconBox.querySelector("#"+this.anchorID); + + // Use a default anchor icon if it's available + if (!anchorElement) + anchorElement = iconBox.querySelector("#default-notification-icon") || + iconBox; + + return anchorElement; + }, + + reshow: function() { + this.owner._reshowNotifications(this.anchorElement, this.browser); + }, + + /** + * Adds a value to the specified histogram, that must be keyed by ID. + */ + _recordTelemetry(histogramId, value) { + if (this.isPrivate) { + // The reason why we don't record telemetry in private windows is because + // the available actions can be different from regular mode. The main + // difference is that all of the persistent permission options like + // "Always remember" aren't there, so they really need to be handled + // separately to avoid skewing results. For notifications with the same + // choices, there would be no reason not to record in private windows as + // well, but it's just simpler to use the same check for everything. + return; + } + let histogram = Services.telemetry.getKeyedHistogramById(histogramId); + histogram.add("(all)", value); + histogram.add(this.id, value); + }, + + /** + * Adds an enumerated value to the POPUP_NOTIFICATION_STATS histogram, + * ensuring that it is recorded at most once for each distinct Notification. + * + * Statistics for reopened notifications are recorded in separate buckets. + * + * @param value + * One of the TELEMETRY_STAT_ constants. + */ + _recordTelemetryStat(value) { + if (this.wasDismissed) { + value += TELEMETRY_STAT_REOPENED_OFFSET; + } + if (!this.recordedTelemetryStats.has(value)) { + this.recordedTelemetryStats.add(value); + this._recordTelemetry("POPUP_NOTIFICATION_STATS", value); + } + }, +}; + +/** + * The PopupNotifications object manages popup notifications for a given browser + * window. + * @param tabbrowser + * window's <xul:tabbrowser/>. Used to observe tab switching events and + * for determining the active browser element. + * @param panel + * The <xul:panel/> element to use for notifications. The panel is + * populated with <popupnotification> children and displayed it as + * needed. + * @param iconBox + * Reference to a container element that should be hidden or + * unhidden when notifications are hidden or shown. It should be the + * parent of anchor elements whose IDs are passed to show(). + * It is used as a fallback popup anchor if notifications specify + * invalid or non-existent anchor IDs. + */ +this.PopupNotifications = function PopupNotifications(tabbrowser, panel, iconBox) { + if (!(tabbrowser instanceof Ci.nsIDOMXULElement)) + throw "Invalid tabbrowser"; + if (iconBox && !(iconBox instanceof Ci.nsIDOMXULElement)) + throw "Invalid iconBox"; + if (!(panel instanceof Ci.nsIDOMXULElement)) + throw "Invalid panel"; + + this.window = tabbrowser.ownerDocument.defaultView; + this.panel = panel; + this.tabbrowser = tabbrowser; + this.iconBox = iconBox; + this.buttonDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY); + + this.panel.addEventListener("popuphidden", this, true); + + this.window.addEventListener("activate", this, true); + if (this.tabbrowser.tabContainer) + this.tabbrowser.tabContainer.addEventListener("TabSelect", this, true); +} + +PopupNotifications.prototype = { + + window: null, + panel: null, + tabbrowser: null, + + _iconBox: null, + set iconBox(iconBox) { + // Remove the listeners on the old iconBox, if needed + if (this._iconBox) { + this._iconBox.removeEventListener("click", this, false); + this._iconBox.removeEventListener("keypress", this, false); + } + this._iconBox = iconBox; + if (iconBox) { + iconBox.addEventListener("click", this, false); + iconBox.addEventListener("keypress", this, false); + } + }, + get iconBox() { + return this._iconBox; + }, + + /** + * Retrieve a Notification object associated with the browser/ID pair. + * @param id + * The Notification ID to search for. + * @param browser + * The browser whose notifications should be searched. If null, the + * currently selected browser's notifications will be searched. + * + * @returns the corresponding Notification object, or null if no such + * notification exists. + */ + getNotification: function PopupNotifications_getNotification(id, browser) { + let n = null; + let notifications = this._getNotificationsForBrowser(browser || this.tabbrowser.selectedBrowser); + notifications.some(x => x.id == id && (n = x)); + return n; + }, + + /** + * Adds a new popup notification. + * @param browser + * The <xul:browser> element associated with the notification. Must not + * be null. + * @param id + * A unique ID that identifies the type of notification (e.g. + * "geolocation"). Only one notification with a given ID can be visible + * at a time. If a notification already exists with the given ID, it + * will be replaced. + * @param message + * The text to be displayed in the notification. + * @param anchorID + * The ID of the element that should be used as this notification + * popup's anchor. May be null, in which case the notification will be + * anchored to the iconBox. + * @param mainAction + * A JavaScript object literal describing the notification button's + * action. If present, it must have the following properties: + * - label (string): the button's label. + * - accessKey (string): the button's accessKey. + * - callback (function): a callback to be invoked when the button is + * pressed, is passed an object that contains the following fields: + * - checkboxChecked: (boolean) If the optional checkbox is checked. + * - [optional] dismiss (boolean): If this is true, the notification + * will be dismissed instead of removed after running the callback. + * If null, the notification will not have a button, and + * secondaryActions will be ignored. + * @param secondaryActions + * An optional JavaScript array describing the notification's alternate + * actions. The array should contain objects with the same properties + * as mainAction. These are used to populate the notification button's + * dropdown menu. + * @param options + * An options JavaScript object holding additional properties for the + * notification. The following properties are currently supported: + * persistence: An integer. The notification will not automatically + * dismiss for this many page loads. + * timeout: A time in milliseconds. The notification will not + * automatically dismiss before this time. + * persistWhileVisible: + * A boolean. If true, a visible notification will always + * persist across location changes. + * dismissed: Whether the notification should be added as a dismissed + * notification. Dismissed notifications can be activated + * by clicking on their anchorElement. + * eventCallback: + * Callback to be invoked when the notification changes + * state. The callback's first argument is a string + * identifying the state change: + * "dismissed": notification has been dismissed by the + * user (e.g. by clicking away or switching + * tabs) + * "removed": notification has been removed (due to + * location change or user action) + * "showing": notification is about to be shown + * (this can be fired multiple times as + * notifications are dismissed and re-shown) + * If the callback returns true, the notification + * will be dismissed. + * "shown": notification has been shown (this can be fired + * multiple times as notifications are dismissed + * and re-shown) + * "swapping": the docshell of the browser that created + * the notification is about to be swapped to + * another browser. A second parameter contains + * the browser that is receiving the docshell, + * so that the event callback can transfer stuff + * specific to this notification. + * If the callback returns true, the notification + * will be moved to the new browser. + * If the callback isn't implemented, returns false, + * or doesn't return any value, the notification + * will be removed. + * neverShow: Indicate that no popup should be shown for this + * notification. Useful for just showing the anchor icon. + * removeOnDismissal: + * Notifications with this parameter set to true will be + * removed when they would have otherwise been dismissed + * (i.e. any time the popup is closed due to user + * interaction). + * checkbox: An object that allows you to add a checkbox and + * control its behavior with these fields: + * label: + * (required) Label to be shown next to the checkbox. + * checked: + * (optional) Whether the checkbox should be checked + * by default. Defaults to false. + * checkedState: + * (optional) An object that allows you to customize + * the notification state when the checkbox is checked. + * disableMainAction: + * (optional) Whether the mainAction is disabled. + * Defaults to false. + * warningLabel: + * (optional) A (warning) text that is shown below the + * checkbox. Pass null to hide. + * uncheckedState: + * (optional) An object that allows you to customize + * the notification state when the checkbox is not checked. + * Has the same attributes as checkedState. + * hideNotNow: If true, indicates that the 'Not Now' menuitem should + * not be shown. If 'Not Now' is hidden, it needs to be + * replaced by another 'do nothing' item, so providing at + * least one secondary action is required; and one of the + * actions needs to have the 'dismiss' property set to true. + * popupIconClass: + * A string. A class (or space separated list of classes) + * that will be applied to the icon in the popup so that + * several notifications using the same panel can use + * different icons. + * popupIconURL: + * A string. URL of the image to be displayed in the popup. + * Normally specified in CSS using list-style-image and the + * .popup-notification-icon[popupid=...] selector. + * learnMoreURL: + * A string URL. Setting this property will make the + * prompt display a "Learn More" link that, when clicked, + * opens the URL in a new tab. + * displayURI: + * The nsIURI of the page the notification came + * from. If present, this will be displayed above the message. + * If the nsIURI represents a file, the path will be displayed, + * otherwise the hostPort will be displayed. + * @returns the Notification object corresponding to the added notification. + */ + show: function PopupNotifications_show(browser, id, message, anchorID, + mainAction, secondaryActions, options) { + function isInvalidAction(a) { + return !a || !(typeof(a.callback) == "function") || !a.label || !a.accessKey; + } + + if (!browser) + throw "PopupNotifications_show: invalid browser"; + if (!id) + throw "PopupNotifications_show: invalid ID"; + if (mainAction && isInvalidAction(mainAction)) + throw "PopupNotifications_show: invalid mainAction"; + if (secondaryActions && secondaryActions.some(isInvalidAction)) + throw "PopupNotifications_show: invalid secondaryActions"; + if (options && options.hideNotNow && + (!secondaryActions || !secondaryActions.length || + !secondaryActions.concat(mainAction).some(action => action.dismiss))) + throw "PopupNotifications_show: 'Not Now' item hidden without replacement"; + + let notification = new Notification(id, message, anchorID, mainAction, + secondaryActions, browser, this, options); + + if (options && options.dismissed) + notification.dismissed = true; + + let existingNotification = this.getNotification(id, browser); + if (existingNotification) + this._remove(existingNotification); + + let notifications = this._getNotificationsForBrowser(browser); + notifications.push(notification); + + let isActiveBrowser = this._isActiveBrowser(browser); + let fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager); + let isActiveWindow = fm.activeWindow == this.window; + + if (isActiveBrowser) { + if (isActiveWindow) { + // show panel now + this._update(notifications, new Set([notification.anchorElement]), true); + } else { + // indicate attention and update the icon if necessary + if (!notification.dismissed) { + this.window.getAttention(); + } + this._updateAnchorIcons(notifications, this._getAnchorsForNotifications( + notifications, notification.anchorElement)); + this._notify("backgroundShow"); + } + + } else { + // Notify observers that we're not showing the popup (useful for testing) + this._notify("backgroundShow"); + } + + return notification; + }, + + /** + * Returns true if the notification popup is currently being displayed. + */ + get isPanelOpen() { + let panelState = this.panel.state; + + return panelState == "showing" || panelState == "open"; + }, + + /** + * Called by the consumer to indicate that a browser's location has changed, + * so that we can update the active notifications accordingly. + */ + locationChange: function PopupNotifications_locationChange(aBrowser) { + if (!aBrowser) + throw "PopupNotifications_locationChange: invalid browser"; + + let notifications = this._getNotificationsForBrowser(aBrowser); + + notifications = notifications.filter(function (notification) { + // The persistWhileVisible option allows an open notification to persist + // across location changes + if (notification.options.persistWhileVisible && + this.isPanelOpen) { + if ("persistence" in notification.options && + notification.options.persistence) + notification.options.persistence--; + return true; + } + + // The persistence option allows a notification to persist across multiple + // page loads + if ("persistence" in notification.options && + notification.options.persistence) { + notification.options.persistence--; + return true; + } + + // The timeout option allows a notification to persist until a certain time + if ("timeout" in notification.options && + Date.now() <= notification.options.timeout) { + return true; + } + + this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED); + return false; + }, this); + + this._setNotificationsForBrowser(aBrowser, notifications); + + if (this._isActiveBrowser(aBrowser)) { + // get the anchor element if the browser has defined one so it will + // _update will handle both the tabs iconBox and non-tab permission + // anchors. + this._update(notifications, this._getAnchorsForNotifications(notifications, + getAnchorFromBrowser(aBrowser))); + } + }, + + /** + * Removes a Notification. + * @param notification + * The Notification object to remove. + */ + remove: function PopupNotifications_remove(notification) { + this._remove(notification); + + if (this._isActiveBrowser(notification.browser)) { + let notifications = this._getNotificationsForBrowser(notification.browser); + this._update(notifications); + } + }, + + handleEvent: function (aEvent) { + switch (aEvent.type) { + case "popuphidden": + this._onPopupHidden(aEvent); + break; + case "activate": + case "TabSelect": + let self = this; + // This is where we could detect if the panel is dismissed if the page + // was switched. Unfortunately, the user usually has clicked elsewhere + // at this point so this value only gets recorded for programmatic + // reasons, like the "Learn More" link being clicked and resulting in a + // tab switch. + this.nextDismissReason = TELEMETRY_STAT_DISMISSAL_LEAVE_PAGE; + // setTimeout(..., 0) needed, otherwise openPopup from "activate" event + // handler results in the popup being hidden again for some reason... + this.window.setTimeout(function () { + self._update(); + }, 0); + break; + case "click": + case "keypress": + this._onIconBoxCommand(aEvent); + break; + } + }, + +// Utility methods + + _ignoreDismissal: null, + _currentAnchorElement: null, + + /** + * Gets notifications for the currently selected browser. + */ + get _currentNotifications() { + return this.tabbrowser.selectedBrowser ? this._getNotificationsForBrowser(this.tabbrowser.selectedBrowser) : []; + }, + + _remove: function PopupNotifications_removeHelper(notification) { + // This notification may already be removed, in which case let's just fail + // silently. + let notifications = this._getNotificationsForBrowser(notification.browser); + if (!notifications) + return; + + var index = notifications.indexOf(notification); + if (index == -1) + return; + + if (this._isActiveBrowser(notification.browser)) + notification.anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING); + + // remove the notification + notifications.splice(index, 1); + this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED); + }, + + /** + * Dismisses the notification without removing it. + */ + _dismiss: function PopupNotifications_dismiss(telemetryReason) { + if (telemetryReason) { + this.nextDismissReason = telemetryReason; + } + + let browser = this.panel.firstChild && + this.panel.firstChild.notification.browser; + this.panel.hidePopup(); + if (browser) + browser.focus(); + }, + + /** + * Hides the notification popup. + */ + _hidePanel: function PopupNotifications_hide() { + if (this.panel.state == "closed") { + return Promise.resolve(); + } + if (this._ignoreDismissal) { + return this._ignoreDismissal.promise; + } + let deferred = Promise.defer(); + this._ignoreDismissal = deferred; + this.panel.hidePopup(); + return deferred.promise; + }, + + /** + * Removes all notifications from the notification popup. + */ + _clearPanel: function () { + let popupnotification; + while ((popupnotification = this.panel.lastChild)) { + this.panel.removeChild(popupnotification); + + // If this notification was provided by the chrome document rather than + // created ad hoc, move it back to where we got it from. + let originalParent = gNotificationParents.get(popupnotification); + if (originalParent) { + popupnotification.notification = null; + + // Remove nodes dynamically added to the notification's menu button + // in _refreshPanel. + let contentNode = popupnotification.lastChild; + while (contentNode) { + let previousSibling = contentNode.previousSibling; + if (contentNode.nodeName == "menuitem" || + contentNode.nodeName == "menuseparator") + popupnotification.removeChild(contentNode); + contentNode = previousSibling; + } + + // Re-hide the notification such that it isn't rendered in the chrome + // document. _refreshPanel will unhide it again when needed. + popupnotification.hidden = true; + + originalParent.appendChild(popupnotification); + } + } + }, + + _refreshPanel: function PopupNotifications_refreshPanel(notificationsToShow) { + this._clearPanel(); + + const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + notificationsToShow.forEach(function (n) { + let doc = this.window.document; + + // Append "-notification" to the ID to try to avoid ID conflicts with other stuff + // in the document. + let popupnotificationID = n.id + "-notification"; + + // If the chrome document provides a popupnotification with this id, use + // that. Otherwise create it ad-hoc. + let popupnotification = doc.getElementById(popupnotificationID); + if (popupnotification) + gNotificationParents.set(popupnotification, popupnotification.parentNode); + else + popupnotification = doc.createElementNS(XUL_NS, "popupnotification"); + + popupnotification.setAttribute("label", n.message); + popupnotification.setAttribute("id", popupnotificationID); + popupnotification.setAttribute("popupid", n.id); + popupnotification.setAttribute("closebuttoncommand", `PopupNotifications._dismiss(${TELEMETRY_STAT_DISMISSAL_CLOSE_BUTTON});`); + if (n.mainAction) { + popupnotification.setAttribute("buttonlabel", n.mainAction.label); + popupnotification.setAttribute("buttonaccesskey", n.mainAction.accessKey); + popupnotification.setAttribute("buttoncommand", "PopupNotifications._onButtonEvent(event, 'buttoncommand');"); + popupnotification.setAttribute("buttonpopupshown", "PopupNotifications._onButtonEvent(event, 'buttonpopupshown');"); + popupnotification.setAttribute("learnmoreclick", "PopupNotifications._onButtonEvent(event, 'learnmoreclick');"); + popupnotification.setAttribute("menucommand", "PopupNotifications._onMenuCommand(event);"); + popupnotification.setAttribute("closeitemcommand", `PopupNotifications._dismiss(${TELEMETRY_STAT_DISMISSAL_NOT_NOW});event.stopPropagation();`); + } else { + popupnotification.removeAttribute("buttonlabel"); + popupnotification.removeAttribute("buttonaccesskey"); + popupnotification.removeAttribute("buttoncommand"); + popupnotification.removeAttribute("buttonpopupshown"); + popupnotification.removeAttribute("learnmoreclick"); + popupnotification.removeAttribute("menucommand"); + popupnotification.removeAttribute("closeitemcommand"); + } + + if (n.options.popupIconClass) { + let classes = "popup-notification-icon " + n.options.popupIconClass; + popupnotification.setAttribute("iconclass", classes); + } + if (n.options.popupIconURL) + popupnotification.setAttribute("icon", n.options.popupIconURL); + + if (n.options.learnMoreURL) + popupnotification.setAttribute("learnmoreurl", n.options.learnMoreURL); + else + popupnotification.removeAttribute("learnmoreurl"); + + if (n.options.displayURI) { + let uri; + try { + if (n.options.displayURI instanceof Ci.nsIFileURL) { + uri = n.options.displayURI.path; + } else { + uri = n.options.displayURI.hostPort; + } + popupnotification.setAttribute("origin", uri); + } catch (e) { + Cu.reportError(e); + popupnotification.removeAttribute("origin"); + } + } else + popupnotification.removeAttribute("origin"); + + popupnotification.notification = n; + + if (n.secondaryActions) { + let telemetryStatId = TELEMETRY_STAT_ACTION_2; + + n.secondaryActions.forEach(function (a) { + let item = doc.createElementNS(XUL_NS, "menuitem"); + item.setAttribute("label", a.label); + item.setAttribute("accesskey", a.accessKey); + item.notification = n; + item.action = a; + + popupnotification.appendChild(item); + + // We can only record a limited number of actions in telemetry. If + // there are more, the latest are all recorded in the last bucket. + item.action.telemetryStatId = telemetryStatId; + if (telemetryStatId < TELEMETRY_STAT_ACTION_LAST) { + telemetryStatId++; + } + }, this); + + if (n.options.hideNotNow) { + popupnotification.setAttribute("hidenotnow", "true"); + } + else if (n.secondaryActions.length) { + let closeItemSeparator = doc.createElementNS(XUL_NS, "menuseparator"); + popupnotification.appendChild(closeItemSeparator); + } + } + + let checkbox = n.options.checkbox; + if (checkbox && checkbox.label) { + let checked = n._checkboxChecked != null ? n._checkboxChecked : !!checkbox.checked; + + popupnotification.setAttribute("checkboxhidden", "false"); + popupnotification.setAttribute("checkboxchecked", checked); + popupnotification.setAttribute("checkboxlabel", checkbox.label); + + popupnotification.setAttribute("checkboxcommand", "PopupNotifications._onCheckboxCommand(event);"); + + if (checked) { + this._setNotificationUIState(popupnotification, checkbox.checkedState); + } else { + this._setNotificationUIState(popupnotification, checkbox.uncheckedState); + } + } else { + popupnotification.setAttribute("checkboxhidden", "true"); + } + + this.panel.appendChild(popupnotification); + + // The popupnotification may be hidden if we got it from the chrome + // document rather than creating it ad hoc. + popupnotification.hidden = false; + }, this); + }, + + _setNotificationUIState(notification, state={}) { + notification.setAttribute("mainactiondisabled", state.disableMainAction || "false"); + + if (state.warningLabel) { + notification.setAttribute("warninglabel", state.warningLabel); + notification.setAttribute("warninghidden", "false"); + } else { + notification.setAttribute("warninghidden", "true"); + } + }, + + _onCheckboxCommand(event) { + let notificationEl = getNotificationFromElement(event.originalTarget); + let checked = notificationEl.checkbox.checked; + let notification = notificationEl.notification; + + // Save checkbox state to be able to persist it when re-opening the doorhanger. + notification._checkboxChecked = checked; + + if (checked) { + this._setNotificationUIState(notificationEl, notification.options.checkbox.checkedState); + } else { + this._setNotificationUIState(notificationEl, notification.options.checkbox.uncheckedState); + } + }, + + _showPanel: function PopupNotifications_showPanel(notificationsToShow, anchorElement) { + this.panel.hidden = false; + + notificationsToShow = notificationsToShow.filter(n => { + let dismiss = this._fireCallback(n, NOTIFICATION_EVENT_SHOWING); + if (dismiss) + n.dismissed = true; + return !dismiss; + }); + if (!notificationsToShow.length) + return; + let notificationIds = notificationsToShow.map(n => n.id); + + this._refreshPanel(notificationsToShow); + + if (this.isPanelOpen && this._currentAnchorElement == anchorElement) { + notificationsToShow.forEach(function (n) { + this._fireCallback(n, NOTIFICATION_EVENT_SHOWN); + }, this); + // Let tests know that the panel was updated and what notifications it was + // updated with so that tests can wait for the correct notifications to be + // added. + let event = new this.window.CustomEvent("PanelUpdated", + {"detail": notificationIds}); + this.panel.dispatchEvent(event); + return; + } + + // If the panel is already open but we're changing anchors, we need to hide + // it first. Otherwise it can appear in the wrong spot. (_hidePanel is + // safe to call even if the panel is already hidden.) + let promise = this._hidePanel().then(() => { + // If the anchor element is hidden or null, use the tab as the anchor. We + // only ever show notifications for the current browser, so we can just use + // the current tab. + let selectedTab = this.tabbrowser.selectedTab; + if (anchorElement) { + let bo = anchorElement.boxObject; + if (bo.height == 0 && bo.width == 0) + anchorElement = selectedTab; // hidden + } else { + anchorElement = selectedTab; // null + } + + this._currentAnchorElement = anchorElement; + + // On OS X and Linux we need a different panel arrow color for + // click-to-play plugins, so copy the popupid and use css. + this.panel.setAttribute("popupid", this.panel.firstChild.getAttribute("popupid")); + notificationsToShow.forEach(function (n) { + // Record that the notification was actually displayed on screen. + // Notifications that were opened a second time or that were originally + // shown with "options.dismissed" will be recorded in a separate bucket. + n._recordTelemetryStat(TELEMETRY_STAT_OFFERED); + // Remember the time the notification was shown for the security delay. + n.timeShown = this.window.performance.now(); + }, this); + + // Unless the panel closing is triggered by a specific known code path, + // the next reason will be that the user clicked elsewhere. + this.nextDismissReason = TELEMETRY_STAT_DISMISSAL_CLICK_ELSEWHERE; + + let target = this.panel; + if (target.parentNode) { + // NOTIFICATION_EVENT_SHOWN should be fired for the panel before + // anyone listening for popupshown on the panel gets run. Otherwise, + // the panel will not be initialized when the popupshown event + // listeners run. + // By targeting the panel's parent and using a capturing listener, we + // can have our listener called before others waiting for the panel to + // be shown (which probably expect the panel to be fully initialized) + target = target.parentNode; + } + if (this._popupshownListener) { + target.removeEventListener("popupshown", this._popupshownListener, true); + } + this._popupshownListener = function (e) { + target.removeEventListener("popupshown", this._popupshownListener, true); + this._popupshownListener = null; + + notificationsToShow.forEach(function (n) { + this._fireCallback(n, NOTIFICATION_EVENT_SHOWN); + }, this); + // These notifications are used by tests to know when all the processing + // required to display the panel has happened. + this.panel.dispatchEvent(new this.window.CustomEvent("Shown")); + let event = new this.window.CustomEvent("PanelUpdated", + {"detail": notificationIds}); + this.panel.dispatchEvent(event); + }; + this._popupshownListener = this._popupshownListener.bind(this); + target.addEventListener("popupshown", this._popupshownListener, true); + + this.panel.openPopup(anchorElement, "bottomcenter topleft"); + }); + }, + + /** + * Updates the notification state in response to window activation or tab + * selection changes. + * + * @param notifications an array of Notification instances. if null, + * notifications will be retrieved off the current + * browser tab + * @param anchors is a XUL element or a Set of XUL elements that the + * notifications panel(s) will be anchored to. + * @param dismissShowing if true, dismiss any currently visible notifications + * if there are no notifications to show. Otherwise, + * currently displayed notifications will be left alone. + */ + _update: function PopupNotifications_update(notifications, anchors = new Set(), dismissShowing = false) { + if (anchors instanceof Ci.nsIDOMXULElement) + anchors = new Set([anchors]); + + if (!notifications) + notifications = this._currentNotifications; + + let haveNotifications = notifications.length > 0; + if (!anchors.size && haveNotifications) + anchors = this._getAnchorsForNotifications(notifications); + + let useIconBox = !!this.iconBox; + if (useIconBox && anchors.size) { + for (let anchor of anchors) { + if (anchor.parentNode == this.iconBox) + continue; + useIconBox = false; + break; + } + } + + // Filter out notifications that have been dismissed. + let notificationsToShow = notifications.filter(function (n) { + return !n.dismissed && !n.options.neverShow; + }); + + if (useIconBox) { + // Hide icons of the previous tab. + this._hideIcons(); + } + + if (haveNotifications) { + // Also filter out notifications that are for a different anchor. + notificationsToShow = notificationsToShow.filter(function (n) { + return anchors.has(n.anchorElement); + }); + + if (useIconBox) { + this._showIcons(notifications); + this.iconBox.hidden = false; + // Make sure that panels can only be attached to anchors of shown + // notifications inside an iconBox. + anchors = this._getAnchorsForNotifications(notificationsToShow); + } else if (anchors.size) { + this._updateAnchorIcons(notifications, anchors); + } + } + + if (notificationsToShow.length > 0) { + let anchorElement = anchors.values().next().value; + if (anchorElement) { + this._showPanel(notificationsToShow, anchorElement); + } + } else { + // Notify observers that we're not showing the popup (useful for testing) + this._notify("updateNotShowing"); + + // Close the panel if there are no notifications to show. + // When called from PopupNotifications.show() we should never close the + // panel, however. It may just be adding a dismissed notification, in + // which case we want to continue showing any existing notifications. + if (!dismissShowing) + this._dismiss(); + + // Only hide the iconBox if we actually have no notifications (as opposed + // to not having any showable notifications) + if (!haveNotifications) { + if (useIconBox) { + this.iconBox.hidden = true; + } else if (anchors.size) { + for (let anchorElement of anchors) + anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING); + } + } + } + }, + + _updateAnchorIcons: function PopupNotifications_updateAnchorIcons(notifications, + anchorElements) { + for (let anchorElement of anchorElements) { + anchorElement.setAttribute(ICON_ATTRIBUTE_SHOWING, "true"); + // Use the anchorID as a class along with the default icon class as a + // fallback if anchorID is not defined in CSS. We always use the first + // notifications icon, so in the case of multiple notifications we'll + // only use the default icon. + if (anchorElement.classList.contains("notification-anchor-icon")) { + // remove previous icon classes + let className = anchorElement.className.replace(/([-\w]+-notification-icon\s?)/g, "") + if (notifications.length > 0) { + // Find the first notification this anchor used for. + let notification = notifications[0]; + for (let n of notifications) { + if (n.anchorElement == anchorElement) { + notification = n; + break; + } + } + // With this notification we can better approximate the most fitting + // style. + className = notification.anchorID + " " + className; + } + anchorElement.className = className; + } + } + }, + + _showIcons: function PopupNotifications_showIcons(aCurrentNotifications) { + for (let notification of aCurrentNotifications) { + let anchorElm = notification.anchorElement; + if (anchorElm) { + anchorElm.setAttribute(ICON_ATTRIBUTE_SHOWING, "true"); + } + } + }, + + _hideIcons: function PopupNotifications_hideIcons() { + let icons = this.iconBox.querySelectorAll(ICON_SELECTOR); + for (let icon of icons) { + icon.removeAttribute(ICON_ATTRIBUTE_SHOWING); + } + }, + + /** + * Gets and sets notifications for the browser. + */ + _getNotificationsForBrowser: function PopupNotifications_getNotifications(browser) { + let notifications = popupNotificationsMap.get(browser); + if (!notifications) { + // Initialize the WeakMap for the browser so callers can reference/manipulate the array. + notifications = []; + popupNotificationsMap.set(browser, notifications); + } + return notifications; + }, + _setNotificationsForBrowser: function PopupNotifications_setNotifications(browser, notifications) { + popupNotificationsMap.set(browser, notifications); + return notifications; + }, + + _getAnchorsForNotifications: function PopupNotifications_getAnchorsForNotifications(notifications, defaultAnchor) { + let anchors = new Set(); + for (let notification of notifications) { + if (notification.anchorElement) + anchors.add(notification.anchorElement) + } + if (defaultAnchor && !anchors.size) + anchors.add(defaultAnchor); + return anchors; + }, + + _isActiveBrowser: function (browser) { + // We compare on frameLoader instead of just comparing the + // selectedBrowser and browser directly because browser tabs in + // Responsive Design Mode put the actual web content into a + // mozbrowser iframe and proxy property read/write and method + // calls from the tab to that iframe. This is so that attempts + // to reload the tab end up reloading the content in + // Responsive Design Mode, and not the Responsive Design Mode + // viewer itself. + // + // This means that PopupNotifications can come up from a browser + // in Responsive Design Mode, but the selectedBrowser will not match + // the browser being passed into this function, despite the browser + // actually being within the selected tab. We workaround this by + // comparing frameLoader instead, which is proxied from the outer + // <xul:browser> to the inner mozbrowser <iframe>. + return this.tabbrowser.selectedBrowser.frameLoader == browser.frameLoader; + }, + + _onIconBoxCommand: function PopupNotifications_onIconBoxCommand(event) { + // Left click, space or enter only + let type = event.type; + if (type == "click" && event.button != 0) + return; + + if (type == "keypress" && + !(event.charCode == Ci.nsIDOMKeyEvent.DOM_VK_SPACE || + event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_RETURN)) + return; + + if (this._currentNotifications.length == 0) + return; + + event.stopPropagation(); + + // Get the anchor that is the immediate child of the icon box + let anchor = event.target; + while (anchor && anchor.parentNode != this.iconBox) + anchor = anchor.parentNode; + + if (!anchor) { + return; + } + + // If the panel is not closed, and the anchor is different, immediately mark all + // active notifications for the previous anchor as dismissed + if (this.panel.state != "closed" && anchor != this._currentAnchorElement) { + this._dismissOrRemoveCurrentNotifications(); + } + + // Ensure we move focus into the panel because it's opened through user interaction: + this.panel.removeAttribute("noautofocus", "true"); + + this._reshowNotifications(anchor); + + // If the user re-selects the current notification, focus it. + if (anchor == this._currentAnchorElement && this.panel.firstChild) { + this.panel.firstChild.button.focus(); + } + }, + + _reshowNotifications: function PopupNotifications_reshowNotifications(anchor, browser) { + // Mark notifications anchored to this anchor as un-dismissed + browser = browser || this.tabbrowser.selectedBrowser; + let notifications = this._getNotificationsForBrowser(browser); + notifications.forEach(function (n) { + if (n.anchorElement == anchor) + n.dismissed = false; + }); + + if (this._isActiveBrowser(browser)) { + // ...and then show them. + this._update(notifications, anchor); + } + }, + + _swapBrowserNotifications: function PopupNotifications_swapBrowserNoficications(ourBrowser, otherBrowser) { + // When swaping browser docshells (e.g. dragging tab to new window) we need + // to update our notification map. + + let ourNotifications = this._getNotificationsForBrowser(ourBrowser); + let other = otherBrowser.ownerDocument.defaultView.PopupNotifications; + if (!other) { + if (ourNotifications.length > 0) + Cu.reportError("unable to swap notifications: otherBrowser doesn't support notifications"); + return; + } + let otherNotifications = other._getNotificationsForBrowser(otherBrowser); + if (ourNotifications.length < 1 && otherNotifications.length < 1) { + // No notification to swap. + return; + } + + otherNotifications = otherNotifications.filter(n => { + if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, ourBrowser)) { + n.browser = ourBrowser; + n.owner = this; + return true; + } + other._fireCallback(n, NOTIFICATION_EVENT_REMOVED); + return false; + }); + + ourNotifications = ourNotifications.filter(n => { + if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, otherBrowser)) { + n.browser = otherBrowser; + n.owner = other; + return true; + } + this._fireCallback(n, NOTIFICATION_EVENT_REMOVED); + return false; + }); + + this._setNotificationsForBrowser(otherBrowser, ourNotifications); + other._setNotificationsForBrowser(ourBrowser, otherNotifications); + + if (otherNotifications.length > 0) + this._update(otherNotifications); + if (ourNotifications.length > 0) + other._update(ourNotifications); + }, + + _fireCallback: function PopupNotifications_fireCallback(n, event, ...args) { + try { + if (n.options.eventCallback) + return n.options.eventCallback.call(n, event, ...args); + } catch (error) { + Cu.reportError(error); + } + return undefined; + }, + + _onPopupHidden: function PopupNotifications_onPopupHidden(event) { + if (event.target != this.panel || this._ignoreDismissal) { + if (this._ignoreDismissal) { + this._ignoreDismissal.resolve(); + this._ignoreDismissal = null; + } + return; + } + + // Ensure that when the panel comes up without user interaction, + // we don't autofocus it. + this.panel.setAttribute("noautofocus", "true"); + + this._dismissOrRemoveCurrentNotifications(); + + this._clearPanel(); + + this._update(); + }, + + _dismissOrRemoveCurrentNotifications: function() { + let browser = this.panel.firstChild && + this.panel.firstChild.notification.browser; + if (!browser) + return; + + let notifications = this._getNotificationsForBrowser(browser); + // Mark notifications as dismissed and call dismissal callbacks + Array.forEach(this.panel.childNodes, function (nEl) { + let notificationObj = nEl.notification; + // Never call a dismissal handler on a notification that's been removed. + if (notifications.indexOf(notificationObj) == -1) + return; + + // Record the time of the first notification dismissal if the main action + // was not triggered in the meantime. + let timeSinceShown = this.window.performance.now() - notificationObj.timeShown; + if (!notificationObj.wasDismissed && + !notificationObj.recordedTelemetryMainAction) { + notificationObj._recordTelemetry("POPUP_NOTIFICATION_DISMISSAL_MS", + timeSinceShown); + } + notificationObj._recordTelemetryStat(this.nextDismissReason); + + // Do not mark the notification as dismissed or fire NOTIFICATION_EVENT_DISMISSED + // if the notification is removed. + if (notificationObj.options.removeOnDismissal) { + this._remove(notificationObj); + } else { + notificationObj.dismissed = true; + this._fireCallback(notificationObj, NOTIFICATION_EVENT_DISMISSED); + } + }, this); + }, + + _onButtonEvent(event, type) { + let notificationEl = getNotificationFromElement(event.originalTarget); + + if (!notificationEl) + throw "PopupNotifications._onButtonEvent: couldn't find notification element"; + + if (!notificationEl.notification) + throw "PopupNotifications._onButtonEvent: couldn't find notification"; + + let notification = notificationEl.notification; + + if (type == "buttonpopupshown") { + notification._recordTelemetryStat(TELEMETRY_STAT_OPEN_SUBMENU); + return; + } + + if (type == "learnmoreclick") { + notification._recordTelemetryStat(TELEMETRY_STAT_LEARN_MORE); + return; + } + + // Record the total timing of the main action since the notification was + // created, even if the notification was dismissed in the meantime. + let timeSinceCreated = this.window.performance.now() - notification.timeCreated; + if (!notification.recordedTelemetryMainAction) { + notification.recordedTelemetryMainAction = true; + notification._recordTelemetry("POPUP_NOTIFICATION_MAIN_ACTION_MS", + timeSinceCreated); + } + + let timeSinceShown = this.window.performance.now() - notification.timeShown; + if (timeSinceShown < this.buttonDelay) { + Services.console.logStringMessage("PopupNotifications._onButtonEvent: " + + "Button click happened before the security delay: " + + timeSinceShown + "ms"); + return; + } + + notification._recordTelemetryStat(TELEMETRY_STAT_ACTION_1); + + try { + notification.mainAction.callback.call(undefined, { + checkboxChecked: notificationEl.checkbox.checked + }); + } catch (error) { + Cu.reportError(error); + } + + if (notification.mainAction.dismiss) { + this._dismiss(); + return; + } + + this._remove(notification); + this._update(); + }, + + _onMenuCommand: function PopupNotifications_onMenuCommand(event) { + let target = event.originalTarget; + if (!target.action || !target.notification) + throw "menucommand target has no associated action/notification"; + + let notificationEl = target.parentElement; + event.stopPropagation(); + + target.notification._recordTelemetryStat(target.action.telemetryStatId); + + try { + target.action.callback.call(undefined, { + checkboxChecked: notificationEl.checkbox.checked + }); + } catch (error) { + Cu.reportError(error); + } + + if (target.action.dismiss) { + this._dismiss(); + return; + } + + this._remove(target.notification); + this._update(); + }, + + _notify: function PopupNotifications_notify(topic) { + Services.obs.notifyObservers(null, "PopupNotifications-" + topic, ""); + }, +}; diff --git a/toolkit/modules/Preferences.jsm b/toolkit/modules/Preferences.jsm new file mode 100644 index 0000000000..232d877fb6 --- /dev/null +++ b/toolkit/modules/Preferences.jsm @@ -0,0 +1,428 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["Preferences"]; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +// The minimum and maximum integers that can be set as preferences. +// The range of valid values is narrower than the range of valid JS values +// because the native preferences code treats integers as NSPR PRInt32s, +// which are 32-bit signed integers on all platforms. +const MAX_INT = 0x7FFFFFFF; // Math.pow(2, 31) - 1 +const MIN_INT = -0x80000000; + +this.Preferences = + function Preferences(args) { + this._cachedPrefBranch = null; + if (isObject(args)) { + if (args.branch) + this._branchStr = args.branch; + if (args.defaultBranch) + this._defaultBranch = args.defaultBranch; + if (args.privacyContext) + this._privacyContext = args.privacyContext; + } + else if (args) + this._branchStr = args; + }; + +/** + * Get the value of a pref, if any; otherwise return the default value. + * + * @param prefName {String|Array} + * the pref to get, or an array of prefs to get + * + * @param defaultValue + * the default value, if any, for prefs that don't have one + * + * @param valueType + * the XPCOM interface of the pref's complex value type, if any + * + * @returns the value of the pref, if any; otherwise the default value + */ +Preferences.get = function(prefName, defaultValue, valueType = Ci.nsISupportsString) { + if (Array.isArray(prefName)) + return prefName.map(v => this.get(v, defaultValue)); + + return this._get(prefName, defaultValue, valueType); +}; + +Preferences._get = function(prefName, defaultValue, valueType) { + switch (this._prefBranch.getPrefType(prefName)) { + case Ci.nsIPrefBranch.PREF_STRING: + return this._prefBranch.getComplexValue(prefName, valueType).data; + + case Ci.nsIPrefBranch.PREF_INT: + return this._prefBranch.getIntPref(prefName); + + case Ci.nsIPrefBranch.PREF_BOOL: + return this._prefBranch.getBoolPref(prefName); + + case Ci.nsIPrefBranch.PREF_INVALID: + return defaultValue; + + default: + // This should never happen. + throw "Error getting pref " + prefName + "; its value's type is " + + this._prefBranch.getPrefType(prefName) + ", which I don't " + + "know how to handle."; + } +}; + +/** + * Set a preference to a value. + * + * You can set multiple prefs by passing an object as the only parameter. + * In that case, this method will treat the properties of the object + * as preferences to set, where each property name is the name of a pref + * and its corresponding property value is the value of the pref. + * + * @param prefName {String|Object} + * the name of the pref to set; or an object containing a set + * of prefs to set + * + * @param prefValue {String|Number|Boolean} + * the value to which to set the pref + * + * Note: Preferences cannot store non-integer numbers or numbers outside + * the signed 32-bit range -(2^31-1) to 2^31-1, If you have such a number, + * store it as a string by calling toString() on the number before passing + * it to this method, i.e.: + * Preferences.set("pi", 3.14159.toString()) + * Preferences.set("big", Math.pow(2, 31).toString()). + */ +Preferences.set = function(prefName, prefValue) { + if (isObject(prefName)) { + for (let [name, value] of Object.entries(prefName)) + this.set(name, value); + return; + } + + this._set(prefName, prefValue); +}; + +Preferences._set = function(prefName, prefValue) { + let prefType; + if (typeof prefValue != "undefined" && prefValue != null) + prefType = prefValue.constructor.name; + + switch (prefType) { + case "String": + { + let str = Cc["@mozilla.org/supports-string;1"]. + createInstance(Ci.nsISupportsString); + str.data = prefValue; + this._prefBranch.setComplexValue(prefName, Ci.nsISupportsString, str); + } + break; + + case "Number": + // We throw if the number is outside the range, since the result + // will never be what the consumer wanted to store, but we only warn + // if the number is non-integer, since the consumer might not mind + // the loss of precision. + if (prefValue > MAX_INT || prefValue < MIN_INT) + throw ("you cannot set the " + prefName + " pref to the number " + + prefValue + ", as number pref values must be in the signed " + + "32-bit integer range -(2^31-1) to 2^31-1. To store numbers " + + "outside that range, store them as strings."); + this._prefBranch.setIntPref(prefName, prefValue); + if (prefValue % 1 != 0) + Cu.reportError("Warning: setting the " + prefName + " pref to the " + + "non-integer number " + prefValue + " converted it " + + "to the integer number " + this.get(prefName) + + "; to retain fractional precision, store non-integer " + + "numbers as strings."); + break; + + case "Boolean": + this._prefBranch.setBoolPref(prefName, prefValue); + break; + + default: + throw "can't set pref " + prefName + " to value '" + prefValue + + "'; it isn't a String, Number, or Boolean"; + } +}; + +/** + * Whether or not the given pref has a value. This is different from isSet + * because it returns true whether the value of the pref is a default value + * or a user-set value, while isSet only returns true if the value + * is a user-set value. + * + * @param prefName {String|Array} + * the pref to check, or an array of prefs to check + * + * @returns {Boolean|Array} + * whether or not the pref has a value; or, if the caller provided + * an array of pref names, an array of booleans indicating whether + * or not the prefs have values + */ +Preferences.has = function(prefName) { + if (Array.isArray(prefName)) + return prefName.map(this.has, this); + + return (this._prefBranch.getPrefType(prefName) != Ci.nsIPrefBranch.PREF_INVALID); +}; + +/** + * Whether or not the given pref has a user-set value. This is different + * from |has| because it returns true only if the value of the pref is a user- + * set value, while |has| returns true if the value of the pref is a default + * value or a user-set value. + * + * @param prefName {String|Array} + * the pref to check, or an array of prefs to check + * + * @returns {Boolean|Array} + * whether or not the pref has a user-set value; or, if the caller + * provided an array of pref names, an array of booleans indicating + * whether or not the prefs have user-set values + */ +Preferences.isSet = function(prefName) { + if (Array.isArray(prefName)) + return prefName.map(this.isSet, this); + + return (this.has(prefName) && this._prefBranch.prefHasUserValue(prefName)); +}, + +/** + * Whether or not the given pref has a user-set value. Use isSet instead, + * which is equivalent. + * @deprecated + */ +Preferences.modified = function(prefName) { return this.isSet(prefName) }, + +Preferences.reset = function(prefName) { + if (Array.isArray(prefName)) { + prefName.map(v => this.reset(v)); + return; + } + + this._prefBranch.clearUserPref(prefName); +}; + +/** + * Lock a pref so it can't be changed. + * + * @param prefName {String|Array} + * the pref to lock, or an array of prefs to lock + */ +Preferences.lock = function(prefName) { + if (Array.isArray(prefName)) + prefName.map(this.lock, this); + + this._prefBranch.lockPref(prefName); +}; + +/** + * Unlock a pref so it can be changed. + * + * @param prefName {String|Array} + * the pref to lock, or an array of prefs to lock + */ +Preferences.unlock = function(prefName) { + if (Array.isArray(prefName)) + prefName.map(this.unlock, this); + + this._prefBranch.unlockPref(prefName); +}; + +/** + * Whether or not the given pref is locked against changes. + * + * @param prefName {String|Array} + * the pref to check, or an array of prefs to check + * + * @returns {Boolean|Array} + * whether or not the pref has a user-set value; or, if the caller + * provided an array of pref names, an array of booleans indicating + * whether or not the prefs have user-set values + */ +Preferences.locked = function(prefName) { + if (Array.isArray(prefName)) + return prefName.map(this.locked, this); + + return this._prefBranch.prefIsLocked(prefName); +}; + +/** + * Start observing a pref. + * + * The callback can be a function or any object that implements nsIObserver. + * When the callback is a function and thisObject is provided, it gets called + * as a method of thisObject. + * + * @param prefName {String} + * the name of the pref to observe + * + * @param callback {Function|Object} + * the code to notify when the pref changes; + * + * @param thisObject {Object} [optional] + * the object to use as |this| when calling a Function callback; + * + * @returns the wrapped observer + */ +Preferences.observe = function(prefName, callback, thisObject) { + let fullPrefName = this._branchStr + (prefName || ""); + + let observer = new PrefObserver(fullPrefName, callback, thisObject); + Preferences._prefBranch.addObserver(fullPrefName, observer, true); + observers.push(observer); + + return observer; +}; + +/** + * Stop observing a pref. + * + * You must call this method with the same prefName, callback, and thisObject + * with which you originally registered the observer. However, you don't have + * to call this method on the same exact instance of Preferences; you can call + * it on any instance. For example, the following code first starts and then + * stops observing the "foo.bar.baz" preference: + * + * let observer = function() {...}; + * Preferences.observe("foo.bar.baz", observer); + * new Preferences("foo.bar.").ignore("baz", observer); + * + * @param prefName {String} + * the name of the pref being observed + * + * @param callback {Function|Object} + * the code being notified when the pref changes + * + * @param thisObject {Object} [optional] + * the object being used as |this| when calling a Function callback + */ +Preferences.ignore = function(prefName, callback, thisObject) { + let fullPrefName = this._branchStr + (prefName || ""); + + // This seems fairly inefficient, but I'm not sure how much better we can + // make it. We could index by fullBranch, but we can't index by callback + // or thisObject, as far as I know, since the keys to JavaScript hashes + // (a.k.a. objects) can apparently only be primitive values. + let [observer] = observers.filter(v => v.prefName == fullPrefName && + v.callback == callback && + v.thisObject == thisObject); + + if (observer) { + Preferences._prefBranch.removeObserver(fullPrefName, observer); + observers.splice(observers.indexOf(observer), 1); + } else { + Cu.reportError(`Attempt to stop observing a preference "${prefName}" that's not being observed`); + } +}; + +Preferences.resetBranch = function(prefBranch = "") { + try { + this._prefBranch.resetBranch(prefBranch); + } + catch (ex) { + // The current implementation of nsIPrefBranch in Mozilla + // doesn't implement resetBranch, so we do it ourselves. + if (ex.result == Cr.NS_ERROR_NOT_IMPLEMENTED) + this.reset(this._prefBranch.getChildList(prefBranch, [])); + else + throw ex; + } +}, + +/** + * A string identifying the branch of the preferences tree to which this + * instance provides access. + * @private + */ +Preferences._branchStr = ""; + +/** + * The cached preferences branch object this instance encapsulates, or null. + * Do not use! Use _prefBranch below instead. + * @private + */ +Preferences._cachedPrefBranch = null; + +/** + * The preferences branch object for this instance. + * @private + */ +Object.defineProperty(Preferences, "_prefBranch", +{ + get: function _prefBranch() { + if (!this._cachedPrefBranch) { + let prefSvc = Services.prefs; + this._cachedPrefBranch = this._defaultBranch ? + prefSvc.getDefaultBranch(this._branchStr) : + prefSvc.getBranch(this._branchStr); + } + return this._cachedPrefBranch; + }, + enumerable: true, + configurable: true +}); + +// Constructor-based access (Preferences.get(...) and set) is preferred over +// instance-based access (new Preferences().get(...) and set) and when using the +// root preferences branch, as it's desirable not to allocate the extra object. +// But both forms are acceptable. +Preferences.prototype = Preferences; + +/** + * A cache of pref observers. + * + * We use this to remove observers when a caller calls Preferences::ignore. + * + * All Preferences instances share this object, because we want callers to be + * able to remove an observer using a different Preferences object than the one + * with which they added it. That means we have to identify the observers + * in this object by their complete pref name, not just their name relative to + * the root branch of the Preferences object with which they were created. + */ +var observers = []; + +function PrefObserver(prefName, callback, thisObject) { + this.prefName = prefName; + this.callback = callback; + this.thisObject = thisObject; +} + +PrefObserver.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]), + + observe: function(subject, topic, data) { + // The pref service only observes whole branches, but we only observe + // individual preferences, so we check here that the pref that changed + // is the exact one we're observing (and not some sub-pref on the branch). + if (data.indexOf(this.prefName) != 0) + return; + + if (typeof this.callback == "function") { + let prefValue = Preferences.get(data); + + if (this.thisObject) + this.callback.call(this.thisObject, prefValue); + else + this.callback(prefValue); + } + else // typeof this.callback == "object" (nsIObserver) + this.callback.observe(subject, topic, data); + } +}; + +function isObject(val) { + // We can't check for |val.constructor == Object| here, since the value + // might be from a different context whose Object constructor is not the same + // as ours, so instead we match based on the name of the constructor. + return (typeof val != "undefined" && val != null && typeof val == "object" && + val.constructor.name == "Object"); +} diff --git a/toolkit/modules/PrivateBrowsingUtils.jsm b/toolkit/modules/PrivateBrowsingUtils.jsm new file mode 100644 index 0000000000..6a84eef93f --- /dev/null +++ b/toolkit/modules/PrivateBrowsingUtils.jsm @@ -0,0 +1,103 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["PrivateBrowsingUtils"]; + +Components.utils.import("resource://gre/modules/Services.jsm"); + +const kAutoStartPref = "browser.privatebrowsing.autostart"; + +// This will be set to true when the PB mode is autostarted from the command +// line for the current session. +var gTemporaryAutoStartMode = false; + +const Cc = Components.classes; +const Ci = Components.interfaces; + +this.PrivateBrowsingUtils = { + // Rather than passing content windows to this function, please use + // isBrowserPrivate since it works with e10s. + isWindowPrivate: function pbu_isWindowPrivate(aWindow) { + if (!(aWindow instanceof Components.interfaces.nsIDOMChromeWindow)) { + dump("WARNING: content window passed to PrivateBrowsingUtils.isWindowPrivate. " + + "Use isContentWindowPrivate instead (but only for frame scripts).\n" + + new Error().stack); + } + + return this.privacyContextFromWindow(aWindow).usePrivateBrowsing; + }, + + // This should be used only in frame scripts. + isContentWindowPrivate: function pbu_isWindowPrivate(aWindow) { + return this.privacyContextFromWindow(aWindow).usePrivateBrowsing; + }, + + isBrowserPrivate: function(aBrowser) { + let chromeWin = aBrowser.ownerDocument.defaultView; + if (chromeWin.gMultiProcessBrowser) { + // In e10s we have to look at the chrome window's private + // browsing status since the only alternative is to check the + // content window, which is in another process. + return this.isWindowPrivate(chromeWin); + } + return this.privacyContextFromWindow(aBrowser.contentWindow).usePrivateBrowsing; + }, + + privacyContextFromWindow: function pbu_privacyContextFromWindow(aWindow) { + return aWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsILoadContext); + }, + + addToTrackingAllowlist(aURI) { + let pbmtpWhitelist = Cc["@mozilla.org/pbm-tp-whitelist;1"] + .getService(Ci.nsIPrivateBrowsingTrackingProtectionWhitelist); + pbmtpWhitelist.addToAllowList(aURI); + }, + + removeFromTrackingAllowlist(aURI) { + let pbmtpWhitelist = Cc["@mozilla.org/pbm-tp-whitelist;1"] + .getService(Ci.nsIPrivateBrowsingTrackingProtectionWhitelist); + pbmtpWhitelist.removeFromAllowList(aURI); + }, + + get permanentPrivateBrowsing() { + try { + return gTemporaryAutoStartMode || + Services.prefs.getBoolPref(kAutoStartPref); + } catch (e) { + // The pref does not exist + return false; + } + }, + + // These should only be used from internal code + enterTemporaryAutoStartMode: function pbu_enterTemporaryAutoStartMode() { + gTemporaryAutoStartMode = true; + }, + get isInTemporaryAutoStartMode() { + return gTemporaryAutoStartMode; + }, + + whenHiddenPrivateWindowReady: function pbu_whenHiddenPrivateWindowReady(cb) { + Components.utils.import("resource://gre/modules/Timer.jsm"); + + let win = Services.appShell.hiddenPrivateDOMWindow; + function isNotLoaded() { + return ["complete", "interactive"].indexOf(win.document.readyState) == -1; + } + if (isNotLoaded()) { + setTimeout(function poll() { + if (isNotLoaded()) { + setTimeout(poll, 100); + return; + } + cb(Services.appShell.hiddenPrivateDOMWindow); + }, 4); + } else { + cb(Services.appShell.hiddenPrivateDOMWindow); + } + } +}; + diff --git a/toolkit/modules/ProfileAge.jsm b/toolkit/modules/ProfileAge.jsm new file mode 100644 index 0000000000..f6030e2da3 --- /dev/null +++ b/toolkit/modules/ProfileAge.jsm @@ -0,0 +1,205 @@ +/* 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 = ["ProfileAge"]; + +const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components; + +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/osfile.jsm") +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-common/utils.js"); + +/** + * Profile access to times.json (eg, creation/reset time). + * This is separate from the provider to simplify testing and enable extraction + * to a shared location in the future. + */ +this.ProfileAge = function(profile, log) { + this.profilePath = profile || OS.Constants.Path.profileDir; + if (!this.profilePath) { + throw new Error("No profile directory."); + } + this._log = log || {"debug": function (s) { dump(s + "\n"); }}; +} +this.ProfileAge.prototype = { + /** + * There are three ways we can get our creation time: + * + * 1. From our own saved value (to avoid redundant work). + * 2. From the on-disk JSON file. + * 3. By calculating it from the filesystem. + * + * If we have to calculate, we write out the file; if we have + * to touch the file, we persist in-memory. + * + * @return a promise that resolves to the profile's creation time. + */ + get created() { + function onSuccess(times) { + if (times.created) { + return times.created; + } + return onFailure.call(this, null, times); + } + + function onFailure(err, times) { + return this.computeAndPersistCreated(times) + .then(function onSuccess(created) { + return created; + }.bind(this)); + } + + return this.getTimes() + .then(onSuccess.bind(this), + onFailure.bind(this)); + }, + + /** + * Explicitly make `file`, a filename, a full path + * relative to our profile path. + */ + getPath: function (file) { + return OS.Path.join(this.profilePath, file); + }, + + /** + * Return a promise which resolves to the JSON contents + * of the time file, using the already read value if possible. + */ + getTimes: function (file="times.json") { + if (this._times) { + return Promise.resolve(this._times); + } + return this.readTimes(file).then( + times => { + return this.times = times || {}; + } + ); + }, + + /** + * Return a promise which resolves to the JSON contents + * of the time file in this accessor's profile. + */ + readTimes: function (file="times.json") { + return CommonUtils.readJSON(this.getPath(file)); + }, + + /** + * Return a promise representing the writing of `contents` + * to `file` in the specified profile. + */ + writeTimes: function (contents, file="times.json") { + return CommonUtils.writeJSON(contents, this.getPath(file)); + }, + + /** + * Merge existing contents with a 'created' field, writing them + * to the specified file. Promise, naturally. + */ + computeAndPersistCreated: function (existingContents, file="times.json") { + let path = this.getPath(file); + function onOldest(oldest) { + let contents = existingContents || {}; + contents.created = oldest; + this._times = contents; + return this.writeTimes(contents, path) + .then(function onSuccess() { + return oldest; + }); + } + + return this.getOldestProfileTimestamp() + .then(onOldest.bind(this)); + }, + + /** + * Traverse the contents of the profile directory, finding the oldest file + * and returning its creation timestamp. + */ + getOldestProfileTimestamp: function () { + let self = this; + let oldest = Date.now() + 1000; + let iterator = new OS.File.DirectoryIterator(this.profilePath); + self._log.debug("Iterating over profile " + this.profilePath); + if (!iterator) { + throw new Error("Unable to fetch oldest profile entry: no profile iterator."); + } + + function onEntry(entry) { + function onStatSuccess(info) { + // OS.File doesn't seem to be behaving. See Bug 827148. + // Let's do the best we can. This whole function is defensive. + let date = info.winBirthDate || info.macBirthDate; + if (!date || !date.getTime()) { + // OS.File will only return file creation times of any kind on Mac + // and Windows, where birthTime is defined. + // That means we're unable to function on Linux, so we use mtime + // instead. + self._log.debug("No birth date. Using mtime."); + date = info.lastModificationDate; + } + + if (date) { + let timestamp = date.getTime(); + self._log.debug("Using date: " + entry.path + " = " + date); + if (timestamp < oldest) { + oldest = timestamp; + } + } + } + + function onStatFailure(e) { + // Never mind. + self._log.debug("Stat failure", e); + } + + return OS.File.stat(entry.path) + .then(onStatSuccess, onStatFailure); + } + + let promise = iterator.forEach(onEntry); + + function onSuccess() { + iterator.close(); + return oldest; + } + + function onFailure(reason) { + iterator.close(); + throw new Error("Unable to fetch oldest profile entry: " + reason); + } + + return promise.then(onSuccess, onFailure); + }, + + /** + * Record (and persist) when a profile reset happened. We just store a + * single value - the timestamp of the most recent reset - but there is scope + * to keep a list of reset times should our health-reporter successor + * be able to make use of that. + * Returns a promise that is resolved once the file has been written. + */ + recordProfileReset: function (time=Date.now(), file="times.json") { + return this.getTimes(file).then( + times => { + times.reset = time; + return this.writeTimes(times, file); + } + ); + }, + + /* Returns a promise that resolves to the time the profile was reset, + * or undefined if not recorded. + */ + get reset() { + return this.getTimes().then( + times => times.reset + ); + }, +} diff --git a/toolkit/modules/Promise-backend.js b/toolkit/modules/Promise-backend.js new file mode 100644 index 0000000000..712a8b4f5c --- /dev/null +++ b/toolkit/modules/Promise-backend.js @@ -0,0 +1,970 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ +/* 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 implementation file is imported by the Promise.jsm module, and as a + * special case by the debugger server. To support chrome debugging, the + * debugger server needs to have all its code in one global, so it must use + * loadSubScript directly. + * + * In the general case, this script should be used by importing Promise.jsm: + * + * Components.utils.import("resource://gre/modules/Promise.jsm"); + * + * More documentation can be found in the Promise.jsm module. + */ + +// Globals + +// Obtain an instance of Cu. How this instance is obtained depends on how this +// file is loaded. +// +// This file can be loaded in three different ways: +// 1. As a CommonJS module, by Loader.jsm, on the main thread. +// 2. As a CommonJS module, by worker-loader.js, on a worker thread. +// 3. As a subscript, by Promise.jsm, on the main thread. +// +// If require is defined, the file is loaded as a CommonJS module. Components +// will not be defined in that case, but we can obtain an instance of Cu from +// the chrome module. Otherwise, this file is loaded as a subscript, and we can +// obtain an instance of Cu from Components directly. +// +// If the file is loaded as a CommonJS module on a worker thread, the instance +// of Cu obtained from the chrome module will be null. The reason for this is +// that Components is not defined in worker threads, so no instance of Cu can +// be obtained. + +var Cu = this.require ? require("chrome").Cu : Components.utils; +var Cc = this.require ? require("chrome").Cc : Components.classes; +var Ci = this.require ? require("chrome").Ci : Components.interfaces; +// If we can access Components, then we use it to capture an async +// parent stack trace; see scheduleWalkerLoop. However, as it might +// not be available (see above), users of this must check it first. +var Components_ = this.require ? require("chrome").components : Components; + +// If Cu is defined, use it to lazily define the FinalizationWitnessService. +if (Cu) { + Cu.import("resource://gre/modules/Services.jsm"); + Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + + XPCOMUtils.defineLazyServiceGetter(this, "FinalizationWitnessService", + "@mozilla.org/toolkit/finalizationwitness;1", + "nsIFinalizationWitnessService"); + + // For now, we're worried about add-ons using Promises with CPOWs, so we'll + // permit them in this scope, but this support will go away soon. + Cu.permitCPOWsInScope(this); +} + +const STATUS_PENDING = 0; +const STATUS_RESOLVED = 1; +const STATUS_REJECTED = 2; + +// This N_INTERNALS name allow internal properties of the Promise to be +// accessed only by this module, while still being visible on the object +// manually when using a debugger. This doesn't strictly guarantee that the +// properties are inaccessible by other code, but provide enough protection to +// avoid using them by mistake. +const salt = Math.floor(Math.random() * 100); +const N_INTERNALS = "{private:internals:" + salt + "}"; + +// We use DOM Promise for scheduling the walker loop. +const DOMPromise = Cu ? Promise : null; + +// Warn-upon-finalization mechanism +// +// One of the difficult problems with promises is locating uncaught +// rejections. We adopt the following strategy: if a promise is rejected +// at the time of its garbage-collection *and* if the promise is at the +// end of a promise chain (i.e. |thatPromise.then| has never been +// called), then we print a warning. +// +// let deferred = Promise.defer(); +// let p = deferred.promise.then(); +// deferred.reject(new Error("I am un uncaught error")); +// deferred = null; +// p = null; +// +// In this snippet, since |deferred.promise| is not the last in the +// chain, no error will be reported for that promise. However, since +// |p| is the last promise in the chain, the error will be reported +// for |p|. +// +// Note that this may, in some cases, cause an error to be reported more +// than once. For instance, consider: +// +// let deferred = Promise.defer(); +// let p1 = deferred.promise.then(); +// let p2 = deferred.promise.then(); +// deferred.reject(new Error("I am an uncaught error")); +// p1 = p2 = deferred = null; +// +// In this snippet, the error is reported both by p1 and by p2. +// + +var PendingErrors = { + // An internal counter, used to generate unique id. + _counter: 0, + // Functions registered to be notified when a pending error + // is reported as uncaught. + _observers: new Set(), + _map: new Map(), + + /** + * Initialize PendingErrors + */ + init: function() { + Services.obs.addObserver(function observe(aSubject, aTopic, aValue) { + PendingErrors.report(aValue); + }, "promise-finalization-witness", false); + }, + + /** + * Register an error as tracked. + * + * @return The unique identifier of the error. + */ + register: function(error) { + let id = "pending-error-" + (this._counter++); + // + // At this stage, ideally, we would like to store the error itself + // and delay any treatment until we are certain that we will need + // to report that error. However, in the (unlikely but possible) + // case the error holds a reference to the promise itself, doing so + // would prevent the promise from being garbabe-collected, which + // would both cause a memory leak and ensure that we cannot report + // the uncaught error. + // + // To avoid this situation, we rather extract relevant data from + // the error and further normalize it to strings. + // + let value = { + date: new Date(), + message: "" + error, + fileName: null, + stack: null, + lineNumber: null + }; + try { // Defend against non-enumerable values + if (error && error instanceof Ci.nsIException) { + // nsIException does things a little differently. + try { + // For starters |.toString()| does not only contain the message, but + // also the top stack frame, and we don't really want that. + value.message = error.message; + } catch (ex) { + // Ignore field + } + try { + // All lowercase filename. ;) + value.fileName = error.filename; + } catch (ex) { + // Ignore field + } + try { + value.lineNumber = error.lineNumber; + } catch (ex) { + // Ignore field + } + } else if (typeof error == "object" && error) { + for (let k of ["fileName", "stack", "lineNumber"]) { + try { // Defend against fallible getters and string conversions + let v = error[k]; + value[k] = v ? ("" + v) : null; + } catch (ex) { + // Ignore field + } + } + } + + if (!value.stack) { + // |error| is not an Error (or Error-alike). Try to figure out the stack. + let stack = null; + if (error && error.location && + error.location instanceof Ci.nsIStackFrame) { + // nsIException has full stack frames in the |.location| member. + stack = error.location; + } else { + // Components.stack to the rescue! + stack = Components_.stack; + // Remove those top frames that refer to Promise.jsm. + while (stack) { + if (!stack.filename.endsWith("/Promise.jsm")) { + break; + } + stack = stack.caller; + } + } + if (stack) { + let frames = []; + while (stack) { + frames.push(stack); + stack = stack.caller; + } + value.stack = frames.join("\n"); + } + } + } catch (ex) { + // Ignore value + } + this._map.set(id, value); + return id; + }, + + /** + * Notify all observers that a pending error is now uncaught. + * + * @param id The identifier of the pending error, as returned by + * |register|. + */ + report: function(id) { + let value = this._map.get(id); + if (!value) { + return; // The error has already been reported + } + this._map.delete(id); + for (let obs of this._observers.values()) { + obs(value); + } + }, + + /** + * Mark all pending errors are uncaught, notify the observers. + */ + flush: function() { + // Since we are going to modify the map while walking it, + // let's copying the keys first. + for (let key of Array.from(this._map.keys())) { + this.report(key); + } + }, + + /** + * Stop tracking an error, as this error has been caught, + * eventually. + */ + unregister: function(id) { + this._map.delete(id); + }, + + /** + * Add an observer notified when an error is reported as uncaught. + * + * @param {function} observer A function notified when an error is + * reported as uncaught. Its arguments are + * {message, date, fileName, stack, lineNumber} + * All arguments are optional. + */ + addObserver: function(observer) { + this._observers.add(observer); + }, + + /** + * Remove an observer added with addObserver + */ + removeObserver: function(observer) { + this._observers.delete(observer); + }, + + /** + * Remove all the observers added with addObserver + */ + removeAllObservers: function() { + this._observers.clear(); + } +}; + +// Initialize the warn-upon-finalization mechanism if and only if Cu is defined. +// Otherwise, FinalizationWitnessService won't be defined (see above). +if (Cu) { + PendingErrors.init(); +} + +// Default mechanism for displaying errors +PendingErrors.addObserver(function(details) { + const generalDescription = "A promise chain failed to handle a rejection." + + " Did you forget to '.catch', or did you forget to 'return'?\nSee" + + " https://developer.mozilla.org/Mozilla/JavaScript_code_modules/Promise.jsm/Promise\n\n"; + + let error = Cc['@mozilla.org/scripterror;1'].createInstance(Ci.nsIScriptError); + if (!error || !Services.console) { + // Too late during shutdown to use the nsIConsole + dump("*************************\n"); + dump(generalDescription); + dump("On: " + details.date + "\n"); + dump("Full message: " + details.message + "\n"); + dump("Full stack: " + (details.stack||"not available") + "\n"); + dump("*************************\n"); + return; + } + let message = details.message; + if (details.stack) { + message += "\nFull Stack: " + details.stack; + } + error.init( + /* message*/ generalDescription + + "Date: " + details.date + "\nFull Message: " + message, + /* sourceName*/ details.fileName, + /* sourceLine*/ details.lineNumber?("" + details.lineNumber):0, + /* lineNumber*/ details.lineNumber || 0, + /* columnNumber*/ 0, + /* flags*/ Ci.nsIScriptError.errorFlag, + /* category*/ "chrome javascript"); + Services.console.logMessage(error); +}); + + +// Additional warnings for developers +// +// The following error types are considered programmer errors, which should be +// reported (possibly redundantly) so as to let programmers fix their code. +const ERRORS_TO_REPORT = ["EvalError", "RangeError", "ReferenceError", "TypeError"]; + +// Promise + +/** + * The Promise constructor. Creates a new promise given an executor callback. + * The executor callback is called with the resolve and reject handlers. + * + * @param aExecutor + * The callback that will be called with resolve and reject. + */ +this.Promise = function Promise(aExecutor) +{ + if (typeof(aExecutor) != "function") { + throw new TypeError("Promise constructor must be called with an executor."); + } + + /* + * Object holding all of our internal values we associate with the promise. + */ + Object.defineProperty(this, N_INTERNALS, { value: { + /* + * Internal status of the promise. This can be equal to STATUS_PENDING, + * STATUS_RESOLVED, or STATUS_REJECTED. + */ + status: STATUS_PENDING, + + /* + * When the status property is STATUS_RESOLVED, this contains the final + * resolution value, that cannot be a promise, because resolving with a + * promise will cause its state to be eventually propagated instead. When the + * status property is STATUS_REJECTED, this contains the final rejection + * reason, that could be a promise, even if this is uncommon. + */ + value: undefined, + + /* + * Array of Handler objects registered by the "then" method, and not processed + * yet. Handlers are removed when the promise is resolved or rejected. + */ + handlers: [], + + /** + * When the status property is STATUS_REJECTED and until there is + * a rejection callback, this contains an array + * - {string} id An id for use with |PendingErrors|; + * - {FinalizationWitness} witness A witness broadcasting |id| on + * notification "promise-finalization-witness". + */ + witness: undefined + }}); + + Object.seal(this); + + let resolve = PromiseWalker.completePromise + .bind(PromiseWalker, this, STATUS_RESOLVED); + let reject = PromiseWalker.completePromise + .bind(PromiseWalker, this, STATUS_REJECTED); + + try { + aExecutor.call(undefined, resolve, reject); + } catch (ex) { + reject(ex); + } +} + +/** + * Calls one of the provided functions as soon as this promise is either + * resolved or rejected. A new promise is returned, whose state evolves + * depending on this promise and the provided callback functions. + * + * The appropriate callback is always invoked after this method returns, even + * if this promise is already resolved or rejected. You can also call the + * "then" method multiple times on the same promise, and the callbacks will be + * invoked in the same order as they were registered. + * + * @param aOnResolve + * If the promise is resolved, this function is invoked with the + * resolution value of the promise as its only argument, and the + * outcome of the function determines the state of the new promise + * returned by the "then" method. In case this parameter is not a + * function (usually "null"), the new promise returned by the "then" + * method is resolved with the same value as the original promise. + * + * @param aOnReject + * If the promise is rejected, this function is invoked with the + * rejection reason of the promise as its only argument, and the + * outcome of the function determines the state of the new promise + * returned by the "then" method. In case this parameter is not a + * function (usually left "undefined"), the new promise returned by the + * "then" method is rejected with the same reason as the original + * promise. + * + * @return A new promise that is initially pending, then assumes a state that + * depends on the outcome of the invoked callback function: + * - If the callback returns a value that is not a promise, including + * "undefined", the new promise is resolved with this resolution + * value, even if the original promise was rejected. + * - If the callback throws an exception, the new promise is rejected + * with the exception as the rejection reason, even if the original + * promise was resolved. + * - If the callback returns a promise, the new promise will + * eventually assume the same state as the returned promise. + * + * @note If the aOnResolve callback throws an exception, the aOnReject + * callback is not invoked. You can register a rejection callback on + * the returned promise instead, to process any exception occurred in + * either of the callbacks registered on this promise. + */ +Promise.prototype.then = function (aOnResolve, aOnReject) +{ + let handler = new Handler(this, aOnResolve, aOnReject); + this[N_INTERNALS].handlers.push(handler); + + // Ensure the handler is scheduled for processing if this promise is already + // resolved or rejected. + if (this[N_INTERNALS].status != STATUS_PENDING) { + + // This promise is not the last in the chain anymore. Remove any watchdog. + if (this[N_INTERNALS].witness != null) { + let [id, witness] = this[N_INTERNALS].witness; + this[N_INTERNALS].witness = null; + witness.forget(); + PendingErrors.unregister(id); + } + + PromiseWalker.schedulePromise(this); + } + + return handler.nextPromise; +}; + +/** + * Invokes `promise.then` with undefined for the resolve handler and a given + * reject handler. + * + * @param aOnReject + * The rejection handler. + * + * @return A new pending promise returned. + * + * @see Promise.prototype.then + */ +Promise.prototype.catch = function (aOnReject) +{ + return this.then(undefined, aOnReject); +}; + +/** + * Creates a new pending promise and provides methods to resolve or reject it. + * + * @return A new object, containing the new promise in the "promise" property, + * and the methods to change its state in the "resolve" and "reject" + * properties. See the Deferred documentation for details. + */ +Promise.defer = function () +{ + return new Deferred(); +}; + +/** + * Creates a new promise resolved with the specified value, or propagates the + * state of an existing promise. + * + * @param aValue + * If this value is not a promise, including "undefined", it becomes + * the resolution value of the returned promise. If this value is a + * promise, then the returned promise will eventually assume the same + * state as the provided promise. + * + * @return A promise that can be pending, resolved, or rejected. + */ +Promise.resolve = function (aValue) +{ + if (aValue && typeof(aValue) == "function" && aValue.isAsyncFunction) { + throw new TypeError( + "Cannot resolve a promise with an async function. " + + "You should either invoke the async function first " + + "or use 'Task.spawn' instead of 'Task.async' to start " + + "the Task and return its promise."); + } + + if (aValue instanceof Promise) { + return aValue; + } + + return new Promise((aResolve) => aResolve(aValue)); +}; + +/** + * Creates a new promise rejected with the specified reason. + * + * @param aReason + * The rejection reason for the returned promise. Although the reason + * can be "undefined", it is generally an Error object, like in + * exception handling. + * + * @return A rejected promise. + * + * @note The aReason argument should not be a promise. Using a rejected + * promise for the value of aReason would make the rejection reason + * equal to the rejected promise itself, and not its rejection reason. + */ +Promise.reject = function (aReason) +{ + return new Promise((_, aReject) => aReject(aReason)); +}; + +/** + * Returns a promise that is resolved or rejected when all values are + * resolved or any is rejected. + * + * @param aValues + * Iterable of promises that may be pending, resolved, or rejected. When + * all are resolved or any is rejected, the returned promise will be + * resolved or rejected as well. + * + * @return A new promise that is fulfilled when all values are resolved or + * that is rejected when any of the values are rejected. Its + * resolution value will be an array of all resolved values in the + * given order, or undefined if aValues is an empty array. The reject + * reason will be forwarded from the first promise in the list of + * given promises to be rejected. + */ +Promise.all = function (aValues) +{ + if (aValues == null || typeof(aValues[Symbol.iterator]) != "function") { + throw new Error("Promise.all() expects an iterable."); + } + + return new Promise((resolve, reject) => { + let values = Array.isArray(aValues) ? aValues : [...aValues]; + let countdown = values.length; + let resolutionValues = new Array(countdown); + + if (!countdown) { + resolve(resolutionValues); + return; + } + + function checkForCompletion(aValue, aIndex) { + resolutionValues[aIndex] = aValue; + if (--countdown === 0) { + resolve(resolutionValues); + } + } + + for (let i = 0; i < values.length; i++) { + let index = i; + let value = values[i]; + let resolver = val => checkForCompletion(val, index); + + if (value && typeof(value.then) == "function") { + value.then(resolver, reject); + } else { + // Given value is not a promise, forward it as a resolution value. + resolver(value); + } + } + }); +}; + +/** + * Returns a promise that is resolved or rejected when the first value is + * resolved or rejected, taking on the value or reason of that promise. + * + * @param aValues + * Iterable of values or promises that may be pending, resolved, or + * rejected. When any is resolved or rejected, the returned promise will + * be resolved or rejected as to the given value or reason. + * + * @return A new promise that is fulfilled when any values are resolved or + * rejected. Its resolution value will be forwarded from the resolution + * value or rejection reason. + */ +Promise.race = function (aValues) +{ + if (aValues == null || typeof(aValues[Symbol.iterator]) != "function") { + throw new Error("Promise.race() expects an iterable."); + } + + return new Promise((resolve, reject) => { + for (let value of aValues) { + Promise.resolve(value).then(resolve, reject); + } + }); +}; + +Promise.Debugging = { + /** + * Add an observer notified when an error is reported as uncaught. + * + * @param {function} observer A function notified when an error is + * reported as uncaught. Its arguments are + * {message, date, fileName, stack, lineNumber} + * All arguments are optional. + */ + addUncaughtErrorObserver: function(observer) { + PendingErrors.addObserver(observer); + }, + + /** + * Remove an observer added with addUncaughtErrorObserver + * + * @param {function} An observer registered with + * addUncaughtErrorObserver. + */ + removeUncaughtErrorObserver: function(observer) { + PendingErrors.removeObserver(observer); + }, + + /** + * Remove all the observers added with addUncaughtErrorObserver + */ + clearUncaughtErrorObservers: function() { + PendingErrors.removeAllObservers(); + }, + + /** + * Force all pending errors to be reported immediately as uncaught. + * Note that this may cause some false positives. + */ + flushUncaughtErrors: function() { + PendingErrors.flush(); + }, +}; +Object.freeze(Promise.Debugging); + +Object.freeze(Promise); + +// If module is defined, this file is loaded as a CommonJS module. Make sure +// Promise is exported in that case. +if (this.module) { + module.exports = Promise; +} + +// PromiseWalker + +/** + * This singleton object invokes the handlers registered on resolved and + * rejected promises, ensuring that processing is not recursive and is done in + * the same order as registration occurred on each promise. + * + * There is no guarantee on the order of execution of handlers registered on + * different promises. + */ +this.PromiseWalker = { + /** + * Singleton array of all the unprocessed handlers currently registered on + * resolved or rejected promises. Handlers are removed from the array as soon + * as they are processed. + */ + handlers: [], + + /** + * Called when a promise needs to change state to be resolved or rejected. + * + * @param aPromise + * Promise that needs to change state. If this is already resolved or + * rejected, this method has no effect. + * @param aStatus + * New desired status, either STATUS_RESOLVED or STATUS_REJECTED. + * @param aValue + * Associated resolution value or rejection reason. + */ + completePromise: function (aPromise, aStatus, aValue) + { + // Do nothing if the promise is already resolved or rejected. + if (aPromise[N_INTERNALS].status != STATUS_PENDING) { + return; + } + + // Resolving with another promise will cause this promise to eventually + // assume the state of the provided promise. + if (aStatus == STATUS_RESOLVED && aValue && + typeof(aValue.then) == "function") { + aValue.then(this.completePromise.bind(this, aPromise, STATUS_RESOLVED), + this.completePromise.bind(this, aPromise, STATUS_REJECTED)); + return; + } + + // Change the promise status and schedule our handlers for processing. + aPromise[N_INTERNALS].status = aStatus; + aPromise[N_INTERNALS].value = aValue; + if (aPromise[N_INTERNALS].handlers.length > 0) { + this.schedulePromise(aPromise); + } else if (Cu && aStatus == STATUS_REJECTED) { + // This is a rejection and the promise is the last in the chain. + // For the time being we therefore have an uncaught error. + let id = PendingErrors.register(aValue); + let witness = + FinalizationWitnessService.make("promise-finalization-witness", id); + aPromise[N_INTERNALS].witness = [id, witness]; + } + }, + + /** + * Sets up the PromiseWalker loop to start on the next tick of the event loop + */ + scheduleWalkerLoop: function() + { + this.walkerLoopScheduled = true; + + // If this file is loaded on a worker thread, DOMPromise will not behave as + // expected: because native promises are not aware of nested event loops + // created by the debugger, their respective handlers will not be called + // until after leaving the nested event loop. The debugger server relies + // heavily on the use promises, so this could cause the debugger to hang. + // + // To work around this problem, any use of native promises in the debugger + // server should be avoided when it is running on a worker thread. Because + // it is still necessary to be able to schedule runnables on the event + // queue, the worker loader defines the function setImmediate as a + // per-module global for this purpose. + // + // If Cu is defined, this file is loaded on the main thread. Otherwise, it + // is loaded on the worker thread. + if (Cu) { + let stack = Components_ ? Components_.stack : null; + if (stack) { + DOMPromise.resolve().then(() => { + Cu.callFunctionWithAsyncStack(this.walkerLoop.bind(this), stack, + "Promise") + }); + } else { + DOMPromise.resolve().then(() => this.walkerLoop()); + } + } else { + setImmediate(this.walkerLoop); + } + }, + + /** + * Schedules the resolution or rejection handlers registered on the provided + * promise for processing. + * + * @param aPromise + * Resolved or rejected promise whose handlers should be processed. It + * is expected that this promise has at least one handler to process. + */ + schedulePromise: function (aPromise) + { + // Migrate the handlers from the provided promise to the global list. + for (let handler of aPromise[N_INTERNALS].handlers) { + this.handlers.push(handler); + } + aPromise[N_INTERNALS].handlers.length = 0; + + // Schedule the walker loop on the next tick of the event loop. + if (!this.walkerLoopScheduled) { + this.scheduleWalkerLoop(); + } + }, + + /** + * Indicates whether the walker loop is currently scheduled for execution on + * the next tick of the event loop. + */ + walkerLoopScheduled: false, + + /** + * Processes all the known handlers during this tick of the event loop. This + * eager processing is done to avoid unnecessarily exiting and re-entering the + * JavaScript context for each handler on a resolved or rejected promise. + * + * This function is called with "this" bound to the PromiseWalker object. + */ + walkerLoop: function () + { + // If there is more than one handler waiting, reschedule the walker loop + // immediately. Otherwise, use walkerLoopScheduled to tell schedulePromise() + // to reschedule the loop if it adds more handlers to the queue. This makes + // this walker resilient to the case where one handler does not return, but + // starts a nested event loop. In that case, the newly scheduled walker will + // take over. In the common case, the newly scheduled walker will be invoked + // after this one has returned, with no actual handler to process. This + // small overhead is required to make nested event loops work correctly, but + // occurs at most once per resolution chain, thus having only a minor + // impact on overall performance. + if (this.handlers.length > 1) { + this.scheduleWalkerLoop(); + } else { + this.walkerLoopScheduled = false; + } + + // Process all the known handlers eagerly. + while (this.handlers.length > 0) { + this.handlers.shift().process(); + } + }, +}; + +// Bind the function to the singleton once. +PromiseWalker.walkerLoop = PromiseWalker.walkerLoop.bind(PromiseWalker); + +// Deferred + +/** + * Returned by "Promise.defer" to provide a new promise along with methods to + * change its state. + */ +function Deferred() +{ + this.promise = new Promise((aResolve, aReject) => { + this.resolve = aResolve; + this.reject = aReject; + }); + Object.freeze(this); +} + +Deferred.prototype = { + /** + * A newly created promise, initially in the pending state. + */ + promise: null, + + /** + * Resolves the associated promise with the specified value, or propagates the + * state of an existing promise. If the associated promise has already been + * resolved or rejected, this method does nothing. + * + * This function is bound to its associated promise when "Promise.defer" is + * called, and can be called with any value of "this". + * + * @param aValue + * If this value is not a promise, including "undefined", it becomes + * the resolution value of the associated promise. If this value is a + * promise, then the associated promise will eventually assume the same + * state as the provided promise. + * + * @note Calling this method with a pending promise as the aValue argument, + * and then calling it again with another value before the promise is + * resolved or rejected, has unspecified behavior and should be avoided. + */ + resolve: null, + + /** + * Rejects the associated promise with the specified reason. If the promise + * has already been resolved or rejected, this method does nothing. + * + * This function is bound to its associated promise when "Promise.defer" is + * called, and can be called with any value of "this". + * + * @param aReason + * The rejection reason for the associated promise. Although the + * reason can be "undefined", it is generally an Error object, like in + * exception handling. + * + * @note The aReason argument should not generally be a promise. In fact, + * using a rejected promise for the value of aReason would make the + * rejection reason equal to the rejected promise itself, not to the + * rejection reason of the rejected promise. + */ + reject: null, +}; + +// Handler + +/** + * Handler registered on a promise by the "then" function. + */ +function Handler(aThisPromise, aOnResolve, aOnReject) +{ + this.thisPromise = aThisPromise; + this.onResolve = aOnResolve; + this.onReject = aOnReject; + this.nextPromise = new Promise(() => {}); +} + +Handler.prototype = { + /** + * Promise on which the "then" method was called. + */ + thisPromise: null, + + /** + * Unmodified resolution handler provided to the "then" method. + */ + onResolve: null, + + /** + * Unmodified rejection handler provided to the "then" method. + */ + onReject: null, + + /** + * New promise that will be returned by the "then" method. + */ + nextPromise: null, + + /** + * Called after thisPromise is resolved or rejected, invokes the appropriate + * callback and propagates the result to nextPromise. + */ + process: function() + { + // The state of this promise is propagated unless a handler is defined. + let nextStatus = this.thisPromise[N_INTERNALS].status; + let nextValue = this.thisPromise[N_INTERNALS].value; + + try { + // If a handler is defined for either resolution or rejection, invoke it + // to determine the state of the next promise, that will be resolved with + // the returned value, that can also be another promise. + if (nextStatus == STATUS_RESOLVED) { + if (typeof(this.onResolve) == "function") { + nextValue = this.onResolve.call(undefined, nextValue); + } + } else if (typeof(this.onReject) == "function") { + nextValue = this.onReject.call(undefined, nextValue); + nextStatus = STATUS_RESOLVED; + } + } catch (ex) { + + // An exception has occurred in the handler. + + if (ex && typeof ex == "object" && "name" in ex && + ERRORS_TO_REPORT.indexOf(ex.name) != -1) { + + // We suspect that the exception is a programmer error, so we now + // display it using dump(). Note that we do not use Cu.reportError as + // we assume that this is a programming error, so we do not want end + // users to see it. Also, if the programmer handles errors correctly, + // they will either treat the error or log them somewhere. + + dump("*************************\n"); + dump("A coding exception was thrown in a Promise " + + ((nextStatus == STATUS_RESOLVED) ? "resolution":"rejection") + + " callback.\n"); + dump("See https://developer.mozilla.org/Mozilla/JavaScript_code_modules/Promise.jsm/Promise\n\n"); + dump("Full message: " + ex + "\n"); + dump("Full stack: " + (("stack" in ex)?ex.stack:"not available") + "\n"); + dump("*************************\n"); + + } + + // Additionally, reject the next promise. + nextStatus = STATUS_REJECTED; + nextValue = ex; + } + + // Propagate the newly determined state to the next promise. + PromiseWalker.completePromise(this.nextPromise, nextStatus, nextValue); + }, +}; diff --git a/toolkit/modules/Promise.jsm b/toolkit/modules/Promise.jsm new file mode 100644 index 0000000000..0df4977aff --- /dev/null +++ b/toolkit/modules/Promise.jsm @@ -0,0 +1,101 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ +/* 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 = [ + "Promise" +]; + +/** + * This module implements the "promise" construct, according to the + * "Promises/A+" proposal as known in April 2013, documented here: + * + * <http://promises-aplus.github.com/promises-spec/> + * + * A promise is an object representing a value that may not be available yet. + * Internally, a promise can be in one of three states: + * + * - Pending, when the final value is not available yet. This is the only state + * that may transition to one of the other two states. + * + * - Resolved, when and if the final value becomes available. A resolution + * value becomes permanently associated with the promise. This may be any + * value, including "undefined". + * + * - Rejected, if an error prevented the final value from being determined. A + * rejection reason becomes permanently associated with the promise. This may + * be any value, including "undefined", though it is generally an Error + * object, like in exception handling. + * + * A reference to an existing promise may be received by different means, for + * example as the return value of a call into an asynchronous API. In this + * case, the state of the promise can be observed but not directly controlled. + * + * To observe the state of a promise, its "then" method must be used. This + * method registers callback functions that are called as soon as the promise is + * either resolved or rejected. The method returns a new promise, that in turn + * is resolved or rejected depending on the state of the original promise and on + * the behavior of the callbacks. For example, unhandled exceptions in the + * callbacks cause the new promise to be rejected, even if the original promise + * is resolved. See the documentation of the "then" method for details. + * + * Promises may also be created using the "Promise.defer" function, the main + * entry point of this module. The function, along with the new promise, + * returns separate methods to change its state to be resolved or rejected. + * See the documentation of the "Deferred" prototype for details. + * + * ----------------------------------------------------------------------------- + * + * Cu.import("resource://gre/modules/Promise.jsm"); + * + * // This function creates and returns a new promise. + * function promiseValueAfterTimeout(aValue, aTimeout) + * { + * let deferred = Promise.defer(); + * + * try { + * // An asynchronous operation will trigger the resolution of the promise. + * // In this example, we don't have a callback that triggers a rejection. + * do_timeout(aTimeout, function () { + * deferred.resolve(aValue); + * }); + * } catch (ex) { + * // Generally, functions returning promises propagate exceptions through + * // the returned promise, though they may also choose to fail early. + * deferred.reject(ex); + * } + * + * // We don't return the deferred to the caller, but only the contained + * // promise, so that the caller cannot accidentally change its state. + * return deferred.promise; + * } + * + * // This code uses the promise returned be the function above. + * let promise = promiseValueAfterTimeout("Value", 1000); + * + * let newPromise = promise.then(function onResolve(aValue) { + * do_print("Resolved with this value: " + aValue); + * }, function onReject(aReason) { + * do_print("Rejected with this reason: " + aReason); + * }); + * + * // Unexpected errors should always be reported at the end of a promise chain. + * newPromise.then(null, Components.utils.reportError); + * + * ----------------------------------------------------------------------------- + */ + +// These constants must be defined on the "this" object for them to be visible +// by subscripts in B2G, since "this" does not match the global scope. +this.Cc = Components.classes; +this.Ci = Components.interfaces; +this.Cu = Components.utils; +this.Cr = Components.results; + +this.Cc["@mozilla.org/moz/jssubscript-loader;1"] + .getService(this.Ci.mozIJSSubScriptLoader) + .loadSubScript("resource://gre/modules/Promise-backend.js", this); diff --git a/toolkit/modules/PromiseMessage.jsm b/toolkit/modules/PromiseMessage.jsm new file mode 100644 index 0000000000..f232d074b3 --- /dev/null +++ b/toolkit/modules/PromiseMessage.jsm @@ -0,0 +1,33 @@ +/* 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 = ["PromiseMessage"]; + +var msgId = 0; + +var PromiseMessage = { + send(messageManager, name, data = {}) { + const id = `${name}_${msgId++}`; + + // Make a copy of data so that the caller doesn't see us setting 'id': + // To a new object, assign data's props, and then override the id. + const dataCopy = Object.assign({}, data, {id}); + + // Send the message. + messageManager.sendAsyncMessage(name, dataCopy); + + // Return a promise that resolves when we get a reply (a message of the same name). + return new Promise(resolve => { + messageManager.addMessageListener(name, function listener(reply) { + if (reply.data.id !== id) { + return; + } + messageManager.removeMessageListener(name, listener); + resolve(reply); + }); + }); + } +}; diff --git a/toolkit/modules/PromiseUtils.jsm b/toolkit/modules/PromiseUtils.jsm new file mode 100644 index 0000000000..8ceebced32 --- /dev/null +++ b/toolkit/modules/PromiseUtils.jsm @@ -0,0 +1,53 @@ +/* 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 = ["PromiseUtils"]; + +Components.utils.import("resource://gre/modules/Timer.jsm"); + +this.PromiseUtils = { + /* + * Creates a new pending Promise and provide methods to resolve and reject this Promise. + * + * @return {Deferred} an object consisting of a pending Promise "promise" + * and methods "resolve" and "reject" to change its state. + */ + defer : function() { + return new Deferred(); + }, +} + +/** + * The definition of Deferred object which is returned by PromiseUtils.defer(), + * It contains a Promise and methods to resolve/reject it. + */ +function Deferred() { + /* A method to resolve the associated Promise with the value passed. + * If the promise is already settled it does nothing. + * + * @param {anything} value : This value is used to resolve the promise + * If the value is a Promise then the associated promise assumes the state + * of Promise passed as value. + */ + this.resolve = null; + + /* A method to reject the assocaited Promise with the value passed. + * If the promise is already settled it does nothing. + * + * @param {anything} reason: The reason for the rejection of the Promise. + * Generally its an Error object. If however a Promise is passed, then the Promise + * itself will be the reason for rejection no matter the state of the Promise. + */ + this.reject = null; + + /* A newly created Pomise object. + * Initially in pending state. + */ + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); +} diff --git a/toolkit/modules/PropertyListUtils.jsm b/toolkit/modules/PropertyListUtils.jsm new file mode 100644 index 0000000000..10a9f8f016 --- /dev/null +++ b/toolkit/modules/PropertyListUtils.jsm @@ -0,0 +1,820 @@ +/* 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/. */ + +/** + * Module for reading Property Lists (.plist) files + * ------------------------------------------------ + * This module functions as a reader for Apple Property Lists (.plist files). + * It supports both binary and xml formatted property lists. It does not + * support the legacy ASCII format. Reading of Cocoa's Keyed Archives serialized + * to binary property lists isn't supported either. + * + * Property Lists objects are represented by standard JS and Mozilla types, + * namely: + * + * XML type Cocoa Class Returned type(s) + * -------------------------------------------------------------------------- + * <true/> / <false/> NSNumber TYPE_PRIMITIVE boolean + * <integer> / <real> NSNumber TYPE_PRIMITIVE number + * TYPE_INT64 String [1] + * Not Available NSNull TYPE_PRIMITIVE null [2] + * TYPE_PRIMITIVE undefined [3] + * <date/> NSDate TYPE_DATE Date + * <data/> NSData TYPE_UINT8_ARRAY Uint8Array + * <array/> NSArray TYPE_ARRAY Array + * Not Available NSSet TYPE_ARRAY Array [2][4] + * <dict/> NSDictionary TYPE_DICTIONARY Map + * + * Use PropertyListUtils.getObjectType to detect the type of a Property list + * object. + * + * ------------- + * 1) Property lists supports storing U/Int64 numbers, while JS can only handle + * numbers that are in this limits of float-64 (±2^53). For numbers that + * do not outbound this limits, simple primitive number are always used. + * Otherwise, a String object. + * 2) About NSNull and NSSet values: While the xml format has no support for + * representing null and set values, the documentation for the binary format + * states that it supports storing both types. However, the Cocoa APIs for + * serializing property lists do not seem to support either types (test with + * NSPropertyListSerialization::propertyList:isValidForFormat). Furthermore, + * if an array or a dictionary (Map) contains a NSNull or a NSSet value, they cannot + * be serialized to a property list. + * As for usage within OS X, not surprisingly there's no known usage of + * storing either of these types in a property list. It seems that, for now, + * Apple is keeping the features of binary and xml formats in sync, probably as + * long as the XML format is not officially deprecated. + * 3) Not used anywhere. + * 4) About NSSet representation: For the time being, we represent those + * theoretical NSSet objects the same way NSArray is represented. + * While this would most certainly work, it is not the right way to handle + * it. A more correct representation for a set is a js generator, which would + * read the set lazily and has no indices semantics. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["PropertyListUtils"]; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +Cu.importGlobalProperties(['File', 'FileReader']); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ctypes", + "resource://gre/modules/ctypes.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +this.PropertyListUtils = Object.freeze({ + /** + * Asynchronously reads a file as a property list. + * + * @param aFile (nsIDOMBlob/nsILocalFile) + * the file to be read as a property list. + * @param aCallback + * If the property list is read successfully, aPropertyListRoot is set + * to the root object of the property list. + * Use getPropertyListObjectType to detect its type. + * If it's not read successfully, aPropertyListRoot is set to null. + * The reaon for failure is reported to the Error Console. + */ + read: function PLU_read(aFile, aCallback) { + if (!(aFile instanceof Ci.nsILocalFile || aFile instanceof File)) + throw new Error("aFile is not a file object"); + if (typeof(aCallback) != "function") + throw new Error("Invalid value for aCallback"); + + // We guarantee not to throw directly for any other exceptions, and always + // call aCallback. + Services.tm.mainThread.dispatch(function() { + let file = aFile; + try { + if (file instanceof Ci.nsILocalFile) { + if (!file.exists()) + throw new Error("The file pointed by aFile does not exist"); + + file = File.createFromNsIFile(file); + } + + let fileReader = new FileReader(); + let onLoadEnd = function() { + let root = null; + try { + fileReader.removeEventListener("loadend", onLoadEnd, false); + if (fileReader.readyState != fileReader.DONE) + throw new Error("Could not read file contents: " + fileReader.error); + + root = this._readFromArrayBufferSync(fileReader.result); + } + finally { + aCallback(root); + } + }.bind(this); + fileReader.addEventListener("loadend", onLoadEnd, false); + fileReader.readAsArrayBuffer(file); + } + catch (ex) { + aCallback(null); + throw ex; + } + }.bind(this), Ci.nsIThread.DISPATCH_NORMAL); + }, + + /** + * DO NOT USE ME. Once Bug 718189 is fixed, this method won't be public. + * + * Synchronously read an ArrayBuffer contents as a property list. + */ + _readFromArrayBufferSync: function PLU__readFromArrayBufferSync(aBuffer) { + if (BinaryPropertyListReader.prototype.canProcess(aBuffer)) + return new BinaryPropertyListReader(aBuffer).root; + + // Convert the buffer into an XML tree. + let domParser = Cc["@mozilla.org/xmlextras/domparser;1"]. + createInstance(Ci.nsIDOMParser); + let bytesView = new Uint8Array(aBuffer); + try { + let doc = domParser.parseFromBuffer(bytesView, bytesView.length, + "application/xml"); + return new XMLPropertyListReader(doc).root; + } + catch (ex) { + throw new Error("aBuffer cannot be parsed as a DOM document: " + ex); + } + }, + + TYPE_PRIMITIVE: 0, + TYPE_DATE: 1, + TYPE_UINT8_ARRAY: 2, + TYPE_ARRAY: 3, + TYPE_DICTIONARY: 4, + TYPE_INT64: 5, + + /** + * Get the type in which the given property list object is represented. + * Check the header for the mapping between the TYPE* constants to js types + * and objects. + * + * @return one of the TYPE_* constants listed above. + * @note this method is merely for convenience. It has no magic to detect + * that aObject is indeed a property list object created by this module. + */ + getObjectType: function PLU_getObjectType(aObject) { + if (aObject === null || typeof(aObject) != "object") + return this.TYPE_PRIMITIVE; + + // Given current usage, we could assume that aObject was created in the + // scope of this module, but in future, this util may be used as part of + // serializing js objects to a property list - in which case the object + // would most likely be created in the caller's scope. + let global = Cu.getGlobalForObject(aObject); + + if (aObject instanceof global.Map) + return this.TYPE_DICTIONARY; + if (Array.isArray(aObject)) + return this.TYPE_ARRAY; + if (aObject instanceof global.Date) + return this.TYPE_DATE; + if (aObject instanceof global.Uint8Array) + return this.TYPE_UINT8_ARRAY; + if (aObject instanceof global.String && "__INT_64_WRAPPER__" in aObject) + return this.TYPE_INT64; + + throw new Error("aObject is not as a property list object."); + }, + + /** + * Wraps a 64-bit stored in the form of a string primitive as a String object, + * which we can later distiguish from regular string values. + * @param aPrimitive + * a number in the form of either a primitive string or a primitive number. + * @return a String wrapper around aNumberStr that can later be identified + * as holding 64-bit number using getObjectType. + */ + wrapInt64: function PLU_wrapInt64(aPrimitive) { + if (typeof(aPrimitive) != "string" && typeof(aPrimitive) != "number") + throw new Error("aPrimitive should be a string primitive"); + + let wrapped = new String(aPrimitive); + Object.defineProperty(wrapped, "__INT_64_WRAPPER__", { value: true }); + return wrapped; + } +}); + +/** + * Here's the base structure of binary-format property lists. + * 1) Header - magic number + * - 6 bytes - "bplist" + * - 2 bytes - version number. This implementation only supports version 00. + * 2) Objects Table + * Variable-sized objects, see _readObject for how various types of objects + * are constructed. + * 3) Offsets Table + * The offset of each object in the objects table. The integer size is + * specified in the trailer. + * 4) Trailer + * - 6 unused bytes + * - 1 byte: the size of integers in the offsets table + * - 1 byte: the size of object references for arrays, sets and + * dictionaries. + * - 8 bytes: the number of objects in the objects table + * - 8 bytes: the index of the root object's offset in the offsets table. + * - 8 bytes: the offset of the offsets table. + * + * Note: all integers are stored in big-endian form. + */ + +/** + * Reader for binary-format property lists. + * + * @param aBuffer + * ArrayBuffer object from which the binary plist should be read. + */ +function BinaryPropertyListReader(aBuffer) { + this._dataView = new DataView(aBuffer); + + const JS_MAX_INT = Math.pow(2, 53); + this._JS_MAX_INT_SIGNED = ctypes.Int64(JS_MAX_INT); + this._JS_MAX_INT_UNSIGNED = ctypes.UInt64(JS_MAX_INT); + this._JS_MIN_INT = ctypes.Int64(-JS_MAX_INT); + + try { + this._readTrailerInfo(); + this._readObjectsOffsets(); + } + catch (ex) { + throw new Error("Could not read aBuffer as a binary property list"); + } + this._objects = []; +} + +BinaryPropertyListReader.prototype = { + /** + * Checks if the given ArrayBuffer can be read as a binary property list. + * It can be called on the prototype. + */ + canProcess: function BPLR_canProcess(aBuffer) { + return Array.from(new Uint8Array(aBuffer, 0, 8)).map(c => String.fromCharCode(c)). + join("") == "bplist00"; + }, + + get root() { + return this._readObject(this._rootObjectIndex); + }, + + _readTrailerInfo: function BPLR__readTrailer() { + // The first 6 bytes of the 32-bytes trailer are unused + let trailerOffset = this._dataView.byteLength - 26; + [this._offsetTableIntegerSize, this._objectRefSize] = + this._readUnsignedInts(trailerOffset, 1, 2); + + [this._numberOfObjects, this._rootObjectIndex, this._offsetTableOffset] = + this._readUnsignedInts(trailerOffset + 2, 8, 3); + }, + + _readObjectsOffsets: function BPLR__readObjectsOffsets() { + this._offsetTable = this._readUnsignedInts(this._offsetTableOffset, + this._offsetTableIntegerSize, + this._numberOfObjects); + }, + + _readSignedInt64: function BPLR__readSignedInt64(aByteOffset) { + let lo = this._dataView.getUint32(aByteOffset + 4); + let hi = this._dataView.getInt32(aByteOffset); + let int64 = ctypes.Int64.join(hi, lo); + if (ctypes.Int64.compare(int64, this._JS_MAX_INT_SIGNED) == 1 || + ctypes.Int64.compare(int64, this._JS_MIN_INT) == -1) + return PropertyListUtils.wrapInt64(int64.toString()); + + return parseInt(int64.toString(), 10); + }, + + _readReal: function BPLR__readReal(aByteOffset, aRealSize) { + if (aRealSize == 4) + return this._dataView.getFloat32(aByteOffset); + if (aRealSize == 8) + return this._dataView.getFloat64(aByteOffset); + + throw new Error("Unsupported real size: " + aRealSize); + }, + + OBJECT_TYPE_BITS: { + SIMPLE: parseInt("0000", 2), + INTEGER: parseInt("0001", 2), + REAL: parseInt("0010", 2), + DATE: parseInt("0011", 2), + DATA: parseInt("0100", 2), + ASCII_STRING: parseInt("0101", 2), + UNICODE_STRING: parseInt("0110", 2), + UID: parseInt("1000", 2), + ARRAY: parseInt("1010", 2), + SET: parseInt("1100", 2), + DICTIONARY: parseInt("1101", 2) + }, + + ADDITIONAL_INFO_BITS: { + // Applies to OBJECT_TYPE_BITS.SIMPLE + NULL: parseInt("0000", 2), + FALSE: parseInt("1000", 2), + TRUE: parseInt("1001", 2), + FILL_BYTE: parseInt("1111", 2), + // Applies to OBJECT_TYPE_BITS.DATE + DATE: parseInt("0011", 2), + // Applies to OBJECT_TYPE_BITS.DATA, ASCII_STRING, UNICODE_STRING, ARRAY, + // SET and DICTIONARY. + LENGTH_INT_SIZE_FOLLOWS: parseInt("1111", 2) + }, + + /** + * Returns an object descriptor in the form of two integers: object type and + * additional info. + * + * @param aByteOffset + * the descriptor's offset. + * @return [objType, additionalInfo] - the object type and additional info. + * @see OBJECT_TYPE_BITS and ADDITIONAL_INFO_BITS + */ + _readObjectDescriptor: function BPLR__readObjectDescriptor(aByteOffset) { + // The first four bits hold the object type. For some types, additional + // info is held in the other 4 bits. + let byte = this._readUnsignedInts(aByteOffset, 1, 1)[0]; + return [(byte & 0xF0) >> 4, byte & 0x0F]; + }, + + _readDate: function BPLR__readDate(aByteOffset) { + // That's the reference date of NSDate. + let date = new Date("1 January 2001, GMT"); + + // NSDate values are float values, but setSeconds takes an integer. + date.setMilliseconds(this._readReal(aByteOffset, 8) * 1000); + return date; + }, + + /** + * Reads a portion of the buffer as a string. + * + * @param aByteOffset + * The offset in the buffer at which the string starts + * @param aNumberOfChars + * The length of the string to be read (that is the number of + * characters, not bytes). + * @param aUnicode + * Whether or not it is a unicode string. + * @return the string read. + * + * @note this is tested to work well with unicode surrogate pairs. Because + * all unicode characters are read as 2-byte integers, unicode surrogate + * pairs are read from the buffer in the form of two integers, as required + * by String.fromCharCode. + */ + _readString: + function BPLR__readString(aByteOffset, aNumberOfChars, aUnicode) { + let codes = this._readUnsignedInts(aByteOffset, aUnicode ? 2 : 1, + aNumberOfChars); + return codes.map(c => String.fromCharCode(c)).join(""); + }, + + /** + * Reads an array of unsigned integers from the buffer. Integers larger than + * one byte are read in big endian form. + * + * @param aByteOffset + * The offset in the buffer at which the array starts. + * @param aIntSize + * The size of each int in the array. + * @param aLength + * The number of ints in the array. + * @param [optional] aBigIntAllowed (default: false) + * Whether or not to accept integers which outbounds JS limits for + * numbers (±2^53) in the form of a String. + * @return an array of integers (number primitive and/or Strings for large + * numbers (see header)). + * @throws if aBigIntAllowed is false and one of the integers in the array + * cannot be represented by a primitive js number. + */ + _readUnsignedInts: + function BPLR__readUnsignedInts(aByteOffset, aIntSize, aLength, aBigIntAllowed) { + let uints = []; + for (let offset = aByteOffset; + offset < aByteOffset + aIntSize * aLength; + offset += aIntSize) { + if (aIntSize == 1) { + uints.push(this._dataView.getUint8(offset)); + } + else if (aIntSize == 2) { + uints.push(this._dataView.getUint16(offset)); + } + else if (aIntSize == 3) { + let int24 = Uint8Array(4); + int24[3] = 0; + int24[2] = this._dataView.getUint8(offset); + int24[1] = this._dataView.getUint8(offset + 1); + int24[0] = this._dataView.getUint8(offset + 2); + uints.push(Uint32Array(int24.buffer)[0]); + } + else if (aIntSize == 4) { + uints.push(this._dataView.getUint32(offset)); + } + else if (aIntSize == 8) { + let lo = this._dataView.getUint32(offset + 4); + let hi = this._dataView.getUint32(offset); + let uint64 = ctypes.UInt64.join(hi, lo); + if (ctypes.UInt64.compare(uint64, this._JS_MAX_INT_UNSIGNED) == 1) { + if (aBigIntAllowed === true) + uints.push(PropertyListUtils.wrapInt64(uint64.toString())); + else + throw new Error("Integer too big to be read as float 64"); + } + else { + uints.push(parseInt(uint64, 10)); + } + } + else { + throw new Error("Unsupported size: " + aIntSize); + } + } + + return uints; + }, + + /** + * Reads from the buffer the data object-count and the offset at which the + * first object starts. + * + * @param aObjectOffset + * the object's offset. + * @return [offset, count] - the offset in the buffer at which the first + * object in data starts, and the number of objects. + */ + _readDataOffsetAndCount: + function BPLR__readDataOffsetAndCount(aObjectOffset) { + // The length of some objects in the data can be stored in two ways: + // * If it is small enough, it is stored in the second four bits of the + // object descriptors. + // * Otherwise, those bits are set to 1111, indicating that the next byte + // consists of the integer size of the data-length (also stored in the form + // of an object descriptor). The length follows this byte. + let [, maybeLength] = this._readObjectDescriptor(aObjectOffset); + if (maybeLength != this.ADDITIONAL_INFO_BITS.LENGTH_INT_SIZE_FOLLOWS) + return [aObjectOffset + 1, maybeLength]; + + let [, intSizeInfo] = this._readObjectDescriptor(aObjectOffset + 1); + + // The int size is 2^intSizeInfo. + let intSize = Math.pow(2, intSizeInfo); + let dataLength = this._readUnsignedInts(aObjectOffset + 2, intSize, 1)[0]; + return [aObjectOffset + 2 + intSize, dataLength]; + }, + + /** + * Read array from the buffer and wrap it as a js array. + * @param aObjectOffset + * the offset in the buffer at which the array starts. + * @param aNumberOfObjects + * the number of objects in the array. + * @return a js array. + */ + _wrapArray: function BPLR__wrapArray(aObjectOffset, aNumberOfObjects) { + let refs = this._readUnsignedInts(aObjectOffset, + this._objectRefSize, + aNumberOfObjects); + + let array = new Array(aNumberOfObjects); + let readObjectBound = this._readObject.bind(this); + + // Each index in the returned array is a lazy getter for its object. + Array.prototype.forEach.call(refs, function(ref, objIndex) { + Object.defineProperty(array, objIndex, { + get: function() { + delete array[objIndex]; + return array[objIndex] = readObjectBound(ref); + }, + configurable: true, + enumerable: true + }); + }, this); + return array; + }, + + /** + * Reads dictionary from the buffer and wraps it as a Map object. + * @param aObjectOffset + * the offset in the buffer at which the dictionary starts + * @param aNumberOfObjects + * the number of keys in the dictionary + * @return Map-style dictionary. + */ + _wrapDictionary: function(aObjectOffset, aNumberOfObjects) { + // A dictionary in the binary format is stored as a list of references to + // key-objects, followed by a list of references to the value-objects for + // those keys. The size of each list is aNumberOfObjects * this._objectRefSize. + let dict = new Proxy(new Map(), LazyMapProxyHandler()); + if (aNumberOfObjects == 0) + return dict; + + let keyObjsRefs = this._readUnsignedInts(aObjectOffset, this._objectRefSize, + aNumberOfObjects); + let valObjsRefs = + this._readUnsignedInts(aObjectOffset + aNumberOfObjects * this._objectRefSize, + this._objectRefSize, aNumberOfObjects); + for (let i = 0; i < aNumberOfObjects; i++) { + let key = this._readObject(keyObjsRefs[i]); + let readBound = this._readObject.bind(this, valObjsRefs[i]); + + dict.setAsLazyGetter(key, readBound); + } + return dict; + }, + + /** + * Reads an object at the spcified index in the object table + * @param aObjectIndex + * index at the object table + * @return the property list object at the given index. + */ + _readObject: function BPLR__readObject(aObjectIndex) { + // If the object was previously read, return the cached object. + if (this._objects[aObjectIndex] !== undefined) + return this._objects[aObjectIndex]; + + let objOffset = this._offsetTable[aObjectIndex]; + let [objType, additionalInfo] = this._readObjectDescriptor(objOffset); + let value; + switch (objType) { + case this.OBJECT_TYPE_BITS.SIMPLE: { + switch (additionalInfo) { + case this.ADDITIONAL_INFO_BITS.NULL: + value = null; + break; + case this.ADDITIONAL_INFO_BITS.FILL_BYTE: + value = undefined; + break; + case this.ADDITIONAL_INFO_BITS.FALSE: + value = false; + break; + case this.ADDITIONAL_INFO_BITS.TRUE: + value = true; + break; + default: + throw new Error("Unexpected value!"); + } + break; + } + + case this.OBJECT_TYPE_BITS.INTEGER: { + // The integer is sized 2^additionalInfo. + let intSize = Math.pow(2, additionalInfo); + + // For objects, 64-bit integers are always signed. Negative integers + // are always represented by a 64-bit integer. + if (intSize == 8) + value = this._readSignedInt64(objOffset + 1); + else + value = this._readUnsignedInts(objOffset + 1, intSize, 1, true)[0]; + break; + } + + case this.OBJECT_TYPE_BITS.REAL: { + // The real is sized 2^additionalInfo. + value = this._readReal(objOffset + 1, Math.pow(2, additionalInfo)); + break; + } + + case this.OBJECT_TYPE_BITS.DATE: { + if (additionalInfo != this.ADDITIONAL_INFO_BITS.DATE) + throw new Error("Unexpected value"); + + value = this._readDate(objOffset + 1); + break; + } + + case this.OBJECT_TYPE_BITS.DATA: { + let [offset, bytesCount] = this._readDataOffsetAndCount(objOffset); + value = new Uint8Array(this._readUnsignedInts(offset, 1, bytesCount)); + break; + } + + case this.OBJECT_TYPE_BITS.ASCII_STRING: { + let [offset, charsCount] = this._readDataOffsetAndCount(objOffset); + value = this._readString(offset, charsCount, false); + break; + } + + case this.OBJECT_TYPE_BITS.UNICODE_STRING: { + let [offset, unicharsCount] = this._readDataOffsetAndCount(objOffset); + value = this._readString(offset, unicharsCount, true); + break; + } + + case this.OBJECT_TYPE_BITS.UID: { + // UIDs are only used in Keyed Archives, which are not yet supported. + throw new Error("Keyed Archives are not supported"); + } + + case this.OBJECT_TYPE_BITS.ARRAY: + case this.OBJECT_TYPE_BITS.SET: { + // Note: For now, we fallback to handle sets the same way we handle + // arrays. See comments in the header of this file. + + // The bytes following the count are references to objects (indices). + // Each reference is an unsigned int with size=this._objectRefSize. + let [offset, objectsCount] = this._readDataOffsetAndCount(objOffset); + value = this._wrapArray(offset, objectsCount); + break; + } + + case this.OBJECT_TYPE_BITS.DICTIONARY: { + let [offset, objectsCount] = this._readDataOffsetAndCount(objOffset); + value = this._wrapDictionary(offset, objectsCount); + break; + } + + default: { + throw new Error("Unknown object type: " + objType); + } + } + + return this._objects[aObjectIndex] = value; + } +}; + +/** + * Reader for XML property lists. + * + * @param aDOMDoc + * the DOM document to be read as a property list. + */ +function XMLPropertyListReader(aDOMDoc) { + let docElt = aDOMDoc.documentElement; + if (!docElt || docElt.localName != "plist" || !docElt.firstElementChild) + throw new Error("aDoc is not a property list document"); + + this._plistRootElement = docElt.firstElementChild; +} + +XMLPropertyListReader.prototype = { + get root() { + return this._readObject(this._plistRootElement); + }, + + /** + * Convert a dom element to a property list object. + * @param aDOMElt + * a dom element in a xml tree of a property list. + * @return a js object representing the property list object. + */ + _readObject: function XPLR__readObject(aDOMElt) { + switch (aDOMElt.localName) { + case "true": + return true; + case "false": + return false; + case "string": + case "key": + return aDOMElt.textContent; + case "integer": + return this._readInteger(aDOMElt); + case "real": { + let number = parseFloat(aDOMElt.textContent.trim()); + if (isNaN(number)) + throw "Could not parse float value"; + return number; + } + case "date": + return new Date(aDOMElt.textContent); + case "data": + // Strip spaces and new lines. + let base64str = aDOMElt.textContent.replace(/\s*/g, ""); + let decoded = atob(base64str); + return new Uint8Array(Array.from(decoded, c => c.charCodeAt(0))); + case "dict": + return this._wrapDictionary(aDOMElt); + case "array": + return this._wrapArray(aDOMElt); + default: + throw new Error("Unexpected tagname"); + } + }, + + _readInteger: function XPLR__readInteger(aDOMElt) { + // The integer may outbound js's max/min integer value. We recognize this + // case by comparing the parsed number to the original string value. + // In case of an outbound, we fallback to return the number as a string. + let numberAsString = aDOMElt.textContent.toString(); + let parsedNumber = parseInt(numberAsString, 10); + if (isNaN(parsedNumber)) + throw new Error("Could not parse integer value"); + + if (parsedNumber.toString() == numberAsString) + return parsedNumber; + + return PropertyListUtils.wrapInt64(numberAsString); + }, + + _wrapDictionary: function XPLR__wrapDictionary(aDOMElt) { + // <dict> + // <key>my true bool</key> + // <true/> + // <key>my string key</key> + // <string>My String Key</string> + // </dict> + if (aDOMElt.children.length % 2 != 0) + throw new Error("Invalid dictionary"); + let dict = new Proxy(new Map(), LazyMapProxyHandler()); + for (let i = 0; i < aDOMElt.children.length; i += 2) { + let keyElem = aDOMElt.children[i]; + let valElem = aDOMElt.children[i + 1]; + + if (keyElem.localName != "key") + throw new Error("Invalid dictionary"); + + let keyName = this._readObject(keyElem); + let readBound = this._readObject.bind(this, valElem); + + dict.setAsLazyGetter(keyName, readBound); + } + return dict; + }, + + _wrapArray: function XPLR__wrapArray(aDOMElt) { + // <array> + // <string>...</string> + // <integer></integer> + // <dict> + // .... + // </dict> + // </array> + + // Each element in the array is a lazy getter for its property list object. + let array = []; + let readObjectBound = this._readObject.bind(this); + Array.prototype.forEach.call(aDOMElt.children, function(elem, elemIndex) { + Object.defineProperty(array, elemIndex, { + get: function() { + delete array[elemIndex]; + return array[elemIndex] = readObjectBound(elem); + }, + configurable: true, + enumerable: true + }); + }); + return array; + } +}; + +/** + * Simple handler method to proxy calls to dict/Map objects to implement the + * setAsLazyGetter API. With this, a value can be set as a function that will + * evaluate its value and only be called when it's first retrieved. + * @member _lazyGetters + * Set() object to hold keys invoking LazyGetter. + * @method get + * Trap for getting property values. Ensures that if a lazyGetter is present + * as value for key, then the function is evaluated, the value is cached, + * and its value will be returned. + * @param target + * Target object. (dict/Map) + * @param name + * Name of operation to be invoked on target. + * @param key + * Key to be set, retrieved or deleted. Keys are checked for laziness. + * @return Returns value of "name" property of target by default. Otherwise returns + * updated target. + */ +function LazyMapProxyHandler () { + return { + _lazyGetters: new Set(), + get: function(target, name) { + switch (name) { + case "setAsLazyGetter": + return (key, value) => { + this._lazyGetters.add(key); + target.set(key, value); + }; + case "get": + return key => { + if (this._lazyGetters.has(key)) { + target.set(key, target.get(key)()); + this._lazyGetters.delete(key); + } + return target.get(key); + }; + case "delete": + return key => { + if (this._lazyGetters.has(key)) { + this._lazyGetters.delete(key); + } + return target.delete(key); + }; + case "has": + return key => target.has(key); + default: + return target[name]; + } + } + } +} diff --git a/toolkit/modules/RemoteController.jsm b/toolkit/modules/RemoteController.jsm new file mode 100644 index 0000000000..23f1bd949f --- /dev/null +++ b/toolkit/modules/RemoteController.jsm @@ -0,0 +1,96 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +// 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/. + +this.EXPORTED_SYMBOLS = ["RemoteController"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +function RemoteController(browser) +{ + this._browser = browser; + + // A map of commands that have had their enabled/disabled state assigned. The + // value of each key will be true if enabled, and false if disabled. + this._supportedCommands = { }; +} + +RemoteController.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIController, + Ci.nsICommandController]), + + isCommandEnabled: function(aCommand) { + return this._supportedCommands[aCommand] || false; + }, + + supportsCommand: function(aCommand) { + return aCommand in this._supportedCommands; + }, + + doCommand: function(aCommand) { + this._browser.messageManager.sendAsyncMessage("ControllerCommands:Do", aCommand); + }, + + getCommandStateWithParams: function(aCommand, aCommandParams) { + throw Cr.NS_ERROR_NOT_IMPLEMENTED; + }, + + doCommandWithParams: function(aCommand, aCommandParams) { + let cmd = { + cmd: aCommand, + params: null + }; + if (aCommand == "cmd_lookUpDictionary") { + // Although getBoundingClientRect of the element is logical pixel, but + // x and y parameter of cmd_lookUpDictionary are device pixel. + // So we need calculate child process's coordinate using correct unit. + let rect = this._browser.getBoundingClientRect(); + let scale = this._browser.ownerDocument.defaultView.devicePixelRatio; + cmd.params = { + x: { + type: "long", + value: aCommandParams.getLongValue("x") - rect.left * scale + }, + y: { + type: "long", + value: aCommandParams.getLongValue("y") - rect.top * scale + } + }; + } else { + throw Cr.NS_ERROR_NOT_IMPLEMENTED; + } + this._browser.messageManager.sendAsyncMessage( + "ControllerCommands:DoWithParams", cmd); + }, + + getSupportedCommands: function(aCount, aCommands) { + throw Cr.NS_ERROR_NOT_IMPLEMENTED; + }, + + onEvent: function () {}, + + // This is intended to be called from the remote-browser binding to update + // the enabled and disabled commands. + enableDisableCommands: function(aAction, + aEnabledLength, aEnabledCommands, + aDisabledLength, aDisabledCommands) { + // Clear the list first + this._supportedCommands = { }; + + for (let c = 0; c < aEnabledLength; c++) { + this._supportedCommands[aEnabledCommands[c]] = true; + } + + for (let c = 0; c < aDisabledLength; c++) { + this._supportedCommands[aDisabledCommands[c]] = false; + } + + this._browser.ownerDocument.defaultView.updateCommands(aAction); + } +}; diff --git a/toolkit/modules/RemoteFinder.jsm b/toolkit/modules/RemoteFinder.jsm new file mode 100644 index 0000000000..ae20da4501 --- /dev/null +++ b/toolkit/modules/RemoteFinder.jsm @@ -0,0 +1,332 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +// vim: set ts=2 sw=2 sts=2 et tw=80: */ +// 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/. + +this.EXPORTED_SYMBOLS = ["RemoteFinder", "RemoteFinderListener"]; + +const { interfaces: Ci, classes: Cc, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Geometry.jsm"); + +XPCOMUtils.defineLazyGetter(this, "GetClipboardSearchString", + () => Cu.import("resource://gre/modules/Finder.jsm", {}).GetClipboardSearchString +); +XPCOMUtils.defineLazyGetter(this, "Rect", + () => Cu.import("resource://gre/modules/Geometry.jsm", {}).Rect +); + +function RemoteFinder(browser) { + this._listeners = new Set(); + this._searchString = null; + + this.swapBrowser(browser); +} + +RemoteFinder.prototype = { + destroy() {}, + + swapBrowser: function(aBrowser) { + if (this._messageManager) { + this._messageManager.removeMessageListener("Finder:Result", this); + this._messageManager.removeMessageListener("Finder:MatchesResult", this); + this._messageManager.removeMessageListener("Finder:CurrentSelectionResult", this); + this._messageManager.removeMessageListener("Finder:HighlightFinished", this); + } + else { + aBrowser.messageManager.sendAsyncMessage("Finder:Initialize"); + } + + this._browser = aBrowser; + this._messageManager = this._browser.messageManager; + this._messageManager.addMessageListener("Finder:Result", this); + this._messageManager.addMessageListener("Finder:MatchesResult", this); + this._messageManager.addMessageListener("Finder:CurrentSelectionResult", this); + this._messageManager.addMessageListener("Finder:HighlightFinished", this); + + // Ideally listeners would have removed themselves but that doesn't happen + // right now + this._listeners.clear(); + }, + + addResultListener: function (aListener) { + this._listeners.add(aListener); + }, + + removeResultListener: function (aListener) { + this._listeners.delete(aListener); + }, + + receiveMessage: function (aMessage) { + // Only Finder:Result messages have the searchString field. + let callback; + let params; + switch (aMessage.name) { + case "Finder:Result": + this._searchString = aMessage.data.searchString; + // The rect stops being a Geometry.jsm:Rect over IPC. + if (aMessage.data.rect) { + aMessage.data.rect = Rect.fromRect(aMessage.data.rect); + } + callback = "onFindResult"; + params = [ aMessage.data ]; + break; + case "Finder:MatchesResult": + callback = "onMatchesCountResult"; + params = [ aMessage.data ]; + break; + case "Finder:CurrentSelectionResult": + callback = "onCurrentSelection"; + params = [ aMessage.data.selection, aMessage.data.initial ]; + break; + case "Finder:HighlightFinished": + callback = "onHighlightFinished"; + params = [ aMessage.data ]; + break; + } + + for (let l of this._listeners) { + // Don't let one callback throwing stop us calling the rest + try { + l[callback].apply(l, params); + } catch (e) { + if (!l[callback]) { + Cu.reportError(`Missing ${callback} callback on RemoteFinderListener`); + } + Cu.reportError(e); + } + } + }, + + get searchString() { + return this._searchString; + }, + + get clipboardSearchString() { + return GetClipboardSearchString(this._browser.loadContext); + }, + + setSearchStringToSelection() { + this._browser.messageManager.sendAsyncMessage("Finder:SetSearchStringToSelection", {}); + }, + + set caseSensitive(aSensitive) { + this._browser.messageManager.sendAsyncMessage("Finder:CaseSensitive", + { caseSensitive: aSensitive }); + }, + + set entireWord(aEntireWord) { + this._browser.messageManager.sendAsyncMessage("Finder:EntireWord", + { entireWord: aEntireWord }); + }, + + getInitialSelection: function() { + this._browser.messageManager.sendAsyncMessage("Finder:GetInitialSelection", {}); + }, + + fastFind: function (aSearchString, aLinksOnly, aDrawOutline) { + this._browser.messageManager.sendAsyncMessage("Finder:FastFind", + { searchString: aSearchString, + linksOnly: aLinksOnly, + drawOutline: aDrawOutline }); + }, + + findAgain: function (aFindBackwards, aLinksOnly, aDrawOutline) { + this._browser.messageManager.sendAsyncMessage("Finder:FindAgain", + { findBackwards: aFindBackwards, + linksOnly: aLinksOnly, + drawOutline: aDrawOutline }); + }, + + highlight: function (aHighlight, aWord, aLinksOnly) { + this._browser.messageManager.sendAsyncMessage("Finder:Highlight", + { highlight: aHighlight, + linksOnly: aLinksOnly, + word: aWord }); + }, + + enableSelection: function () { + this._browser.messageManager.sendAsyncMessage("Finder:EnableSelection"); + }, + + removeSelection: function () { + this._browser.messageManager.sendAsyncMessage("Finder:RemoveSelection"); + }, + + focusContent: function () { + // Allow Finder listeners to cancel focusing the content. + for (let l of this._listeners) { + try { + if ("shouldFocusContent" in l && + !l.shouldFocusContent()) + return; + } catch (ex) { + Cu.reportError(ex); + } + } + + this._browser.focus(); + this._browser.messageManager.sendAsyncMessage("Finder:FocusContent"); + }, + + onFindbarClose: function () { + this._browser.messageManager.sendAsyncMessage("Finder:FindbarClose"); + }, + + onFindbarOpen: function () { + this._browser.messageManager.sendAsyncMessage("Finder:FindbarOpen"); + }, + + onModalHighlightChange: function(aUseModalHighlight) { + this._browser.messageManager.sendAsyncMessage("Finder:ModalHighlightChange", { + useModalHighlight: aUseModalHighlight + }); + }, + + onHighlightAllChange: function(aHighlightAll) { + this._browser.messageManager.sendAsyncMessage("Finder:HighlightAllChange", { + highlightAll: aHighlightAll + }); + }, + + keyPress: function (aEvent) { + this._browser.messageManager.sendAsyncMessage("Finder:KeyPress", + { keyCode: aEvent.keyCode, + ctrlKey: aEvent.ctrlKey, + metaKey: aEvent.metaKey, + altKey: aEvent.altKey, + shiftKey: aEvent.shiftKey }); + }, + + requestMatchesCount: function (aSearchString, aLinksOnly) { + this._browser.messageManager.sendAsyncMessage("Finder:MatchesCount", + { searchString: aSearchString, + linksOnly: aLinksOnly }); + } +} + +function RemoteFinderListener(global) { + let {Finder} = Cu.import("resource://gre/modules/Finder.jsm", {}); + this._finder = new Finder(global.docShell); + this._finder.addResultListener(this); + this._global = global; + + for (let msg of this.MESSAGES) { + global.addMessageListener(msg, this); + } +} + +RemoteFinderListener.prototype = { + MESSAGES: [ + "Finder:CaseSensitive", + "Finder:EntireWord", + "Finder:FastFind", + "Finder:FindAgain", + "Finder:SetSearchStringToSelection", + "Finder:GetInitialSelection", + "Finder:Highlight", + "Finder:HighlightAllChange", + "Finder:EnableSelection", + "Finder:RemoveSelection", + "Finder:FocusContent", + "Finder:FindbarClose", + "Finder:FindbarOpen", + "Finder:KeyPress", + "Finder:MatchesCount", + "Finder:ModalHighlightChange" + ], + + onFindResult: function (aData) { + this._global.sendAsyncMessage("Finder:Result", aData); + }, + + // When the child receives messages with results of requestMatchesCount, + // it passes them forward to the parent. + onMatchesCountResult: function (aData) { + this._global.sendAsyncMessage("Finder:MatchesResult", aData); + }, + + onHighlightFinished: function(aData) { + this._global.sendAsyncMessage("Finder:HighlightFinished", aData); + }, + + receiveMessage: function (aMessage) { + let data = aMessage.data; + + switch (aMessage.name) { + case "Finder:CaseSensitive": + this._finder.caseSensitive = data.caseSensitive; + break; + + case "Finder:EntireWord": + this._finder.entireWord = data.entireWord; + break; + + case "Finder:SetSearchStringToSelection": { + let selection = this._finder.setSearchStringToSelection(); + this._global.sendAsyncMessage("Finder:CurrentSelectionResult", + { selection: selection, + initial: false }); + break; + } + + case "Finder:GetInitialSelection": { + let selection = this._finder.getActiveSelectionText(); + this._global.sendAsyncMessage("Finder:CurrentSelectionResult", + { selection: selection, + initial: true }); + break; + } + + case "Finder:FastFind": + this._finder.fastFind(data.searchString, data.linksOnly, data.drawOutline); + break; + + case "Finder:FindAgain": + this._finder.findAgain(data.findBackwards, data.linksOnly, data.drawOutline); + break; + + case "Finder:Highlight": + this._finder.highlight(data.highlight, data.word, data.linksOnly); + break; + + case "Finder:HighlightAllChange": + this._finder.onHighlightAllChange(data.highlightAll); + break; + + case "Finder:EnableSelection": + this._finder.enableSelection(); + break; + + case "Finder:RemoveSelection": + this._finder.removeSelection(); + break; + + case "Finder:FocusContent": + this._finder.focusContent(); + break; + + case "Finder:FindbarClose": + this._finder.onFindbarClose(); + break; + + case "Finder:FindbarOpen": + this._finder.onFindbarOpen(); + break; + + case "Finder:KeyPress": + this._finder.keyPress(data); + break; + + case "Finder:MatchesCount": + this._finder.requestMatchesCount(data.searchString, data.linksOnly); + break; + + case "Finder:ModalHighlightChange": + this._finder.onModalHighlightChange(data.useModalHighlight); + break; + } + } +}; diff --git a/toolkit/modules/RemotePageManager.jsm b/toolkit/modules/RemotePageManager.jsm new file mode 100644 index 0000000000..a3fea49e67 --- /dev/null +++ b/toolkit/modules/RemotePageManager.jsm @@ -0,0 +1,534 @@ +/* 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 = ["RemotePages", "RemotePageManager", "PageListener"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +function MessageListener() { + this.listeners = new Map(); +} + +MessageListener.prototype = { + keys: function() { + return this.listeners.keys(); + }, + + has: function(name) { + return this.listeners.has(name); + }, + + callListeners: function(message) { + let listeners = this.listeners.get(message.name); + if (!listeners) { + return; + } + + for (let listener of listeners.values()) { + try { + listener(message); + } + catch (e) { + Cu.reportError(e); + } + } + }, + + addMessageListener: function(name, callback) { + if (!this.listeners.has(name)) + this.listeners.set(name, new Set([callback])); + else + this.listeners.get(name).add(callback); + }, + + removeMessageListener: function(name, callback) { + if (!this.listeners.has(name)) + return; + + this.listeners.get(name).delete(callback); + }, +} + + +/** + * Creates a RemotePages object which listens for new remote pages of a + * particular URL. A "RemotePage:Init" message will be dispatched to this object + * for every page loaded. Message listeners added to this object receive + * messages from all loaded pages from the requested url. + */ +this.RemotePages = function(url) { + this.url = url; + this.messagePorts = new Set(); + this.listener = new MessageListener(); + this.destroyed = false; + + RemotePageManager.addRemotePageListener(url, this.portCreated.bind(this)); + this.portMessageReceived = this.portMessageReceived.bind(this); +} + +RemotePages.prototype = { + url: null, + messagePorts: null, + listener: null, + destroyed: null, + + destroy: function() { + RemotePageManager.removeRemotePageListener(this.url); + + for (let port of this.messagePorts.values()) { + this.removeMessagePort(port); + } + + this.messagePorts = null; + this.listener = null; + this.destroyed = true; + }, + + // Called when a page matching the url has loaded in a frame. + portCreated: function(port) { + this.messagePorts.add(port); + + port.addMessageListener("RemotePage:Unload", this.portMessageReceived); + + for (let name of this.listener.keys()) { + this.registerPortListener(port, name); + } + + this.listener.callListeners({ target: port, name: "RemotePage:Init" }); + }, + + // A message has been received from one of the pages + portMessageReceived: function(message) { + if (message.name == "RemotePage:Unload") + this.removeMessagePort(message.target); + + this.listener.callListeners(message); + }, + + // A page has closed + removeMessagePort: function(port) { + for (let name of this.listener.keys()) { + port.removeMessageListener(name, this.portMessageReceived); + } + + port.removeMessageListener("RemotePage:Unload", this.portMessageReceived); + this.messagePorts.delete(port); + }, + + registerPortListener: function(port, name) { + port.addMessageListener(name, this.portMessageReceived); + }, + + // Sends a message to all known pages + sendAsyncMessage: function(name, data = null) { + for (let port of this.messagePorts.values()) { + port.sendAsyncMessage(name, data); + } + }, + + addMessageListener: function(name, callback) { + if (this.destroyed) { + throw new Error("RemotePages has been destroyed"); + } + + if (!this.listener.has(name)) { + for (let port of this.messagePorts.values()) { + this.registerPortListener(port, name) + } + } + + this.listener.addMessageListener(name, callback); + }, + + removeMessageListener: function(name, callback) { + if (this.destroyed) { + throw new Error("RemotePages has been destroyed"); + } + + this.listener.removeMessageListener(name, callback); + }, + + portsForBrowser: function(browser) { + return [...this.messagePorts].filter(port => port.browser == browser); + }, +}; + + +// Only exposes the public properties of the MessagePort +function publicMessagePort(port) { + let properties = ["addMessageListener", "removeMessageListener", + "sendAsyncMessage", "destroy"]; + + let clean = {}; + for (let property of properties) { + clean[property] = port[property].bind(port); + } + + if (port instanceof ChromeMessagePort) { + Object.defineProperty(clean, "browser", { + get: function() { + return port.browser; + } + }); + } + + return clean; +} + + +/* + * A message port sits on each side of the process boundary for every remote + * page. Each has a port ID that is unique to the message manager it talks + * through. + * + * We roughly implement the same contract as nsIMessageSender and + * nsIMessageListenerManager + */ +function MessagePort(messageManager, portID) { + this.messageManager = messageManager; + this.portID = portID; + this.destroyed = false; + this.listener = new MessageListener(); + + this.message = this.message.bind(this); + this.messageManager.addMessageListener("RemotePage:Message", this.message); +} + +MessagePort.prototype = { + messageManager: null, + portID: null, + destroyed: null, + listener: null, + _browser: null, + remotePort: null, + + // Called when the message manager used to connect to the other process has + // changed, i.e. when a tab is detached. + swapMessageManager: function(messageManager) { + this.messageManager.removeMessageListener("RemotePage:Message", this.message); + + this.messageManager = messageManager; + + this.messageManager.addMessageListener("RemotePage:Message", this.message); + }, + + /* Adds a listener for messages. Many callbacks can be registered for the + * same message if necessary. An attempt to register the same callback for the + * same message twice will be ignored. When called the callback is passed an + * object with these properties: + * target: This message port + * name: The message name + * data: Any data sent with the message + */ + addMessageListener: function(name, callback) { + if (this.destroyed) { + throw new Error("Message port has been destroyed"); + } + + this.listener.addMessageListener(name, callback); + }, + + /* + * Removes a listener for messages. + */ + removeMessageListener: function(name, callback) { + if (this.destroyed) { + throw new Error("Message port has been destroyed"); + } + + this.listener.removeMessageListener(name, callback); + }, + + // Sends a message asynchronously to the other process + sendAsyncMessage: function(name, data = null) { + if (this.destroyed) { + throw new Error("Message port has been destroyed"); + } + + this.messageManager.sendAsyncMessage("RemotePage:Message", { + portID: this.portID, + name: name, + data: data, + }); + }, + + // Called to destroy this port + destroy: function() { + try { + // This can fail in the child process if the tab has already been closed + this.messageManager.removeMessageListener("RemotePage:Message", this.message); + } + catch (e) { } + this.messageManager = null; + this.destroyed = true; + this.portID = null; + this.listener = null; + }, +}; + + +// The chome side of a message port +function ChromeMessagePort(browser, portID) { + MessagePort.call(this, browser.messageManager, portID); + + this._browser = browser; + this._permanentKey = browser.permanentKey; + + Services.obs.addObserver(this, "message-manager-disconnect", false); + this.publicPort = publicMessagePort(this); + + this.swapBrowsers = this.swapBrowsers.bind(this); + this._browser.addEventListener("SwapDocShells", this.swapBrowsers, false); +} + +ChromeMessagePort.prototype = Object.create(MessagePort.prototype); + +Object.defineProperty(ChromeMessagePort.prototype, "browser", { + get: function() { + return this._browser; + } +}); + +// Called when the docshell is being swapped with another browser. We have to +// update to use the new browser's message manager +ChromeMessagePort.prototype.swapBrowsers = function({ detail: newBrowser }) { + // We can see this event for the new browser before the swap completes so + // check that the browser we're tracking has our permanentKey. + if (this._browser.permanentKey != this._permanentKey) + return; + + this._browser.removeEventListener("SwapDocShells", this.swapBrowsers, false); + + this._browser = newBrowser; + this.swapMessageManager(newBrowser.messageManager); + + this._browser.addEventListener("SwapDocShells", this.swapBrowsers, false); +} + +// Called when a message manager has been disconnected indicating that the +// tab has closed or crashed +ChromeMessagePort.prototype.observe = function(messageManager) { + if (messageManager != this.messageManager) + return; + + this.listener.callListeners({ + target: this.publicPort, + name: "RemotePage:Unload", + data: null, + }); + this.destroy(); +}; + +// Called when a message is received from the message manager. This could +// have come from any port in the message manager so verify the port ID. +ChromeMessagePort.prototype.message = function({ data: messagedata }) { + if (this.destroyed || (messagedata.portID != this.portID)) { + return; + } + + let message = { + target: this.publicPort, + name: messagedata.name, + data: messagedata.data, + }; + this.listener.callListeners(message); + + if (messagedata.name == "RemotePage:Unload") + this.destroy(); +}; + +ChromeMessagePort.prototype.destroy = function() { + try { + this._browser.removeEventListener( + "SwapDocShells", this.swapBrowsers, false); + } + catch (e) { + // It's possible the browser instance is already dead so we can just ignore + // this error. + } + + this._browser = null; + Services.obs.removeObserver(this, "message-manager-disconnect"); + MessagePort.prototype.destroy.call(this); +}; + + +// The content side of a message port +function ChildMessagePort(contentFrame, window) { + let portID = Services.appinfo.processID + ":" + ChildMessagePort.prototype.nextPortID++; + MessagePort.call(this, contentFrame, portID); + + this.window = window; + + // Add functionality to the content page + Cu.exportFunction(this.sendAsyncMessage.bind(this), window, { + defineAs: "sendAsyncMessage", + }); + Cu.exportFunction(this.addMessageListener.bind(this), window, { + defineAs: "addMessageListener", + allowCallbacks: true, + }); + Cu.exportFunction(this.removeMessageListener.bind(this), window, { + defineAs: "removeMessageListener", + allowCallbacks: true, + }); + + // Send a message for load events + let loadListener = () => { + this.sendAsyncMessage("RemotePage:Load"); + window.removeEventListener("load", loadListener, false); + }; + window.addEventListener("load", loadListener, false); + + // Destroy the port when the window is unloaded + window.addEventListener("unload", () => { + try { + this.sendAsyncMessage("RemotePage:Unload"); + } + catch (e) { + // If the tab has been closed the frame message manager has already been + // destroyed + } + this.destroy(); + }, false); + + // Tell the main process to set up its side of the message pipe. + this.messageManager.sendAsyncMessage("RemotePage:InitPort", { + portID: portID, + url: window.document.documentURI.replace(/[\#|\?].*$/, ""), + }); +} + +ChildMessagePort.prototype = Object.create(MessagePort.prototype); + +ChildMessagePort.prototype.nextPortID = 0; + +// Called when a message is received from the message manager. This could +// have come from any port in the message manager so verify the port ID. +ChildMessagePort.prototype.message = function({ data: messagedata }) { + if (this.destroyed || (messagedata.portID != this.portID)) { + return; + } + + let message = { + name: messagedata.name, + data: messagedata.data, + }; + this.listener.callListeners(Cu.cloneInto(message, this.window)); +}; + +ChildMessagePort.prototype.destroy = function() { + this.window = null; + MessagePort.prototype.destroy.call(this); +} + +// Allows callers to register to connect to specific content pages. Registration +// is done through the addRemotePageListener method +var RemotePageManagerInternal = { + // The currently registered remote pages + pages: new Map(), + + // Initialises all the needed listeners + init: function() { + Services.ppmm.addMessageListener("RemotePage:InitListener", this.initListener.bind(this)); + Services.mm.addMessageListener("RemotePage:InitPort", this.initPort.bind(this)); + }, + + // Registers interest in a remote page. A callback is called with a port for + // the new page when loading begins (i.e. the page hasn't actually loaded yet). + // Only one callback can be registered per URL. + addRemotePageListener: function(url, callback) { + if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) + throw new Error("RemotePageManager can only be used in the main process."); + + if (this.pages.has(url)) { + throw new Error("Remote page already registered: " + url); + } + + this.pages.set(url, callback); + + // Notify all the frame scripts of the new registration + Services.ppmm.broadcastAsyncMessage("RemotePage:Register", { urls: [url] }); + }, + + // Removes any interest in a remote page. + removeRemotePageListener: function(url) { + if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) + throw new Error("RemotePageManager can only be used in the main process."); + + if (!this.pages.has(url)) { + throw new Error("Remote page is not registered: " + url); + } + + // Notify all the frame scripts of the removed registration + Services.ppmm.broadcastAsyncMessage("RemotePage:Unregister", { urls: [url] }); + this.pages.delete(url); + }, + + // A listener is requesting the list of currently registered urls + initListener: function({ target: messageManager }) { + messageManager.sendAsyncMessage("RemotePage:Register", { urls: Array.from(this.pages.keys()) }) + }, + + // A remote page has been created and a port is ready in the content side + initPort: function({ target: browser, data: { url, portID } }) { + let callback = this.pages.get(url); + if (!callback) { + Cu.reportError("Unexpected remote page load: " + url); + return; + } + + let port = new ChromeMessagePort(browser, portID); + callback(port.publicPort); + } +}; + +if (Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) + RemotePageManagerInternal.init(); + +// The public API for the above object +this.RemotePageManager = { + addRemotePageListener: RemotePageManagerInternal.addRemotePageListener.bind(RemotePageManagerInternal), + removeRemotePageListener: RemotePageManagerInternal.removeRemotePageListener.bind(RemotePageManagerInternal), +}; + +// Listen for pages in any process we're loaded in +var registeredURLs = new Set(); + +var observer = (window) => { + // Strip the hash from the URL, because it's not part of the origin. + let url = window.document.documentURI.replace(/[\#|\?].*$/, ""); + if (!registeredURLs.has(url)) + return; + + // Get the frame message manager for this window so we can associate this + // page with a browser element + let messageManager = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIContentFrameMessageManager); + // Set up the child side of the message port + let port = new ChildMessagePort(messageManager, window); +}; +Services.obs.addObserver(observer, "chrome-document-global-created", false); +Services.obs.addObserver(observer, "content-document-global-created", false); + +// A message from chrome telling us what pages to listen for +Services.cpmm.addMessageListener("RemotePage:Register", ({ data }) => { + for (let url of data.urls) + registeredURLs.add(url); +}); + +// A message from chrome telling us what pages to stop listening for +Services.cpmm.addMessageListener("RemotePage:Unregister", ({ data }) => { + for (let url of data.urls) + registeredURLs.delete(url); +}); + +Services.cpmm.sendAsyncMessage("RemotePage:InitListener"); diff --git a/toolkit/modules/RemoteSecurityUI.jsm b/toolkit/modules/RemoteSecurityUI.jsm new file mode 100644 index 0000000000..a59176042e --- /dev/null +++ b/toolkit/modules/RemoteSecurityUI.jsm @@ -0,0 +1,34 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +// 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/. + +this.EXPORTED_SYMBOLS = ["RemoteSecurityUI"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +function RemoteSecurityUI() +{ + this._SSLStatus = null; + this._state = 0; +} + +RemoteSecurityUI.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsISSLStatusProvider, Ci.nsISecureBrowserUI]), + + // nsISSLStatusProvider + get SSLStatus() { return this._SSLStatus; }, + + // nsISecureBrowserUI + get state() { return this._state; }, + get tooltipText() { return ""; }, + + _update: function (aStatus, aState) { + this._SSLStatus = aStatus; + this._state = aState; + } +}; diff --git a/toolkit/modules/RemoteWebProgress.jsm b/toolkit/modules/RemoteWebProgress.jsm new file mode 100644 index 0000000000..0031d6b98c --- /dev/null +++ b/toolkit/modules/RemoteWebProgress.jsm @@ -0,0 +1,285 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +// 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/. + +this.EXPORTED_SYMBOLS = ["RemoteWebProgressManager"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +function newURI(spec) +{ + return Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService) + .newURI(spec, null, null); +} + +function RemoteWebProgressRequest(spec, originalSpec, requestCPOW) +{ + this.wrappedJSObject = this; + + this._uri = newURI(spec); + this._originalURI = newURI(originalSpec); + this._requestCPOW = requestCPOW; +} + +RemoteWebProgressRequest.prototype = { + QueryInterface : XPCOMUtils.generateQI([Ci.nsIChannel]), + + get URI() { return this._uri.clone(); }, + get originalURI() { return this._originalURI.clone(); } +}; + +function RemoteWebProgress(aManager, aIsTopLevel) { + this.wrappedJSObject = this; + + this._manager = aManager; + + this._isLoadingDocument = false; + this._DOMWindow = null; + this._DOMWindowID = 0; + this._isTopLevel = aIsTopLevel; + this._loadType = 0; +} + +RemoteWebProgress.prototype = { + NOTIFY_STATE_REQUEST: 0x00000001, + NOTIFY_STATE_DOCUMENT: 0x00000002, + NOTIFY_STATE_NETWORK: 0x00000004, + NOTIFY_STATE_WINDOW: 0x00000008, + NOTIFY_STATE_ALL: 0x0000000f, + NOTIFY_PROGRESS: 0x00000010, + NOTIFY_STATUS: 0x00000020, + NOTIFY_SECURITY: 0x00000040, + NOTIFY_LOCATION: 0x00000080, + NOTIFY_REFRESH: 0x00000100, + NOTIFY_ALL: 0x000001ff, + + get isLoadingDocument() { return this._isLoadingDocument }, + get DOMWindow() { return this._DOMWindow; }, + get DOMWindowID() { return this._DOMWindowID; }, + get isTopLevel() { return this._isTopLevel }, + get loadType() { return this._loadType; }, + + addProgressListener: function (aListener) { + this._manager.addProgressListener(aListener); + }, + + removeProgressListener: function (aListener) { + this._manager.removeProgressListener(aListener); + } +}; + +function RemoteWebProgressManager (aBrowser) { + this._topLevelWebProgress = new RemoteWebProgress(this, true); + this._progressListeners = []; + + this.swapBrowser(aBrowser); +} + +RemoteWebProgressManager.argumentsForAddonListener = function(kind, args) { + function checkType(arg, typ) { + if (!arg) { + return false; + } + return (arg instanceof typ) || + (arg instanceof Ci.nsISupports && arg.wrappedJSObject instanceof typ); + } + + // Arguments for a tabs listener are shifted over one since the + // <browser> element is passed as the first argument. + let webProgressIndex = 0; + let requestIndex = 1; + if (kind == "tabs") { + webProgressIndex = 1; + requestIndex = 2; + } + + if (checkType(args[webProgressIndex], RemoteWebProgress)) { + args[webProgressIndex] = args[webProgressIndex].wrappedJSObject._webProgressCPOW; + } + + if (checkType(args[requestIndex], RemoteWebProgressRequest)) { + args[requestIndex] = args[requestIndex].wrappedJSObject._requestCPOW; + } + + return args; +}; + +RemoteWebProgressManager.prototype = { + swapBrowser: function(aBrowser) { + if (this._messageManager) { + this._messageManager.removeMessageListener("Content:StateChange", this); + this._messageManager.removeMessageListener("Content:LocationChange", this); + this._messageManager.removeMessageListener("Content:SecurityChange", this); + this._messageManager.removeMessageListener("Content:StatusChange", this); + this._messageManager.removeMessageListener("Content:ProgressChange", this); + this._messageManager.removeMessageListener("Content:LoadURIResult", this); + } + + this._browser = aBrowser; + this._messageManager = aBrowser.messageManager; + this._messageManager.addMessageListener("Content:StateChange", this); + this._messageManager.addMessageListener("Content:LocationChange", this); + this._messageManager.addMessageListener("Content:SecurityChange", this); + this._messageManager.addMessageListener("Content:StatusChange", this); + this._messageManager.addMessageListener("Content:ProgressChange", this); + this._messageManager.addMessageListener("Content:LoadURIResult", this); + }, + + get topLevelWebProgress() { + return this._topLevelWebProgress; + }, + + addProgressListener: function (aListener) { + let listener = aListener.QueryInterface(Ci.nsIWebProgressListener); + this._progressListeners.push(listener); + }, + + removeProgressListener: function (aListener) { + this._progressListeners = + this._progressListeners.filter(l => l != aListener); + }, + + _fixSSLStatusAndState: function (aStatus, aState) { + let deserialized = null; + if (aStatus) { + let helper = Cc["@mozilla.org/network/serialization-helper;1"] + .getService(Components.interfaces.nsISerializationHelper); + + deserialized = helper.deserializeObject(aStatus) + deserialized.QueryInterface(Ci.nsISSLStatus); + } + + return [deserialized, aState]; + }, + + setCurrentURI: function (aURI) { + // This function is simpler than nsDocShell::SetCurrentURI since + // it doesn't have to deal with child docshells. + let remoteWebNav = this._browser._remoteWebNavigationImpl; + remoteWebNav._currentURI = aURI; + + let webProgress = this.topLevelWebProgress; + for (let p of this._progressListeners) { + p.onLocationChange(webProgress, null, aURI); + } + }, + + _callProgressListeners: function(methodName, ...args) { + for (let p of this._progressListeners) { + if (p[methodName]) { + try { + p[methodName].apply(p, args); + } catch (ex) { + Cu.reportError("RemoteWebProgress failed to call " + methodName + ": " + ex + "\n"); + } + } + } + }, + + receiveMessage: function (aMessage) { + let json = aMessage.json; + let objects = aMessage.objects; + // This message is a custom one we send as a result of a loadURI call. + // It shouldn't go through the same processing as all the forwarded + // webprogresslistener messages. + if (aMessage.name == "Content:LoadURIResult") { + this._browser.inLoadURI = false; + return; + } + + let webProgress = null; + let isTopLevel = json.webProgress && json.webProgress.isTopLevel; + // The top-level WebProgress is always the same, but because we don't + // really have a concept of subframes/content we always create a new object + // for those. + if (json.webProgress) { + webProgress = isTopLevel ? this._topLevelWebProgress + : new RemoteWebProgress(this, false); + + // Update the actual WebProgress fields. + webProgress._isLoadingDocument = json.webProgress.isLoadingDocument; + webProgress._DOMWindow = objects.DOMWindow; + webProgress._DOMWindowID = json.webProgress.DOMWindowID; + webProgress._loadType = json.webProgress.loadType; + webProgress._webProgressCPOW = objects.webProgress; + } + + // The WebProgressRequest object however is always dynamic. + let request = null; + if (json.requestURI) { + request = new RemoteWebProgressRequest(json.requestURI, + json.originalRequestURI, + objects.request); + } + + if (isTopLevel) { + this._browser._contentWindow = objects.contentWindow; + this._browser._documentContentType = json.documentContentType; + if (typeof json.inLoadURI != "undefined") { + this._browser.inLoadURI = json.inLoadURI; + } + if (json.charset) { + this._browser._characterSet = json.charset; + this._browser._mayEnableCharacterEncodingMenu = json.mayEnableCharacterEncodingMenu; + } + } + + switch (aMessage.name) { + case "Content:StateChange": + if (isTopLevel) { + this._browser._documentURI = newURI(json.documentURI); + } + this._callProgressListeners("onStateChange", webProgress, request, json.stateFlags, json.status); + break; + + case "Content:LocationChange": + let location = newURI(json.location); + let flags = json.flags; + let remoteWebNav = this._browser._remoteWebNavigationImpl; + + // These properties can change even for a sub-frame navigation. + remoteWebNav.canGoBack = json.canGoBack; + remoteWebNav.canGoForward = json.canGoForward; + + if (isTopLevel) { + remoteWebNav._currentURI = location; + this._browser._documentURI = newURI(json.documentURI); + this._browser._contentTitle = json.title; + this._browser._imageDocument = null; + this._browser._contentPrincipal = json.principal; + this._browser._isSyntheticDocument = json.synthetic; + this._browser._innerWindowID = json.innerWindowID; + } + + this._callProgressListeners("onLocationChange", webProgress, request, location, flags); + break; + + case "Content:SecurityChange": + let [status, state] = this._fixSSLStatusAndState(json.status, json.state); + + if (isTopLevel) { + // Invoking this getter triggers the generation of the underlying object, + // which we need to access with ._securityUI, because .securityUI returns + // a wrapper that makes _update inaccessible. + void this._browser.securityUI; + this._browser._securityUI._update(status, state); + } + + this._callProgressListeners("onSecurityChange", webProgress, request, state); + break; + + case "Content:StatusChange": + this._callProgressListeners("onStatusChange", webProgress, request, json.status, json.message); + break; + + case "Content:ProgressChange": + this._callProgressListeners("onProgressChange", webProgress, request, json.curSelf, json.maxSelf, json.curTotal, json.maxTotal); + break; + } + }, +}; diff --git a/toolkit/modules/ResetProfile.jsm b/toolkit/modules/ResetProfile.jsm new file mode 100644 index 0000000000..fe0d1cfe8e --- /dev/null +++ b/toolkit/modules/ResetProfile.jsm @@ -0,0 +1,65 @@ +/* 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 = ["ResetProfile"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/AppConstants.jsm"); + +const MOZ_APP_NAME = AppConstants.MOZ_APP_NAME; +const MOZ_BUILD_APP = AppConstants.MOZ_BUILD_APP; + +this.ResetProfile = { + /** + * Check if reset is supported for the currently running profile. + * + * @return boolean whether reset is supported. + */ + resetSupported: function() { + // Reset is only supported if the self-migrator used for reset exists. + let migrator = "@mozilla.org/profile/migrator;1?app=" + MOZ_BUILD_APP + + "&type=" + MOZ_APP_NAME; + if (!(migrator in Cc)) { + return false; + } + // We also need to be using a profile the profile manager knows about. + let profileService = Cc["@mozilla.org/toolkit/profile-service;1"]. + getService(Ci.nsIToolkitProfileService); + let currentProfileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + let profileEnumerator = profileService.profiles; + while (profileEnumerator.hasMoreElements()) { + let profile = profileEnumerator.getNext().QueryInterface(Ci.nsIToolkitProfile); + if (profile.rootDir && profile.rootDir.equals(currentProfileDir)) { + return true; + } + } + return false; + }, + + /** + * Ask the user if they wish to restart the application to reset the profile. + */ + openConfirmationDialog: function(window) { + // Prompt the user to confirm. + let params = { + reset: false, + }; + window.openDialog("chrome://global/content/resetProfile.xul", null, + "chrome,modal,centerscreen,titlebar,dialog=yes", params); + if (!params.reset) + return; + + // Set the reset profile environment variable. + let env = Cc["@mozilla.org/process/environment;1"] + .getService(Ci.nsIEnvironment); + env.set("MOZ_RESET_PROFILE_RESTART", "1"); + + let appStartup = Cc["@mozilla.org/toolkit/app-startup;1"].getService(Ci.nsIAppStartup); + appStartup.quit(Ci.nsIAppStartup.eForceQuit | Ci.nsIAppStartup.eRestart); + }, +}; diff --git a/toolkit/modules/ResponsivenessMonitor.jsm b/toolkit/modules/ResponsivenessMonitor.jsm new file mode 100644 index 0000000000..f84c6d8373 --- /dev/null +++ b/toolkit/modules/ResponsivenessMonitor.jsm @@ -0,0 +1,37 @@ +/* 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 = ["ResponsivenessMonitor"]; + +const { classes: Cc, interfaces: Ci } = Components; + +function ResponsivenessMonitor(intervalMS = 100) { + this._intervalMS = intervalMS; + this._prevTimestamp = Date.now(); + this._accumulatedDelay = 0; + this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this._timer.initWithCallback(this, this._intervalMS, Ci.nsITimer.TYPE_REPEATING_SLACK); +} + +ResponsivenessMonitor.prototype = { + notify() { + let now = Date.now(); + this._accumulatedDelay += Math.max(0, now - this._prevTimestamp - this._intervalMS); + this._prevTimestamp = now; + }, + + abort() { + if (this._timer) { + this._timer.cancel(); + this._timer = null; + } + }, + + finish() { + this.abort(); + return this._accumulatedDelay; + }, +}; diff --git a/toolkit/modules/SelectContentHelper.jsm b/toolkit/modules/SelectContentHelper.jsm new file mode 100644 index 0000000000..fd1e414050 --- /dev/null +++ b/toolkit/modules/SelectContentHelper.jsm @@ -0,0 +1,246 @@ +/* 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"; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "DOMUtils", + "@mozilla.org/inspector/dom-utils;1", "inIDOMUtils"); +XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask", + "resource://gre/modules/DeferredTask.jsm"); + +const kStateActive = 0x00000001; // NS_EVENT_STATE_ACTIVE +const kStateHover = 0x00000004; // NS_EVENT_STATE_HOVER + +// A process global state for whether or not content thinks +// that a <select> dropdown is open or not. This is managed +// entirely within this module, and is read-only accessible +// via SelectContentHelper.open. +var gOpen = false; + +this.EXPORTED_SYMBOLS = [ + "SelectContentHelper" +]; + +this.SelectContentHelper = function (aElement, aOptions, aGlobal) { + this.element = aElement; + this.initialSelection = aElement[aElement.selectedIndex] || null; + this.global = aGlobal; + this.closedWithEnter = false; + this.isOpenedViaTouch = aOptions.isOpenedViaTouch; + this.init(); + this.showDropDown(); + this._updateTimer = new DeferredTask(this._update.bind(this), 0); +} + +Object.defineProperty(SelectContentHelper, "open", { + get: function() { + return gOpen; + }, +}); + +this.SelectContentHelper.prototype = { + init: function() { + this.global.addMessageListener("Forms:SelectDropDownItem", this); + this.global.addMessageListener("Forms:DismissedDropDown", this); + this.global.addMessageListener("Forms:MouseOver", this); + this.global.addMessageListener("Forms:MouseOut", this); + this.global.addEventListener("pagehide", this); + this.global.addEventListener("mozhidedropdown", this); + let MutationObserver = this.element.ownerDocument.defaultView.MutationObserver; + this.mut = new MutationObserver(mutations => { + // Something changed the <select> while it was open, so + // we'll poke a DeferredTask to update the parent sometime + // in the very near future. + this._updateTimer.arm(); + }); + this.mut.observe(this.element, {childList: true, subtree: true}); + }, + + uninit: function() { + this.element.openInParentProcess = false; + this.global.removeMessageListener("Forms:SelectDropDownItem", this); + this.global.removeMessageListener("Forms:DismissedDropDown", this); + this.global.removeMessageListener("Forms:MouseOver", this); + this.global.removeMessageListener("Forms:MouseOut", this); + this.global.removeEventListener("pagehide", this); + this.global.removeEventListener("mozhidedropdown", this); + this.element = null; + this.global = null; + this.mut.disconnect(); + this._updateTimer.disarm(); + this._updateTimer = null; + gOpen = false; + }, + + showDropDown: function() { + this.element.openInParentProcess = true; + let rect = this._getBoundingContentRect(); + this.global.sendAsyncMessage("Forms:ShowDropDown", { + rect: rect, + options: this._buildOptionList(), + selectedIndex: this.element.selectedIndex, + direction: getComputedStyles(this.element).direction, + isOpenedViaTouch: this.isOpenedViaTouch + }); + gOpen = true; + }, + + _getBoundingContentRect: function() { + return BrowserUtils.getElementBoundingScreenRect(this.element); + }, + + _buildOptionList: function() { + return buildOptionListForChildren(this.element); + }, + + _update() { + // The <select> was updated while the dropdown was open. + // Let's send up a new list of options. + this.global.sendAsyncMessage("Forms:UpdateDropDown", { + options: this._buildOptionList(), + selectedIndex: this.element.selectedIndex, + }); + }, + + receiveMessage: function(message) { + switch (message.name) { + case "Forms:SelectDropDownItem": + this.element.selectedIndex = message.data.value; + this.closedWithEnter = message.data.closedWithEnter; + break; + + case "Forms:DismissedDropDown": + let selectedOption = this.element.item(this.element.selectedIndex); + if (this.initialSelection != selectedOption) { + let win = this.element.ownerDocument.defaultView; + // For ordering of events, we're using non-e10s as our guide here, + // since the spec isn't exactly clear. In non-e10s, we fire: + // mousedown, mouseup, input, change, click if the user clicks + // on an element in the dropdown. If the user uses the keyboard + // to select an element in the dropdown, we only fire input and + // change events. + if (!this.closedWithEnter) { + const MOUSE_EVENTS = ["mousedown", "mouseup"]; + for (let eventName of MOUSE_EVENTS) { + let mouseEvent = new win.MouseEvent(eventName, { + view: win, + bubbles: true, + cancelable: true, + }); + selectedOption.dispatchEvent(mouseEvent); + } + DOMUtils.removeContentState(this.element, kStateActive); + } + + let inputEvent = new win.UIEvent("input", { + bubbles: true, + }); + this.element.dispatchEvent(inputEvent); + + let changeEvent = new win.Event("change", { + bubbles: true, + }); + this.element.dispatchEvent(changeEvent); + + if (!this.closedWithEnter) { + let mouseEvent = new win.MouseEvent("click", { + view: win, + bubbles: true, + cancelable: true, + }); + selectedOption.dispatchEvent(mouseEvent); + } + } + + this.uninit(); + break; + + case "Forms:MouseOver": + DOMUtils.setContentState(this.element, kStateHover); + break; + + case "Forms:MouseOut": + DOMUtils.removeContentState(this.element, kStateHover); + break; + + } + }, + + handleEvent: function(event) { + switch (event.type) { + case "pagehide": + if (this.element.ownerDocument === event.target) { + this.global.sendAsyncMessage("Forms:HideDropDown", {}); + this.uninit(); + } + break; + case "mozhidedropdown": + if (this.element === event.target) { + this.global.sendAsyncMessage("Forms:HideDropDown", {}); + this.uninit(); + } + break; + } + } + +} + +function getComputedStyles(element) { + return element.ownerDocument.defaultView.getComputedStyle(element); +} + +function buildOptionListForChildren(node) { + let result = []; + + let win = node.ownerDocument.defaultView; + + for (let child of node.children) { + let tagName = child.tagName.toUpperCase(); + + if (tagName == 'OPTION' || tagName == 'OPTGROUP') { + if (child.hidden) { + continue; + } + + let textContent = + tagName == 'OPTGROUP' ? child.getAttribute("label") + : child.text; + if (textContent == null) { + textContent = ""; + } + + let cs = getComputedStyles(child); + + let info = { + index: child.index, + tagName: tagName, + textContent: textContent, + disabled: child.disabled, + display: cs.display, + // We need to do this for every option element as each one can have + // an individual style set for direction + textDirection: cs.direction, + tooltip: child.title, + // XXX this uses a highlight color when this is the selected element. + // We need to suppress such highlighting in the content process to get + // the option's correct unhighlighted color here. + // We also need to detect default color vs. custom so that a standard + // color does not override color: menutext in the parent. + // backgroundColor: computedStyle.backgroundColor, + // color: computedStyle.color, + children: tagName == 'OPTGROUP' ? buildOptionListForChildren(child) : [] + }; + result.push(info); + } + } + return result; +} diff --git a/toolkit/modules/SelectParentHelper.jsm b/toolkit/modules/SelectParentHelper.jsm new file mode 100644 index 0000000000..2d37cdd5ee --- /dev/null +++ b/toolkit/modules/SelectParentHelper.jsm @@ -0,0 +1,211 @@ +/* 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 = [ + "SelectParentHelper" +]; + +// Maximum number of rows to display in the select dropdown. +const MAX_ROWS = 20; + +var currentBrowser = null; +var currentMenulist = null; +var currentZoom = 1; +var closedWithEnter = false; + +this.SelectParentHelper = { + populate: function(menulist, items, selectedIndex, zoom) { + // Clear the current contents of the popup + menulist.menupopup.textContent = ""; + currentZoom = zoom; + currentMenulist = menulist; + populateChildren(menulist, items, selectedIndex, zoom); + }, + + open: function(browser, menulist, rect, isOpenedViaTouch) { + menulist.hidden = false; + currentBrowser = browser; + closedWithEnter = false; + this._registerListeners(browser, menulist.menupopup); + + let win = browser.ownerDocument.defaultView; + + // Set the maximum height to show exactly MAX_ROWS items. + let menupopup = menulist.menupopup; + let firstItem = menupopup.firstChild; + while (firstItem && firstItem.hidden) { + firstItem = firstItem.nextSibling; + } + + if (firstItem) { + let itemHeight = firstItem.getBoundingClientRect().height; + + // Include the padding and border on the popup. + let cs = win.getComputedStyle(menupopup); + let bpHeight = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) + + parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom); + menupopup.style.maxHeight = (itemHeight * MAX_ROWS + bpHeight) + "px"; + } + + menupopup.classList.toggle("isOpenedViaTouch", isOpenedViaTouch); + + let constraintRect = browser.getBoundingClientRect(); + constraintRect = new win.DOMRect(constraintRect.left + win.mozInnerScreenX, + constraintRect.top + win.mozInnerScreenY, + constraintRect.width, constraintRect.height); + menupopup.setConstraintRect(constraintRect); + menupopup.openPopupAtScreenRect("after_start", rect.left, rect.top, rect.width, rect.height, false, false); + }, + + hide: function(menulist, browser) { + if (currentBrowser == browser) { + menulist.menupopup.hidePopup(); + } + }, + + handleEvent: function(event) { + switch (event.type) { + case "mouseover": + currentBrowser.messageManager.sendAsyncMessage("Forms:MouseOver", {}); + break; + + case "mouseout": + currentBrowser.messageManager.sendAsyncMessage("Forms:MouseOut", {}); + break; + + case "keydown": + if (event.keyCode == event.DOM_VK_RETURN) { + closedWithEnter = true; + } + break; + + case "command": + if (event.target.hasAttribute("value")) { + let win = currentBrowser.ownerDocument.defaultView; + + currentBrowser.messageManager.sendAsyncMessage("Forms:SelectDropDownItem", { + value: event.target.value, + closedWithEnter: closedWithEnter + }); + } + break; + + case "fullscreen": + if (currentMenulist) { + currentMenulist.menupopup.hidePopup(); + } + break; + + case "popuphidden": + currentBrowser.messageManager.sendAsyncMessage("Forms:DismissedDropDown", {}); + let popup = event.target; + this._unregisterListeners(currentBrowser, popup); + popup.parentNode.hidden = true; + currentBrowser = null; + currentMenulist = null; + currentZoom = 1; + break; + } + }, + + receiveMessage(msg) { + if (msg.name == "Forms:UpdateDropDown") { + // Sanity check - we'd better know what the currently + // opened menulist is, and what browser it belongs to... + if (!currentMenulist || !currentBrowser) { + return; + } + + let options = msg.data.options; + let selectedIndex = msg.data.selectedIndex; + this.populate(currentMenulist, options, selectedIndex, currentZoom); + } + }, + + _registerListeners: function(browser, popup) { + popup.addEventListener("command", this); + popup.addEventListener("popuphidden", this); + popup.addEventListener("mouseover", this); + popup.addEventListener("mouseout", this); + browser.ownerDocument.defaultView.addEventListener("keydown", this, true); + browser.ownerDocument.defaultView.addEventListener("fullscreen", this, true); + browser.messageManager.addMessageListener("Forms:UpdateDropDown", this); + }, + + _unregisterListeners: function(browser, popup) { + popup.removeEventListener("command", this); + popup.removeEventListener("popuphidden", this); + popup.removeEventListener("mouseover", this); + popup.removeEventListener("mouseout", this); + browser.ownerDocument.defaultView.removeEventListener("keydown", this, true); + browser.ownerDocument.defaultView.removeEventListener("fullscreen", this, true); + browser.messageManager.removeMessageListener("Forms:UpdateDropDown", this); + }, + +}; + +function populateChildren(menulist, options, selectedIndex, zoom, + parentElement = null, isGroupDisabled = false, adjustedTextSize = -1) { + let element = menulist.menupopup; + + // -1 just means we haven't calculated it yet. When we recurse through this function + // we will pass in adjustedTextSize to save on recalculations. + if (adjustedTextSize == -1) { + let win = element.ownerDocument.defaultView; + + // Grab the computed text size and multiply it by the remote browser's fullZoom to ensure + // the popup's text size is matched with the content's. We can't just apply a CSS transform + // here as the popup's preferred size is calculated pre-transform. + let textSize = win.getComputedStyle(element).getPropertyValue("font-size"); + adjustedTextSize = (zoom * parseFloat(textSize, 10)) + "px"; + } + + for (let option of options) { + let isOptGroup = (option.tagName == 'OPTGROUP'); + let item = element.ownerDocument.createElement(isOptGroup ? "menucaption" : "menuitem"); + + item.setAttribute("label", option.textContent); + item.style.direction = option.textDirection; + item.style.fontSize = adjustedTextSize; + item.hidden = option.display == "none" || (parentElement && parentElement.hidden); + item.setAttribute("tooltiptext", option.tooltip); + + element.appendChild(item); + + // A disabled optgroup disables all of its child options. + let isDisabled = isGroupDisabled || option.disabled; + if (isDisabled) { + item.setAttribute("disabled", "true"); + } + + if (isOptGroup) { + populateChildren(menulist, option.children, selectedIndex, zoom, + item, isDisabled, adjustedTextSize); + } else { + if (option.index == selectedIndex) { + // We expect the parent element of the popup to be a <xul:menulist> that + // has the popuponly attribute set to "true". This is necessary in order + // for a <xul:menupopup> to act like a proper <html:select> dropdown, as + // the <xul:menulist> does things like remember state and set the + // _moz-menuactive attribute on the selected <xul:menuitem>. + menulist.selectedItem = item; + + // It's hack time. In the event that we've re-populated the menulist due + // to a mutation in the <select> in content, that means that the -moz_activemenu + // may have been removed from the selected item. Since that's normally only + // set for the initially selected on popupshowing for the menulist, and we + // don't want to close and re-open the popup, we manually set it here. + menulist.menuBoxObject.activeChild = item; + } + + item.setAttribute("value", option.index); + + if (parentElement) { + item.classList.add("contentSelectDropdown-ingroup") + } + } + } +} diff --git a/toolkit/modules/ServiceRequest.jsm b/toolkit/modules/ServiceRequest.jsm new file mode 100644 index 0000000000..92afec1133 --- /dev/null +++ b/toolkit/modules/ServiceRequest.jsm @@ -0,0 +1,49 @@ +/* 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"; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +/** + * This module consolidates various code and data update requests, so flags + * can be set, Telemetry collected, etc. in a central place. + */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.importGlobalProperties(["XMLHttpRequest"]); + +this.EXPORTED_SYMBOLS = [ "ServiceRequest" ]; + +const logger = Log.repository.getLogger("ServiceRequest"); +logger.level = Log.Level.Debug; +logger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); + +/** + * ServiceRequest is intended to be a drop-in replacement for current users + * of XMLHttpRequest. + * + * @param {Object} options - Options for underlying XHR, e.g. { mozAnon: bool } + */ +class ServiceRequest extends XMLHttpRequest { + constructor(options) { + super(options); + } + /** + * Opens an XMLHttpRequest, and sets the NSS "beConservative" flag. + * Requests are always async. + * + * @param {String} method - HTTP method to use, e.g. "GET". + * @param {String} url - URL to open. + * @param {Object} options - Additional options (reserved for future use). + */ + open(method, url, options) { + super.open(method, url, true); + + // Disable cutting edge features, like TLS 1.3, where middleboxes might brick us + if (super.channel instanceof Ci.nsIHttpChannelInternal) { + super.channel.QueryInterface(Ci.nsIHttpChannelInternal).beConservative = true; + } + } +} diff --git a/toolkit/modules/Services.jsm b/toolkit/modules/Services.jsm new file mode 100644 index 0000000000..1a6c3ea87d --- /dev/null +++ b/toolkit/modules/Services.jsm @@ -0,0 +1,117 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["Services"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cr = Components.results; + +Components.utils.import("resource://gre/modules/AppConstants.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +this.Services = {}; + +XPCOMUtils.defineLazyGetter(Services, "prefs", function () { + return Cc["@mozilla.org/preferences-service;1"] + .getService(Ci.nsIPrefService) + .QueryInterface(Ci.nsIPrefBranch); +}); + +XPCOMUtils.defineLazyGetter(Services, "appinfo", function () { + let appinfo = Cc["@mozilla.org/xre/app-info;1"] + .getService(Ci.nsIXULRuntime); + try { + appinfo.QueryInterface(Ci.nsIXULAppInfo); + } catch (ex) { + // Not all applications implement nsIXULAppInfo (e.g. xpcshell doesn't). + if (!(ex instanceof Components.Exception) || ex.result != Cr.NS_NOINTERFACE) { + throw ex; + } + } + return appinfo; +}); + +XPCOMUtils.defineLazyGetter(Services, "dirsvc", function () { + return Cc["@mozilla.org/file/directory_service;1"] + .getService(Ci.nsIDirectoryService) + .QueryInterface(Ci.nsIProperties); +}); + +if (AppConstants.MOZ_CRASHREPORTER) { + XPCOMUtils.defineLazyGetter(Services, "crashmanager", () => { + let ns = {}; + Components.utils.import("resource://gre/modules/CrashManager.jsm", ns); + + return ns.CrashManager.Singleton; + }); +} + +XPCOMUtils.defineLazyGetter(Services, "mm", () => { + return Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageBroadcaster) + .QueryInterface(Ci.nsIFrameScriptLoader); +}); + +XPCOMUtils.defineLazyGetter(Services, "ppmm", () => { + return Cc["@mozilla.org/parentprocessmessagemanager;1"] + .getService(Ci.nsIMessageBroadcaster) + .QueryInterface(Ci.nsIProcessScriptLoader); +}); + +var initTable = [ + ["androidBridge", "@mozilla.org/android/bridge;1", "nsIAndroidBridge", + AppConstants.platform == "android"], + ["appShell", "@mozilla.org/appshell/appShellService;1", "nsIAppShellService"], + ["cache", "@mozilla.org/network/cache-service;1", "nsICacheService"], + ["cache2", "@mozilla.org/netwerk/cache-storage-service;1", "nsICacheStorageService"], + ["cpmm", "@mozilla.org/childprocessmessagemanager;1", "nsIMessageSender"], + ["console", "@mozilla.org/consoleservice;1", "nsIConsoleService"], + ["contentPrefs", "@mozilla.org/content-pref/service;1", "nsIContentPrefService"], + ["cookies", "@mozilla.org/cookiemanager;1", "nsICookieManager2"], + ["downloads", "@mozilla.org/download-manager;1", "nsIDownloadManager"], + ["droppedLinkHandler", "@mozilla.org/content/dropped-link-handler;1", "nsIDroppedLinkHandler"], + ["els", "@mozilla.org/eventlistenerservice;1", "nsIEventListenerService"], + ["eTLD", "@mozilla.org/network/effective-tld-service;1", "nsIEffectiveTLDService"], + ["io", "@mozilla.org/network/io-service;1", "nsIIOService2"], + ["locale", "@mozilla.org/intl/nslocaleservice;1", "nsILocaleService"], + ["logins", "@mozilla.org/login-manager;1", "nsILoginManager"], + ["obs", "@mozilla.org/observer-service;1", "nsIObserverService"], + ["perms", "@mozilla.org/permissionmanager;1", "nsIPermissionManager"], + ["prompt", "@mozilla.org/embedcomp/prompt-service;1", "nsIPromptService"], + ["profiler", "@mozilla.org/tools/profiler;1", "nsIProfiler", + AppConstants.MOZ_ENABLE_PROFILER_SPS], + ["scriptloader", "@mozilla.org/moz/jssubscript-loader;1", "mozIJSSubScriptLoader"], + ["scriptSecurityManager", "@mozilla.org/scriptsecuritymanager;1", "nsIScriptSecurityManager"], + ["search", "@mozilla.org/browser/search-service;1", "nsIBrowserSearchService", + AppConstants.MOZ_TOOLKIT_SEARCH], + ["storage", "@mozilla.org/storage/service;1", "mozIStorageService"], + ["domStorageManager", "@mozilla.org/dom/localStorage-manager;1", "nsIDOMStorageManager"], + ["strings", "@mozilla.org/intl/stringbundle;1", "nsIStringBundleService"], + ["telemetry", "@mozilla.org/base/telemetry;1", "nsITelemetry"], + ["tm", "@mozilla.org/thread-manager;1", "nsIThreadManager"], + ["urlFormatter", "@mozilla.org/toolkit/URLFormatterService;1", "nsIURLFormatter"], + ["vc", "@mozilla.org/xpcom/version-comparator;1", "nsIVersionComparator"], + ["wm", "@mozilla.org/appshell/window-mediator;1", "nsIWindowMediator"], + ["ww", "@mozilla.org/embedcomp/window-watcher;1", "nsIWindowWatcher"], + ["startup", "@mozilla.org/toolkit/app-startup;1", "nsIAppStartup"], + ["sysinfo", "@mozilla.org/system-info;1", "nsIPropertyBag2"], + ["clipboard", "@mozilla.org/widget/clipboard;1", "nsIClipboard"], + ["DOMRequest", "@mozilla.org/dom/dom-request-service;1", "nsIDOMRequestService"], + ["focus", "@mozilla.org/focus-manager;1", "nsIFocusManager"], + ["uriFixup", "@mozilla.org/docshell/urifixup;1", "nsIURIFixup"], + ["blocklist", "@mozilla.org/extensions/blocklist;1", "nsIBlocklistService"], + ["netUtils", "@mozilla.org/network/util;1", "nsINetUtil"], + ["loadContextInfo", "@mozilla.org/load-context-info-factory;1", "nsILoadContextInfoFactory"], + ["qms", "@mozilla.org/dom/quota-manager-service;1", "nsIQuotaManagerService"], +]; + +initTable.forEach(([name, contract, intf, enabled = true]) => { + if (enabled) { + XPCOMUtils.defineLazyServiceGetter(Services, name, contract, intf); + } +}); + + +initTable = undefined; diff --git a/toolkit/modules/SessionRecorder.jsm b/toolkit/modules/SessionRecorder.jsm new file mode 100644 index 0000000000..174be08e31 --- /dev/null +++ b/toolkit/modules/SessionRecorder.jsm @@ -0,0 +1,403 @@ +/* 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 = [ + "SessionRecorder", +]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-common/utils.js"); + +// We automatically prune sessions older than this. +const MAX_SESSION_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days. +const STARTUP_RETRY_INTERVAL_MS = 5000; + +// Wait up to 5 minutes for startup measurements before giving up. +const MAX_STARTUP_TRIES = 300000 / STARTUP_RETRY_INTERVAL_MS; + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "SessionRecorder::"; + +/** + * Records information about browser sessions. + * + * This serves as an interface to both current session information as + * well as a history of previous sessions. + * + * Typically only one instance of this will be installed in an + * application. It is typically managed by an XPCOM service. The + * instance is instantiated at application start; onStartup is called + * once the profile is installed; onShutdown is called during shutdown. + * + * We currently record state in preferences. However, this should be + * invisible to external consumers. We could easily swap in a different + * storage mechanism if desired. + * + * Please note the different semantics for storing times and dates in + * preferences. Full dates (notably the session start time) are stored + * as strings because preferences have a 32-bit limit on integer values + * and milliseconds since UNIX epoch would overflow. Many times are + * stored as integer offsets from the session start time because they + * should not overflow 32 bits. + * + * Since this records history of all sessions, there is a possibility + * for unbounded data aggregation. This is curtailed through: + * + * 1) An "idle-daily" observer which delete sessions older than + * MAX_SESSION_AGE_MS. + * 2) The creator of this instance explicitly calling + * `pruneOldSessions`. + * + * @param branch + * (string) Preferences branch on which to record state. + */ +this.SessionRecorder = function (branch) { + if (!branch) { + throw new Error("branch argument must be defined."); + } + + if (!branch.endsWith(".")) { + throw new Error("branch argument must end with '.': " + branch); + } + + this._log = Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX); + + this._prefs = new Preferences(branch); + this._lastActivityWasInactive = false; + this._activeTicks = 0; + this.fineTotalTime = 0; + this._started = false; + this._timer = null; + this._startupFieldTries = 0; + + this._os = Cc["@mozilla.org/observer-service;1"] + .getService(Ci.nsIObserverService); + +}; + +SessionRecorder.prototype = Object.freeze({ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), + + STARTUP_RETRY_INTERVAL_MS: STARTUP_RETRY_INTERVAL_MS, + + get _currentIndex() { + return this._prefs.get("currentIndex", 0); + }, + + set _currentIndex(value) { + this._prefs.set("currentIndex", value); + }, + + get _prunedIndex() { + return this._prefs.get("prunedIndex", 0); + }, + + set _prunedIndex(value) { + this._prefs.set("prunedIndex", value); + }, + + get startDate() { + return CommonUtils.getDatePref(this._prefs, "current.startTime"); + }, + + set _startDate(value) { + CommonUtils.setDatePref(this._prefs, "current.startTime", value); + }, + + get activeTicks() { + return this._prefs.get("current.activeTicks", 0); + }, + + incrementActiveTicks: function () { + this._prefs.set("current.activeTicks", ++this._activeTicks); + }, + + /** + * Total time of this session in integer seconds. + * + * See also fineTotalTime for the time in milliseconds. + */ + get totalTime() { + return this._prefs.get("current.totalTime", 0); + }, + + updateTotalTime: function () { + // We store millisecond precision internally to prevent drift from + // repeated rounding. + this.fineTotalTime = Date.now() - this.startDate; + this._prefs.set("current.totalTime", Math.floor(this.fineTotalTime / 1000)); + }, + + get main() { + return this._prefs.get("current.main", -1); + }, + + set _main(value) { + if (!Number.isInteger(value)) { + throw new Error("main time must be an integer."); + } + + this._prefs.set("current.main", value); + }, + + get firstPaint() { + return this._prefs.get("current.firstPaint", -1); + }, + + set _firstPaint(value) { + if (!Number.isInteger(value)) { + throw new Error("firstPaint must be an integer."); + } + + this._prefs.set("current.firstPaint", value); + }, + + get sessionRestored() { + return this._prefs.get("current.sessionRestored", -1); + }, + + set _sessionRestored(value) { + if (!Number.isInteger(value)) { + throw new Error("sessionRestored must be an integer."); + } + + this._prefs.set("current.sessionRestored", value); + }, + + getPreviousSessions: function () { + let result = {}; + + for (let i = this._prunedIndex; i < this._currentIndex; i++) { + let s = this.getPreviousSession(i); + if (!s) { + continue; + } + + result[i] = s; + } + + return result; + }, + + getPreviousSession: function (index) { + return this._deserialize(this._prefs.get("previous." + index)); + }, + + /** + * Prunes old, completed sessions that started earlier than the + * specified date. + */ + pruneOldSessions: function (date) { + for (let i = this._prunedIndex; i < this._currentIndex; i++) { + let s = this.getPreviousSession(i); + if (!s) { + continue; + } + + if (s.startDate >= date) { + continue; + } + + this._log.debug("Pruning session #" + i + "."); + this._prefs.reset("previous." + i); + this._prunedIndex = i; + } + }, + + recordStartupFields: function () { + let si = this._getStartupInfo(); + + if (!si.process) { + throw new Error("Startup info not available."); + } + + let missing = false; + + for (let field of ["main", "firstPaint", "sessionRestored"]) { + if (!(field in si)) { + this._log.debug("Missing startup field: " + field); + missing = true; + continue; + } + + this["_" + field] = si[field].getTime() - si.process.getTime(); + } + + if (!missing || this._startupFieldTries > MAX_STARTUP_TRIES) { + this._clearStartupTimer(); + return; + } + + // If we have missing fields, install a timer and keep waiting for + // data. + this._startupFieldTries++; + + if (!this._timer) { + this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this._timer.initWithCallback({ + notify: this.recordStartupFields.bind(this), + }, this.STARTUP_RETRY_INTERVAL_MS, this._timer.TYPE_REPEATING_SLACK); + } + }, + + _clearStartupTimer: function () { + if (this._timer) { + this._timer.cancel(); + delete this._timer; + } + }, + + /** + * Perform functionality on application startup. + * + * This is typically called in a "profile-do-change" handler. + */ + onStartup: function () { + if (this._started) { + throw new Error("onStartup has already been called."); + } + + let si = this._getStartupInfo(); + if (!si.process) { + throw new Error("Process information not available. Misconfigured app?"); + } + + this._started = true; + + this._os.addObserver(this, "profile-before-change", false); + this._os.addObserver(this, "user-interaction-active", false); + this._os.addObserver(this, "user-interaction-inactive", false); + this._os.addObserver(this, "idle-daily", false); + + // This has the side-effect of clearing current session state. + this._moveCurrentToPrevious(); + + this._startDate = si.process; + this._prefs.set("current.activeTicks", 0); + this.updateTotalTime(); + + this.recordStartupFields(); + }, + + /** + * Record application activity. + */ + onActivity: function (active) { + let updateActive = active && !this._lastActivityWasInactive; + this._lastActivityWasInactive = !active; + + this.updateTotalTime(); + + if (updateActive) { + this.incrementActiveTicks(); + } + }, + + onShutdown: function () { + this._log.info("Recording clean session shutdown."); + this._prefs.set("current.clean", true); + this.updateTotalTime(); + this._clearStartupTimer(); + + this._os.removeObserver(this, "profile-before-change"); + this._os.removeObserver(this, "user-interaction-active"); + this._os.removeObserver(this, "user-interaction-inactive"); + this._os.removeObserver(this, "idle-daily"); + }, + + _CURRENT_PREFS: [ + "current.startTime", + "current.activeTicks", + "current.totalTime", + "current.main", + "current.firstPaint", + "current.sessionRestored", + "current.clean", + ], + + // This is meant to be called only during onStartup(). + _moveCurrentToPrevious: function () { + try { + if (!this.startDate.getTime()) { + this._log.info("No previous session. Is this first app run?"); + return; + } + + let clean = this._prefs.get("current.clean", false); + + let count = this._currentIndex++; + let obj = { + s: this.startDate.getTime(), + a: this.activeTicks, + t: this.totalTime, + c: clean, + m: this.main, + fp: this.firstPaint, + sr: this.sessionRestored, + }; + + this._log.debug("Recording last sessions as #" + count + "."); + this._prefs.set("previous." + count, JSON.stringify(obj)); + } catch (ex) { + this._log.warn("Exception when migrating last session", ex); + } finally { + this._log.debug("Resetting prefs from last session."); + for (let pref of this._CURRENT_PREFS) { + this._prefs.reset(pref); + } + } + }, + + _deserialize: function (s) { + let o; + try { + o = JSON.parse(s); + } catch (ex) { + return null; + } + + return { + startDate: new Date(o.s), + activeTicks: o.a, + totalTime: o.t, + clean: !!o.c, + main: o.m, + firstPaint: o.fp, + sessionRestored: o.sr, + }; + }, + + // Implemented as a function to allow for monkeypatching in tests. + _getStartupInfo: function () { + return Cc["@mozilla.org/toolkit/app-startup;1"] + .getService(Ci.nsIAppStartup) + .getStartupInfo(); + }, + + observe: function (subject, topic, data) { + switch (topic) { + case "profile-before-change": + this.onShutdown(); + break; + + case "user-interaction-active": + this.onActivity(true); + break; + + case "user-interaction-inactive": + this.onActivity(false); + break; + + case "idle-daily": + this.pruneOldSessions(new Date(Date.now() - MAX_SESSION_AGE_MS)); + break; + } + }, +}); diff --git a/toolkit/modules/ShortcutUtils.jsm b/toolkit/modules/ShortcutUtils.jsm new file mode 100644 index 0000000000..fed0950b06 --- /dev/null +++ b/toolkit/modules/ShortcutUtils.jsm @@ -0,0 +1,113 @@ +/* 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 = ["ShortcutUtils"]; + +const Cu = Components.utils; +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "PlatformKeys", function() { + return Services.strings.createBundle( + "chrome://global-platform/locale/platformKeys.properties"); +}); + +XPCOMUtils.defineLazyGetter(this, "Keys", function() { + return Services.strings.createBundle( + "chrome://global/locale/keys.properties"); +}); + +var ShortcutUtils = { + /** + * Prettifies the modifier keys for an element. + * + * @param Node aElemKey + * The key element to get the modifiers from. + * @param boolean aNoCloverLeaf + * Pass true to use a descriptive string instead of the cloverleaf symbol. (OS X only) + * @return string + * A prettified and properly separated modifier keys string. + */ + prettifyShortcut: function(aElemKey, aNoCloverLeaf) { + let elemString = ""; + let elemMod = aElemKey.getAttribute("modifiers"); + let haveCloverLeaf = false; + + if (elemMod.match("accel")) { + if (Services.appinfo.OS == "Darwin") { + // XXX bug 779642 Use "Cmd-" literal vs. cloverleaf meta-key until + // Orion adds variable height lines. + if (aNoCloverLeaf) { + elemString += "Cmd-"; + } else { + haveCloverLeaf = true; + } + } else { + elemString += PlatformKeys.GetStringFromName("VK_CONTROL") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + } + if (elemMod.match("access")) { + if (Services.appinfo.OS == "Darwin") { + elemString += PlatformKeys.GetStringFromName("VK_CONTROL") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } else { + elemString += PlatformKeys.GetStringFromName("VK_ALT") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + } + if (elemMod.match("os")) { + elemString += PlatformKeys.GetStringFromName("VK_WIN") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + if (elemMod.match("shift")) { + elemString += PlatformKeys.GetStringFromName("VK_SHIFT") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + if (elemMod.match("alt")) { + elemString += PlatformKeys.GetStringFromName("VK_ALT") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + if (elemMod.match("ctrl") || elemMod.match("control")) { + elemString += PlatformKeys.GetStringFromName("VK_CONTROL") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + if (elemMod.match("meta")) { + elemString += PlatformKeys.GetStringFromName("VK_META") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + + if (haveCloverLeaf) { + elemString += PlatformKeys.GetStringFromName("VK_META") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + + let key; + let keyCode = aElemKey.getAttribute("keycode"); + if (keyCode) { + try { + // Some keys might not exist in the locale file, which will throw: + key = Keys.GetStringFromName(keyCode.toUpperCase()); + } catch (ex) { + Cu.reportError("Error finding " + keyCode + ": " + ex); + key = keyCode.replace(/^VK_/, ''); + } + } else { + key = aElemKey.getAttribute("key"); + key = key.toUpperCase(); + } + return elemString + key; + }, + + findShortcut: function(aElemCommand) { + let document = aElemCommand.ownerDocument; + return document.querySelector("key[command=\"" + aElemCommand.getAttribute("id") + "\"]"); + } +}; + +Object.freeze(ShortcutUtils); + +this.ShortcutUtils = ShortcutUtils; diff --git a/toolkit/modules/Sntp.jsm b/toolkit/modules/Sntp.jsm new file mode 100644 index 0000000000..6041c5f15c --- /dev/null +++ b/toolkit/modules/Sntp.jsm @@ -0,0 +1,334 @@ +/* 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 = [ + "Sntp", +]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +// Set to true to see debug messages. +var DEBUG = false; + +/** + * Constructor of Sntp. + * + * @param dataAvailableCb + * Callback function gets called when SNTP offset available. Signature + * is function dataAvailableCb(offsetInMS). + * @param maxRetryCount + * Maximum retry count when SNTP failed to connect to server; set to + * zero to disable the retry. + * @param refreshPeriodInSecs + * Refresh period; set to zero to disable refresh. + * @param timeoutInSecs + * Timeout value used for connection. + * @param pools + * SNTP server lists separated by ';'. + * @param port + * SNTP port. + */ +this.Sntp = function Sntp(dataAvailableCb, maxRetryCount, refreshPeriodInSecs, + timeoutInSecs, pools, port) { + if (dataAvailableCb != null) { + this._dataAvailableCb = dataAvailableCb; + } + if (maxRetryCount != null) { + this._maxRetryCount = maxRetryCount; + } + if (refreshPeriodInSecs != null) { + this._refreshPeriodInMS = refreshPeriodInSecs * 1000; + } + if (timeoutInSecs != null) { + this._timeoutInMS = timeoutInSecs * 1000; + } + if (pools != null && Array.isArray(pools) && pools.length > 0) { + this._pools = pools; + } + if (port != null) { + this._port = port; + } +} + +Sntp.prototype = { + isAvailable: function isAvailable() { + return this._cachedOffset != null; + }, + + isExpired: function isExpired() { + let valid = this._cachedOffset != null && this._cachedTimeInMS != null; + if (this._refreshPeriodInMS > 0) { + valid = valid && Date.now() < this._cachedTimeInMS + + this._refreshPeriodInMS; + } + return !valid; + }, + + request: function request() { + this._request(); + }, + + getOffset: function getOffset() { + return this._cachedOffset; + }, + + /** + * Indicates the system clock has been changed by [offset]ms so we need to + * adjust the stored value. + */ + updateOffset: function updateOffset(offset) { + if (this._cachedOffset != null) { + this._cachedOffset -= offset; + } + }, + + /** + * Used to schedule a retry or periodic updates. + */ + _schedule: function _schedule(timeInMS) { + if (this._updateTimer == null) { + this._updateTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + } + + this._updateTimer.initWithCallback(this._request.bind(this), + timeInMS, + Ci.nsITimer.TYPE_ONE_SHOT); + debug("Scheduled SNTP request in " + timeInMS + "ms"); + }, + + /** + * Handle the SNTP response. + */ + _handleSntp: function _handleSntp(originateTimeInMS, receiveTimeInMS, + transmitTimeInMS, respondTimeInMS) { + let clockOffset = Math.floor(((receiveTimeInMS - originateTimeInMS) + + (transmitTimeInMS - respondTimeInMS)) / 2); + debug("Clock offset: " + clockOffset); + + // We've succeeded so clear the retry status. + this._retryCount = 0; + this._retryPeriodInMS = 0; + + // Cache the latest SNTP offset whenever receiving it. + this._cachedOffset = clockOffset; + this._cachedTimeInMS = respondTimeInMS; + + if (this._dataAvailableCb != null) { + this._dataAvailableCb(clockOffset); + } + + this._schedule(this._refreshPeriodInMS); + }, + + /** + * Used for retry SNTP requests. + */ + _retry: function _retry() { + this._retryCount++; + if (this._retryCount > this._maxRetryCount) { + debug ("stop retrying SNTP"); + // Clear so we can start with clean status next time we have network. + this._retryCount = 0; + this._retryPeriodInMS = 0; + return; + } + this._retryPeriodInMS = Math.max(1000, this._retryPeriodInMS * 2); + + this._schedule(this._retryPeriodInMS); + }, + + /** + * Request SNTP. + */ + _request: function _request() { + function GetRequest() { + let NTP_PACKET_SIZE = 48; + let NTP_MODE_CLIENT = 3; + let NTP_VERSION = 3; + let TRANSMIT_TIME_OFFSET = 40; + + // Send the NTP request. + let requestTimeInMS = Date.now(); + let s = requestTimeInMS / 1000; + let ms = requestTimeInMS % 1000; + // NTP time is relative to 1900. + s += OFFSET_1900_TO_1970; + let f = ms * 0x100000000 / 1000; + s = Math.floor(s); + f = Math.floor(f); + + let buffer = new ArrayBuffer(NTP_PACKET_SIZE); + let data = new DataView(buffer); + data.setUint8(0, NTP_MODE_CLIENT | (NTP_VERSION << 3)); + data.setUint32(TRANSMIT_TIME_OFFSET, s, false); + data.setUint32(TRANSMIT_TIME_OFFSET + 4, f, false); + + return String.fromCharCode.apply(null, new Uint8Array(buffer)); + } + + function SNTPListener() {} + SNTPListener.prototype = { + onStartRequest: function onStartRequest(request, context) { + }, + + onStopRequest: function onStopRequest(request, context, status) { + if (!Components.isSuccessCode(status)) { + debug ("Connection failed"); + this._requesting = false; + this._retry(); + } + }.bind(this), + + onDataAvailable: function onDataAvailable(request, context, inputStream, + offset, count) { + function GetTimeStamp(binaryInputStream) { + let s = binaryInputStream.read32(); + let f = binaryInputStream.read32(); + return Math.floor( + ((s - OFFSET_1900_TO_1970) * 1000) + ((f * 1000) / 0x100000000) + ); + } + debug ("Data available: " + count + " bytes"); + + try { + let binaryInputStream = Cc["@mozilla.org/binaryinputstream;1"] + .createInstance(Ci.nsIBinaryInputStream); + binaryInputStream.setInputStream(inputStream); + // We don't need first 24 bytes. + for (let i = 0; i < 6; i++) { + binaryInputStream.read32(); + } + // Offset 24: originate time. + let originateTimeInMS = GetTimeStamp(binaryInputStream); + // Offset 32: receive time. + let receiveTimeInMS = GetTimeStamp(binaryInputStream); + // Offset 40: transmit time. + let transmitTimeInMS = GetTimeStamp(binaryInputStream); + let respondTimeInMS = Date.now(); + + this._handleSntp(originateTimeInMS, receiveTimeInMS, + transmitTimeInMS, respondTimeInMS); + this._requesting = false; + } catch (e) { + debug ("SNTPListener Error: " + e.message); + this._requesting = false; + this._retry(); + } + inputStream.close(); + }.bind(this) + }; + + function SNTPRequester() {} + SNTPRequester.prototype = { + onOutputStreamReady: function(stream) { + try { + let data = GetRequest(); + let bytes_write = stream.write(data, data.length); + debug ("SNTP: sent " + bytes_write + " bytes"); + stream.close(); + } catch (e) { + debug ("SNTPRequester Error: " + e.message); + this._requesting = false; + this._retry(); + } + }.bind(this) + }; + + // Number of seconds between Jan 1, 1900 and Jan 1, 1970. + // 70 years plus 17 leap days. + let OFFSET_1900_TO_1970 = ((365 * 70) + 17) * 24 * 60 * 60; + + if (this._requesting) { + return; + } + if (this._pools.length < 1) { + debug("No server defined"); + return; + } + if (this._updateTimer) { + this._updateTimer.cancel(); + } + + debug ("Making request"); + this._requesting = true; + + let currentThread = Cc["@mozilla.org/thread-manager;1"] + .getService().currentThread; + let socketTransportService = + Cc["@mozilla.org/network/socket-transport-service;1"] + .getService(Ci.nsISocketTransportService); + let pump = Cc["@mozilla.org/network/input-stream-pump;1"] + .createInstance(Ci.nsIInputStreamPump); + let transport = socketTransportService + .createTransport(["udp"], + 1, + this._pools[Math.floor(this._pools.length * Math.random())], + this._port, + null); + + transport.setTimeout(Ci.nsISocketTransport.TIMEOUT_CONNECT, this._timeoutInMS); + transport.setTimeout(Ci.nsISocketTransport.TIMEOUT_READ_WRITE, this._timeoutInMS); + + let outStream = transport.openOutputStream(0, 0, 0) + .QueryInterface(Ci.nsIAsyncOutputStream); + let inStream = transport.openInputStream(0, 0, 0); + + pump.init(inStream, -1, -1, 0, 0, false); + pump.asyncRead(new SNTPListener(), null); + + outStream.asyncWait(new SNTPRequester(), 0, 0, currentThread); + }, + + // Callback function. + _dataAvailableCb: null, + + // Sntp servers. + _pools: [ + "0.pool.ntp.org", + "1.pool.ntp.org", + "2.pool.ntp.org", + "3.pool.ntp.org" + ], + + // The SNTP port. + _port: 123, + + // Maximum retry count allowed when request failed. + _maxRetryCount: 0, + + // Refresh period. + _refreshPeriodInMS: 0, + + // Timeout value used for connecting. + _timeoutInMS: 30 * 1000, + + // Cached SNTP offset. + _cachedOffset: null, + + // The time point when we cache the offset. + _cachedTimeInMS: null, + + // Flag to avoid redundant requests. + _requesting: false, + + // Retry counter. + _retryCount: 0, + + // Retry time offset (in seconds). + _retryPeriodInMS: 0, + + // Timer used for retries & daily updates. + _updateTimer: null +}; + +var debug; +if (DEBUG) { + debug = function (s) { + dump("-*- Sntp: " + s + "\n"); + }; +} else { + debug = function (s) {}; +} diff --git a/toolkit/modules/SpatialNavigation.jsm b/toolkit/modules/SpatialNavigation.jsm new file mode 100644 index 0000000000..c6f18a84f2 --- /dev/null +++ b/toolkit/modules/SpatialNavigation.jsm @@ -0,0 +1,606 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +/* 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/. */ + +/** + * Import this module through + * + * Components.utils.import("resource://gre/modules/SpatialNavigation.jsm"); + * + * Usage: (Literal class) + * + * SpatialNavigation.init(browser_element, optional_callback); + * + * optional_callback will be called when a new element is focused. + * + * function optional_callback(element) {} + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["SpatialNavigation"]; + +var SpatialNavigation = { + init: function(browser, callback) { + browser.addEventListener("keydown", function (event) { + _onInputKeyPress(event, callback); + }, true); + } +}; + +// Private stuff + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +Cu["import"]("resource://gre/modules/Services.jsm", this); + +var eventListenerService = Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); +var focusManager = Cc["@mozilla.org/focus-manager;1"] + .getService(Ci.nsIFocusManager); +var windowMediator = Cc['@mozilla.org/appshell/window-mediator;1'] + .getService(Ci.nsIWindowMediator); + +// Debug helpers: +function dump(a) { + Services.console.logStringMessage("SpatialNavigation: " + a); +} + +function dumpRect(desc, rect) { + dump(desc + " " + Math.round(rect.left) + " " + Math.round(rect.top) + " " + + Math.round(rect.right) + " " + Math.round(rect.bottom) + " width:" + + Math.round(rect.width) + " height:" + Math.round(rect.height)); +} + +function dumpNodeCoord(desc, node) { + let rect = node.getBoundingClientRect(); + dump(desc + " " + node.tagName + " x:" + Math.round(rect.left + rect.width/2) + + " y:" + Math.round(rect.top + rect.height / 2)); +} + +// modifier values + +const kAlt = "alt"; +const kShift = "shift"; +const kCtrl = "ctrl"; +const kNone = "none"; + +function _onInputKeyPress (event, callback) { + // If Spatial Navigation isn't enabled, return. + if (!PrefObserver['enabled']) { + return; + } + + // Use whatever key value is available (either keyCode or charCode). + // It might be useful for addons or whoever wants to set different + // key to be used here (e.g. "a", "F1", "arrowUp", ...). + var key = event.which || event.keyCode; + + if (key != PrefObserver['keyCodeDown'] && + key != PrefObserver['keyCodeRight'] && + key != PrefObserver['keyCodeUp'] && + key != PrefObserver['keyCodeLeft'] && + key != PrefObserver['keyCodeReturn']) { + return; + } + + if (key == PrefObserver['keyCodeReturn']) { + // We report presses of the action button on a gamepad "A" as the return + // key to the DOM. The behaviour of hitting the return key and clicking an + // element is the same for some elements, but not all, so we handle the + // ones we want (like the Select element) here: + if (event.target instanceof Ci.nsIDOMHTMLSelectElement && + event.target.click) { + event.target.click(); + event.stopPropagation(); + event.preventDefault(); + return; + } + // Leave the action key press to get reported to the DOM as a return + // keypress. + return; + } + + // If it is not using the modifiers it should, return. + if (!event.altKey && PrefObserver['modifierAlt'] || + !event.shiftKey && PrefObserver['modifierShift'] || + !event.crtlKey && PrefObserver['modifierCtrl']) { + return; + } + + let currentlyFocused = event.target; + let currentlyFocusedWindow = currentlyFocused.ownerDocument.defaultView; + let bestElementToFocus = null; + + // If currentlyFocused is an nsIDOMHTMLBodyElement then the page has just been + // loaded, and this is the first keypress in the page. + if (currentlyFocused instanceof Ci.nsIDOMHTMLBodyElement) { + focusManager.moveFocus(currentlyFocusedWindow, null, focusManager.MOVEFOCUS_FIRST, 0); + event.stopPropagation(); + event.preventDefault(); + return; + } + + if ((currentlyFocused instanceof Ci.nsIDOMHTMLInputElement && + currentlyFocused.mozIsTextField(false)) || + currentlyFocused instanceof Ci.nsIDOMHTMLTextAreaElement) { + // If there is a text selection, remain in the element. + if (currentlyFocused.selectionEnd - currentlyFocused.selectionStart != 0) { + return; + } + + // If there is no text, there is nothing special to do. + if (currentlyFocused.textLength > 0) { + if (key == PrefObserver['keyCodeRight'] || + key == PrefObserver['keyCodeDown'] ) { + // We are moving forward into the document. + if (currentlyFocused.textLength != currentlyFocused.selectionEnd) { + return; + } + } else if (currentlyFocused.selectionStart != 0) { + return; + } + } + } + + let windowUtils = currentlyFocusedWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + let cssPageRect = _getRootBounds(windowUtils); + let searchRect = _getSearchRect(currentlyFocused, key, cssPageRect); + + let nodes = {}; + nodes.length = 0; + + let searchRectOverflows = false; + + while (!bestElementToFocus && !searchRectOverflows) { + switch (key) { + case PrefObserver['keyCodeLeft']: + case PrefObserver['keyCodeRight']: { + if (searchRect.top < cssPageRect.top && + searchRect.bottom > cssPageRect.bottom) { + searchRectOverflows = true; + } + break; + } + case PrefObserver['keyCodeUp']: + case PrefObserver['keyCodeDown']: { + if (searchRect.left < cssPageRect.left && + searchRect.right > cssPageRect.right) { + searchRectOverflows = true; + } + break; + } + } + + nodes = windowUtils.nodesFromRect(searchRect.left, searchRect.top, + 0, searchRect.width, searchRect.height, 0, + true, false); + // Make the search rectangle "wider": double it's size in the direction + // that is not the keypress. + switch (key) { + case PrefObserver['keyCodeLeft']: + case PrefObserver['keyCodeRight']: { + searchRect.top = searchRect.top - (searchRect.height / 2); + searchRect.bottom = searchRect.top + (searchRect.height * 2); + searchRect.height = searchRect.height * 2; + break; + } + case PrefObserver['keyCodeUp']: + case PrefObserver['keyCodeDown']: { + searchRect.left = searchRect.left - (searchRect.width / 2); + searchRect.right = searchRect.left + (searchRect.width * 2); + searchRect.width = searchRect.width * 2; + break; + } + } + bestElementToFocus = _getBestToFocus(nodes, key, currentlyFocused); + } + + + if (bestElementToFocus === null) { + // Couldn't find an element to focus. + return; + } + + focusManager.setFocus(bestElementToFocus, focusManager.FLAG_SHOWRING); + + // if it is a text element, select all. + if ((bestElementToFocus instanceof Ci.nsIDOMHTMLInputElement && + bestElementToFocus.mozIsTextField(false)) || + bestElementToFocus instanceof Ci.nsIDOMHTMLTextAreaElement) { + bestElementToFocus.selectionStart = 0; + bestElementToFocus.selectionEnd = bestElementToFocus.textLength; + } + + if (callback != undefined) { + callback(bestElementToFocus); + } + + event.preventDefault(); + event.stopPropagation(); +} + +// Returns the bounds of the page relative to the viewport. +function _getRootBounds(windowUtils) { + let cssPageRect = windowUtils.getRootBounds(); + + let scrollX = {}; + let scrollY = {}; + windowUtils.getScrollXY(false, scrollX, scrollY); + + let cssPageRectCopy = {}; + + cssPageRectCopy.right = cssPageRect.right - scrollX.value; + cssPageRectCopy.left = cssPageRect.left - scrollX.value; + cssPageRectCopy.top = cssPageRect.top - scrollY.value; + cssPageRectCopy.bottom = cssPageRect.bottom - scrollY.value; + cssPageRectCopy.width = cssPageRect.width; + cssPageRectCopy.height = cssPageRect.height; + + return cssPageRectCopy; +} + +// Returns the best node to focus from the list of nodes returned by the hit +// test. +function _getBestToFocus(nodes, key, currentlyFocused) { + let best = null; + let bestDist; + let bestMid; + let nodeMid; + let currentlyFocusedMid = _getMidpoint(currentlyFocused); + let currentlyFocusedRect = currentlyFocused.getBoundingClientRect(); + + for (let i = 0; i < nodes.length; i++) { + // Reject the currentlyFocused, and all node types we can't focus + if (!_canFocus(nodes[i]) || nodes[i] === currentlyFocused) { + continue; + } + + // Reject all nodes that aren't "far enough" in the direction of the + // keypress + nodeMid = _getMidpoint(nodes[i]); + switch (key) { + case PrefObserver['keyCodeLeft']: + if (nodeMid.x >= (currentlyFocusedMid.x - currentlyFocusedRect.width / 2)) { + continue; + } + break; + case PrefObserver['keyCodeRight']: + if (nodeMid.x <= (currentlyFocusedMid.x + currentlyFocusedRect.width / 2)) { + continue; + } + break; + + case PrefObserver['keyCodeUp']: + if (nodeMid.y >= (currentlyFocusedMid.y - currentlyFocusedRect.height / 2)) { + continue; + } + break; + case PrefObserver['keyCodeDown']: + if (nodeMid.y <= (currentlyFocusedMid.y + currentlyFocusedRect.height / 2)) { + continue; + } + break; + } + + // Initialize best to the first viable value: + if (!best) { + bestDist = _spatialDistanceOfCorner(currentlyFocused, nodes[i], key); + if (bestDist >= 0) { + best = nodes[i]; + } + continue; + } + + // Of the remaining nodes, pick the one closest to the currently focused + // node. + let curDist = _spatialDistanceOfCorner(currentlyFocused, nodes[i], key); + if ((curDist > bestDist) || curDist === -1) { + continue; + } + else if (curDist === bestDist) { + let midCurDist = _spatialDistance(currentlyFocused, nodes[i]); + let midBestDist = _spatialDistance(currentlyFocused, best); + if (midCurDist > midBestDist) + continue; + } + + best = nodes[i]; + bestDist = curDist; + } + return best; +} + +// Returns the midpoint of a node. +function _getMidpoint(node) { + let mid = {}; + let box = node.getBoundingClientRect(); + mid.x = box.left + (box.width / 2); + mid.y = box.top + (box.height / 2); + + return mid; +} + +// Returns true if the node is a type that we want to focus, false otherwise. +function _canFocus(node) { + if (node instanceof Ci.nsIDOMHTMLLinkElement || + node instanceof Ci.nsIDOMHTMLAnchorElement) { + return true; + } + if ((node instanceof Ci.nsIDOMHTMLButtonElement || + node instanceof Ci.nsIDOMHTMLInputElement || + node instanceof Ci.nsIDOMHTMLLinkElement || + node instanceof Ci.nsIDOMHTMLOptGroupElement || + node instanceof Ci.nsIDOMHTMLSelectElement || + node instanceof Ci.nsIDOMHTMLTextAreaElement) && + node.disabled === false) { + return true; + } + return false; +} + +// Returns a rectangle that extends to the end of the screen in the direction that +// the key is pressed. +function _getSearchRect(currentlyFocused, key, cssPageRect) { + let currentlyFocusedRect = currentlyFocused.getBoundingClientRect(); + + let newRect = {}; + newRect.left = currentlyFocusedRect.left; + newRect.top = currentlyFocusedRect.top; + newRect.right = currentlyFocusedRect.right; + newRect.bottom = currentlyFocusedRect.bottom; + newRect.width = currentlyFocusedRect.width; + newRect.height = currentlyFocusedRect.height; + + switch (key) { + case PrefObserver['keyCodeLeft']: + newRect.right = newRect.left; + newRect.left = cssPageRect.left; + newRect.width = newRect.right - newRect.left; + + newRect.bottom = cssPageRect.bottom; + newRect.top = cssPageRect.top; + newRect.height = newRect.bottom - newRect.top; + break; + + case PrefObserver['keyCodeRight']: + newRect.left = newRect.right; + newRect.right = cssPageRect.right; + newRect.width = newRect.right - newRect.left; + + newRect.bottom = cssPageRect.bottom; + newRect.top = cssPageRect.top; + newRect.height = newRect.bottom - newRect.top; + break; + + case PrefObserver['keyCodeUp']: + newRect.bottom = newRect.top; + newRect.top = cssPageRect.top; + newRect.height = newRect.bottom - newRect.top; + + newRect.right = cssPageRect.right; + newRect.left = cssPageRect.left; + newRect.width = newRect.right - newRect.left; + break; + + case PrefObserver['keyCodeDown']: + newRect.top = newRect.bottom; + newRect.bottom = cssPageRect.bottom; + newRect.height = newRect.bottom - newRect.top; + + newRect.right = cssPageRect.right; + newRect.left = cssPageRect.left; + newRect.width = newRect.right - newRect.left; + break; + } + return newRect; +} + +// Gets the distance between two points a and b. +function _spatialDistance(a, b) { + let mida = _getMidpoint(a); + let midb = _getMidpoint(b); + + return Math.round(Math.pow(mida.x - midb.x, 2) + + Math.pow(mida.y - midb.y, 2)); +} + +// Get the distance between the corner of two nodes +function _spatialDistanceOfCorner(from, to, key) { + let fromRect = from.getBoundingClientRect(); + let toRect = to.getBoundingClientRect(); + let fromMid = _getMidpoint(from); + let toMid = _getMidpoint(to); + let hDistance = 0; + let vDistance = 0; + + switch (key) { + case PrefObserver['keyCodeLeft']: + // Make sure the "to" node is really at the left side of "from" node by + // 1. Check the mid point + // 2. The right border of "to" node must be less than the "from" node + if ((fromMid.x - toMid.x) < 0 || toRect.right >= fromRect.right) + return -1; + hDistance = Math.abs(fromRect.left - toRect.right); + if (toRect.bottom <= fromRect.top) { + vDistance = fromRect.top - toRect.bottom; + } + else if (fromRect.bottom <= toRect.top) { + vDistance = toRect.top - fromRect.bottom; + } + else { + vDistance = 0; + } + break; + + case PrefObserver['keyCodeRight']: + if ((toMid.x - fromMid.x) < 0 || toRect.left <= fromRect.left) + return -1; + hDistance = Math.abs(toRect.left - fromRect.right); + if (toRect.bottom <= fromRect.top) { + vDistance = fromRect.top - toRect.bottom; + } + else if (fromRect.bottom <= toRect.top) { + vDistance = toRect.top - fromRect.bottom; + } + else { + vDistance = 0; + } + break; + + case PrefObserver['keyCodeUp']: + if ((fromMid.y - toMid.y) < 0 || toRect.bottom >= fromRect.bottom) + return -1; + vDistance = Math.abs(fromRect.top - toRect.bottom); + if (fromRect.right <= toRect.left) { + hDistance = toRect.left - fromRect.right; + } + else if (toRect.right <= fromRect.left) { + hDistance = fromRect.left - toRect.right; + } + else { + hDistance = 0; + } + break; + + case PrefObserver['keyCodeDown']: + if ((toMid.y - fromMid.y) < 0 || toRect.top <= fromRect.top) + return -1; + vDistance = Math.abs(toRect.top - fromRect.bottom); + if (fromRect.right <= toRect.left) { + hDistance = toRect.left - fromRect.right; + } + else if (toRect.right <= fromRect.left) { + hDistance = fromRect.left - toRect.right; + } + else { + hDistance = 0; + } + break; + } + return Math.round(Math.pow(hDistance, 2) + + Math.pow(vDistance, 2)); +} + +// Snav preference observer +var PrefObserver = { + register: function() { + this.prefService = Cc["@mozilla.org/preferences-service;1"] + .getService(Ci.nsIPrefService); + + this._branch = this.prefService.getBranch("snav."); + this._branch.QueryInterface(Ci.nsIPrefBranch2); + this._branch.addObserver("", this, false); + + // set current or default pref values + this.observe(null, "nsPref:changed", "enabled"); + this.observe(null, "nsPref:changed", "xulContentEnabled"); + this.observe(null, "nsPref:changed", "keyCode.modifier"); + this.observe(null, "nsPref:changed", "keyCode.right"); + this.observe(null, "nsPref:changed", "keyCode.up"); + this.observe(null, "nsPref:changed", "keyCode.down"); + this.observe(null, "nsPref:changed", "keyCode.left"); + this.observe(null, "nsPref:changed", "keyCode.return"); + }, + + observe: function(aSubject, aTopic, aData) { + if (aTopic != "nsPref:changed") { + return; + } + + // aSubject is the nsIPrefBranch we're observing (after appropriate QI) + // aData is the name of the pref that's been changed (relative to aSubject) + switch (aData) { + case "enabled": + try { + this.enabled = this._branch.getBoolPref("enabled"); + } catch (e) { + this.enabled = false; + } + break; + + case "xulContentEnabled": + try { + this.xulContentEnabled = this._branch.getBoolPref("xulContentEnabled"); + } catch (e) { + this.xulContentEnabled = false; + } + break; + + case "keyCode.modifier": { + let keyCodeModifier; + try { + keyCodeModifier = this._branch.getCharPref("keyCode.modifier"); + + // resetting modifiers + this.modifierAlt = false; + this.modifierShift = false; + this.modifierCtrl = false; + + if (keyCodeModifier != this.kNone) { + // we are using '+' as a separator in about:config. + let mods = keyCodeModifier.split(/\++/); + for (let i = 0; i < mods.length; i++) { + let mod = mods[i].toLowerCase(); + if (mod === "") + continue; + else if (mod == kAlt) + this.modifierAlt = true; + else if (mod == kShift) + this.modifierShift = true; + else if (mod == kCtrl) + this.modifierCtrl = true; + else { + keyCodeModifier = kNone; + break; + } + } + } + } catch (e) { } + break; + } + + case "keyCode.up": + try { + this.keyCodeUp = this._branch.getIntPref("keyCode.up"); + } catch (e) { + this.keyCodeUp = Ci.nsIDOMKeyEvent.DOM_VK_UP; + } + break; + case "keyCode.down": + try { + this.keyCodeDown = this._branch.getIntPref("keyCode.down"); + } catch (e) { + this.keyCodeDown = Ci.nsIDOMKeyEvent.DOM_VK_DOWN; + } + break; + case "keyCode.left": + try { + this.keyCodeLeft = this._branch.getIntPref("keyCode.left"); + } catch (e) { + this.keyCodeLeft = Ci.nsIDOMKeyEvent.DOM_VK_LEFT; + } + break; + case "keyCode.right": + try { + this.keyCodeRight = this._branch.getIntPref("keyCode.right"); + } catch (e) { + this.keyCodeRight = Ci.nsIDOMKeyEvent.DOM_VK_RIGHT; + } + break; + case "keyCode.return": + try { + this.keyCodeReturn = this._branch.getIntPref("keyCode.return"); + } catch (e) { + this.keyCodeReturn = Ci.nsIDOMKeyEvent.DOM_VK_RETURN; + } + break; + } + } +}; + +PrefObserver.register(); diff --git a/toolkit/modules/Sqlite.jsm b/toolkit/modules/Sqlite.jsm new file mode 100644 index 0000000000..e8d986c0e5 --- /dev/null +++ b/toolkit/modules/Sqlite.jsm @@ -0,0 +1,1461 @@ +/* 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 = [ + "Sqlite", +]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +// The time to wait before considering a transaction stuck and rejecting it. +const TRANSACTIONS_QUEUE_TIMEOUT_MS = 240000 // 4 minutes + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Log", + "resource://gre/modules/Log.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "FinalizationWitnessService", + "@mozilla.org/toolkit/finalizationwitness;1", + "nsIFinalizationWitnessService"); +XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils", + "resource://gre/modules/PromiseUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); + +// Regular expression used by isInvalidBoundLikeQuery +var likeSqlRegex = /\bLIKE\b\s(?![@:?])/i; + +// Counts the number of created connections per database basename(). This is +// used for logging to distinguish connection instances. +var connectionCounters = new Map(); + +// Tracks identifiers of wrapped connections, that are Storage connections +// opened through mozStorage and then wrapped by Sqlite.jsm to use its syntactic +// sugar API. Since these connections have an unknown origin, we use this set +// to differentiate their behavior. +var wrappedConnections = new Set(); + +/** + * Once `true`, reject any attempt to open or close a database. + */ +var isClosed = false; + +var Debugging = { + // Tests should fail if a connection auto closes. The exception is + // when finalization itself is tested, in which case this flag + // should be set to false. + failTestsOnAutoClose: true +}; + +/** + * Helper function to check whether LIKE is implemented using proper bindings. + * + * @param sql + * (string) The SQL query to be verified. + * @return boolean value telling us whether query was correct or not +*/ +function isInvalidBoundLikeQuery(sql) { + return likeSqlRegex.test(sql); +} + +// Displays a script error message +function logScriptError(message) { + let consoleMessage = Cc["@mozilla.org/scripterror;1"]. + createInstance(Ci.nsIScriptError); + let stack = new Error(); + consoleMessage.init(message, stack.fileName, null, stack.lineNumber, 0, + Ci.nsIScriptError.errorFlag, "component javascript"); + Services.console.logMessage(consoleMessage); + + // This `Promise.reject` will cause tests to fail. The debugging + // flag can be used to suppress this for tests that explicitly + // test auto closes. + if (Debugging.failTestsOnAutoClose) { + Promise.reject(new Error(message)); + } +} + +/** + * Gets connection identifier from its database file path. + * + * @param path + * A file string path pointing to a database file. + * @return the connection identifier. + */ +function getIdentifierByPath(path) { + let basename = OS.Path.basename(path); + let number = connectionCounters.get(basename) || 0; + connectionCounters.set(basename, number + 1); + return basename + "#" + number; +} + +/** + * Barriers used to ensure that Sqlite.jsm is shutdown after all + * its clients. + */ +XPCOMUtils.defineLazyGetter(this, "Barriers", () => { + let Barriers = { + /** + * Public barrier that clients may use to add blockers to the + * shutdown of Sqlite.jsm. Triggered by profile-before-change. + * Once all blockers of this barrier are lifted, we close the + * ability to open new connections. + */ + shutdown: new AsyncShutdown.Barrier("Sqlite.jsm: wait until all clients have completed their task"), + + /** + * Private barrier blocked by connections that are still open. + * Triggered after Barriers.shutdown is lifted and `isClosed` is + * set to `true`. + */ + connections: new AsyncShutdown.Barrier("Sqlite.jsm: wait until all connections are closed"), + }; + + /** + * Observer for the event which is broadcasted when the finalization + * witness `_witness` of `OpenedConnection` is garbage collected. + * + * The observer is passed the connection identifier of the database + * connection that is being finalized. + */ + let finalizationObserver = function (subject, topic, identifier) { + let connectionData = ConnectionData.byId.get(identifier); + + if (connectionData === undefined) { + logScriptError("Error: Attempt to finalize unknown Sqlite connection: " + + identifier + "\n"); + return; + } + + ConnectionData.byId.delete(identifier); + logScriptError("Warning: Sqlite connection '" + identifier + + "' was not properly closed. Auto-close triggered by garbage collection.\n"); + connectionData.close(); + }; + Services.obs.addObserver(finalizationObserver, "sqlite-finalization-witness", false); + + /** + * Ensure that Sqlite.jsm: + * - informs its clients before shutting down; + * - lets clients open connections during shutdown, if necessary; + * - waits for all connections to be closed before shutdown. + */ + AsyncShutdown.profileBeforeChange.addBlocker("Sqlite.jsm shutdown blocker", + Task.async(function* () { + yield Barriers.shutdown.wait(); + // At this stage, all clients have had a chance to open (and close) + // their databases. Some previous close operations may still be pending, + // so we need to wait until they are complete before proceeding. + + // Prevent any new opening. + isClosed = true; + + // Now, wait until all databases are closed + yield Barriers.connections.wait(); + + // Everything closed, no finalization events to catch + Services.obs.removeObserver(finalizationObserver, "sqlite-finalization-witness"); + }), + + function status() { + if (isClosed) { + // We are waiting for the connections to close. The interesting + // status is therefore the list of connections still pending. + return { description: "Waiting for connections to close", + state: Barriers.connections.state }; + } + + // We are still in the first stage: waiting for the barrier + // to be lifted. The interesting status is therefore that of + // the barrier. + return { description: "Waiting for the barrier to be lifted", + state: Barriers.shutdown.state }; + }); + + return Barriers; +}); + +/** + * Connection data with methods necessary for closing the connection. + * + * To support auto-closing in the event of garbage collection, this + * data structure contains all the connection data of an opened + * connection and all of the methods needed for sucessfully closing + * it. + * + * By putting this information in its own separate object, it is + * possible to store an additional reference to it without preventing + * a garbage collection of a finalization witness in + * OpenedConnection. When the witness detects a garbage collection, + * this object can be used to close the connection. + * + * This object contains more methods than just `close`. When + * OpenedConnection needs to use the methods in this object, it will + * dispatch its method calls here. + */ +function ConnectionData(connection, identifier, options={}) { + this._log = Log.repository.getLoggerWithMessagePrefix("Sqlite.Connection", + identifier + ": "); + this._log.info("Opened"); + + this._dbConn = connection; + + // This is a unique identifier for the connection, generated through + // getIdentifierByPath. It may be used for logging or as a key in Maps. + this._identifier = identifier; + + this._open = true; + + this._cachedStatements = new Map(); + this._anonymousStatements = new Map(); + this._anonymousCounter = 0; + + // A map from statement index to mozIStoragePendingStatement, to allow for + // canceling prior to finalizing the mozIStorageStatements. + this._pendingStatements = new Map(); + + // Increments for each executed statement for the life of the connection. + this._statementCounter = 0; + + // Increments whenever we request a unique operation id. + this._operationsCounter = 0; + + this._hasInProgressTransaction = false; + // Manages a chain of transactions promises, so that new transactions + // always happen in queue to the previous ones. It never rejects. + this._transactionQueue = Promise.resolve(); + + this._idleShrinkMS = options.shrinkMemoryOnConnectionIdleMS; + if (this._idleShrinkMS) { + this._idleShrinkTimer = Cc["@mozilla.org/timer;1"] + .createInstance(Ci.nsITimer); + // We wait for the first statement execute to start the timer because + // shrinking now would not do anything. + } + + // Deferred whose promise is resolved when the connection closing procedure + // is complete. + this._deferredClose = PromiseUtils.defer(); + this._closeRequested = false; + + // An AsyncShutdown barrier used to make sure that we wait until clients + // are done before shutting down the connection. + this._barrier = new AsyncShutdown.Barrier(`${this._identifier}: waiting for clients`); + + Barriers.connections.client.addBlocker( + this._identifier + ": waiting for shutdown", + this._deferredClose.promise, + () => ({ + identifier: this._identifier, + isCloseRequested: this._closeRequested, + hasDbConn: !!this._dbConn, + hasInProgressTransaction: this._hasInProgressTransaction, + pendingStatements: this._pendingStatements.size, + statementCounter: this._statementCounter, + }) + ); +} + +/** + * Map of connection identifiers to ConnectionData objects + * + * The connection identifier is a human-readable name of the + * database. Used by finalization witnesses to be able to close opened + * connections on garbage collection. + * + * Key: _identifier of ConnectionData + * Value: ConnectionData object + */ +ConnectionData.byId = new Map(); + +ConnectionData.prototype = Object.freeze({ + /** + * Run a task, ensuring that its execution will not be interrupted by shutdown. + * + * As the operations of this module are asynchronous, a sequence of operations, + * or even an individual operation, can still be pending when the process shuts + * down. If any of this operations is a write, this can cause data loss, simply + * because the write has not been completed (or even started) by shutdown. + * + * To avoid this risk, clients are encouraged to use `executeBeforeShutdown` for + * any write operation, as follows: + * + * myConnection.executeBeforeShutdown("Bookmarks: Removing a bookmark", + * Task.async(function*(db) { + * // The connection will not be closed and shutdown will not proceed + * // until this task has completed. + * + * // `db` exposes the same API as `myConnection` but provides additional + * // logging support to help debug hard-to-catch shutdown timeouts. + * + * yield db.execute(...); + * })); + * + * @param {string} name A human-readable name for the ongoing operation, used + * for logging and debugging purposes. + * @param {function(db)} task A function that takes as argument a Sqlite.jsm + * db and returns a Promise. + */ + executeBeforeShutdown: function(parent, name, task) { + if (!name) { + throw new TypeError("Expected a human-readable name as first argument"); + } + if (typeof task != "function") { + throw new TypeError("Expected a function as second argument"); + } + if (this._closeRequested) { + throw new Error(`${this._identifier}: cannot execute operation ${name}, the connection is already closing`); + } + + // Status, used for AsyncShutdown crash reports. + let status = { + // The latest command started by `task`, either as a + // sql string, or as one of "<not started>" or "<closing>". + command: "<not started>", + + // `true` if `command` was started but not completed yet. + isPending: false, + }; + + // An object with the same API as `this` but with + // additional logging. To keep logging simple, we + // assume that `task` is not running several queries + // concurrently. + let loggedDb = Object.create(parent, { + execute: { + value: Task.async(function*(sql, ...rest) { + status.isPending = true; + status.command = sql; + try { + return (yield this.execute(sql, ...rest)); + } finally { + status.isPending = false; + } + }.bind(this)) + }, + close: { + value: Task.async(function*() { + status.isPending = false; + status.command = "<close>"; + try { + return (yield this.close()); + } finally { + status.isPending = false; + } + }.bind(this)) + }, + executeCached: { + value: Task.async(function*(sql, ...rest) { + status.isPending = false; + status.command = sql; + try { + return (yield this.executeCached(sql, ...rest)); + } finally { + status.isPending = false; + } + }.bind(this)) + }, + }); + + let promiseResult = task(loggedDb); + if (!promiseResult || typeof promiseResult != "object" || !("then" in promiseResult)) { + throw new TypeError("Expected a Promise"); + } + let key = `${this._identifier}: ${name} (${this._getOperationId()})`; + let promiseComplete = promiseResult.catch(() => {}); + this._barrier.client.addBlocker(key, promiseComplete, { + fetchState: () => status + }); + + return Task.spawn(function*() { + try { + return (yield promiseResult); + } finally { + this._barrier.client.removeBlocker(key, promiseComplete) + } + }.bind(this)); + }, + close: function () { + this._closeRequested = true; + + if (!this._dbConn) { + return this._deferredClose.promise; + } + + this._log.debug("Request to close connection."); + this._clearIdleShrinkTimer(); + + return this._barrier.wait().then(() => { + if (!this._dbConn) { + return undefined; + } + return this._finalize(); + }); + }, + + clone: function (readOnly=false) { + this.ensureOpen(); + + this._log.debug("Request to clone connection."); + + let options = { + connection: this._dbConn, + readOnly: readOnly, + }; + if (this._idleShrinkMS) + options.shrinkMemoryOnConnectionIdleMS = this._idleShrinkMS; + + return cloneStorageConnection(options); + }, + _getOperationId: function() { + return this._operationsCounter++; + }, + _finalize: function () { + this._log.debug("Finalizing connection."); + // Cancel any pending statements. + for (let [k, statement] of this._pendingStatements) { + statement.cancel(); + } + this._pendingStatements.clear(); + + // We no longer need to track these. + this._statementCounter = 0; + + // Next we finalize all active statements. + for (let [k, statement] of this._anonymousStatements) { + statement.finalize(); + } + this._anonymousStatements.clear(); + + for (let [k, statement] of this._cachedStatements) { + statement.finalize(); + } + this._cachedStatements.clear(); + + // This guards against operations performed between the call to this + // function and asyncClose() finishing. See also bug 726990. + this._open = false; + + // We must always close the connection at the Sqlite.jsm-level, not + // necessarily at the mozStorage-level. + let markAsClosed = () => { + this._log.info("Closed"); + // Now that the connection is closed, no need to keep + // a blocker for Barriers.connections. + Barriers.connections.client.removeBlocker(this._deferredClose.promise); + this._deferredClose.resolve(); + } + if (wrappedConnections.has(this._identifier)) { + wrappedConnections.delete(this._identifier); + this._dbConn = null; + markAsClosed(); + } else { + this._log.debug("Calling asyncClose()."); + this._dbConn.asyncClose(markAsClosed); + this._dbConn = null; + } + return this._deferredClose.promise; + }, + + executeCached: function (sql, params=null, onRow=null) { + this.ensureOpen(); + + if (!sql) { + throw new Error("sql argument is empty."); + } + + let statement = this._cachedStatements.get(sql); + if (!statement) { + statement = this._dbConn.createAsyncStatement(sql); + this._cachedStatements.set(sql, statement); + } + + this._clearIdleShrinkTimer(); + + return new Promise((resolve, reject) => { + try { + this._executeStatement(sql, statement, params, onRow).then( + result => { + this._startIdleShrinkTimer(); + resolve(result); + }, + error => { + this._startIdleShrinkTimer(); + reject(error); + } + ); + } catch (ex) { + this._startIdleShrinkTimer(); + throw ex; + } + }); + }, + + execute: function (sql, params=null, onRow=null) { + if (typeof(sql) != "string") { + throw new Error("Must define SQL to execute as a string: " + sql); + } + + this.ensureOpen(); + + let statement = this._dbConn.createAsyncStatement(sql); + let index = this._anonymousCounter++; + + this._anonymousStatements.set(index, statement); + this._clearIdleShrinkTimer(); + + let onFinished = () => { + this._anonymousStatements.delete(index); + statement.finalize(); + this._startIdleShrinkTimer(); + }; + + return new Promise((resolve, reject) => { + try { + this._executeStatement(sql, statement, params, onRow).then( + rows => { + onFinished(); + resolve(rows); + }, + error => { + onFinished(); + reject(error); + } + ); + } catch (ex) { + onFinished(); + throw ex; + } + }); + }, + + get transactionInProgress() { + return this._open && this._hasInProgressTransaction; + }, + + executeTransaction: function (func, type) { + if (typeof type == "undefined") { + throw new Error("Internal error: expected a type"); + } + this.ensureOpen(); + + this._log.debug("Beginning transaction"); + + let promise = this._transactionQueue.then(() => { + if (this._closeRequested) { + throw new Error("Transaction canceled due to a closed connection."); + } + + let transactionPromise = Task.spawn(function* () { + // At this point we should never have an in progress transaction, since + // they are enqueued. + if (this._hasInProgressTransaction) { + console.error("Unexpected transaction in progress when trying to start a new one."); + } + this._hasInProgressTransaction = true; + try { + // We catch errors in statement execution to detect nested transactions. + try { + yield this.execute("BEGIN " + type + " TRANSACTION"); + } catch (ex) { + // Unfortunately, if we are wrapping an existing connection, a + // transaction could have been started by a client of the same + // connection that doesn't use Sqlite.jsm (e.g. C++ consumer). + // The best we can do is proceed without a transaction and hope + // things won't break. + if (wrappedConnections.has(this._identifier)) { + this._log.warn("A new transaction could not be started cause the wrapped connection had one in progress", ex); + // Unmark the in progress transaction, since it's managed by + // some other non-Sqlite.jsm client. See the comment above. + this._hasInProgressTransaction = false; + } else { + this._log.warn("A transaction was already in progress, likely a nested transaction", ex); + throw ex; + } + } + + let result; + try { + result = yield Task.spawn(func); + } catch (ex) { + // It's possible that the exception has been caused by trying to + // close the connection in the middle of a transaction. + if (this._closeRequested) { + this._log.warn("Connection closed while performing a transaction", ex); + } else { + this._log.warn("Error during transaction. Rolling back", ex); + // If we began a transaction, we must rollback it. + if (this._hasInProgressTransaction) { + try { + yield this.execute("ROLLBACK TRANSACTION"); + } catch (inner) { + this._log.warn("Could not roll back transaction", inner); + } + } + } + // Rethrow the exception. + throw ex; + } + + // See comment above about connection being closed during transaction. + if (this._closeRequested) { + this._log.warn("Connection closed before committing the transaction."); + throw new Error("Connection closed before committing the transaction."); + } + + // If we began a transaction, we must commit it. + if (this._hasInProgressTransaction) { + try { + yield this.execute("COMMIT TRANSACTION"); + } catch (ex) { + this._log.warn("Error committing transaction", ex); + throw ex; + } + } + + return result; + } finally { + this._hasInProgressTransaction = false; + } + }.bind(this)); + + // If a transaction yields on a never resolved promise, or is mistakenly + // nested, it could hang the transactions queue forever. Thus we timeout + // the execution after a meaningful amount of time, to ensure in any case + // we'll proceed after a while. + let timeoutPromise = new Promise((resolve, reject) => { + setTimeout(() => reject(new Error("Transaction timeout, most likely caused by unresolved pending work.")), + TRANSACTIONS_QUEUE_TIMEOUT_MS); + }); + return Promise.race([transactionPromise, timeoutPromise]); + }); + // Atomically update the queue before anyone else has a chance to enqueue + // further transactions. + this._transactionQueue = promise.catch(ex => { console.error(ex) }); + + // Make sure that we do not shutdown the connection during a transaction. + this._barrier.client.addBlocker(`Transaction (${this._getOperationId()})`, + this._transactionQueue); + return promise; + }, + + shrinkMemory: function () { + this._log.info("Shrinking memory usage."); + let onShrunk = this._clearIdleShrinkTimer.bind(this); + return this.execute("PRAGMA shrink_memory").then(onShrunk, onShrunk); + }, + + discardCachedStatements: function () { + let count = 0; + for (let [k, statement] of this._cachedStatements) { + ++count; + statement.finalize(); + } + this._cachedStatements.clear(); + this._log.debug("Discarded " + count + " cached statements."); + return count; + }, + + /** + * Helper method to bind parameters of various kinds through + * reflection. + */ + _bindParameters: function (statement, params) { + if (!params) { + return; + } + + if (Array.isArray(params)) { + // It's an array of separate params. + if (params.length && (typeof(params[0]) == "object")) { + let paramsArray = statement.newBindingParamsArray(); + for (let p of params) { + let bindings = paramsArray.newBindingParams(); + for (let [key, value] of Object.entries(p)) { + bindings.bindByName(key, value); + } + paramsArray.addParams(bindings); + } + + statement.bindParameters(paramsArray); + return; + } + + // Indexed params. + for (let i = 0; i < params.length; i++) { + statement.bindByIndex(i, params[i]); + } + return; + } + + // Named params. + if (params && typeof(params) == "object") { + for (let k in params) { + statement.bindByName(k, params[k]); + } + return; + } + + throw new Error("Invalid type for bound parameters. Expected Array or " + + "object. Got: " + params); + }, + + _executeStatement: function (sql, statement, params, onRow) { + if (statement.state != statement.MOZ_STORAGE_STATEMENT_READY) { + throw new Error("Statement is not ready for execution."); + } + + if (onRow && typeof(onRow) != "function") { + throw new Error("onRow must be a function. Got: " + onRow); + } + + this._bindParameters(statement, params); + + let index = this._statementCounter++; + + let deferred = PromiseUtils.defer(); + let userCancelled = false; + let errors = []; + let rows = []; + let handledRow = false; + + // Don't incur overhead for serializing params unless the messages go + // somewhere. + if (this._log.level <= Log.Level.Trace) { + let msg = "Stmt #" + index + " " + sql; + + if (params) { + msg += " - " + JSON.stringify(params); + } + this._log.trace(msg); + } else { + this._log.debug("Stmt #" + index + " starting"); + } + + let self = this; + let pending = statement.executeAsync({ + handleResult: function (resultSet) { + // .cancel() may not be immediate and handleResult() could be called + // after a .cancel(). + for (let row = resultSet.getNextRow(); row && !userCancelled; row = resultSet.getNextRow()) { + if (!onRow) { + rows.push(row); + continue; + } + + handledRow = true; + + try { + onRow(row); + } catch (e) { + if (e instanceof StopIteration) { + userCancelled = true; + pending.cancel(); + break; + } + + self._log.warn("Exception when calling onRow callback", e); + } + } + }, + + handleError: function (error) { + self._log.info("Error when executing SQL (" + + error.result + "): " + error.message); + errors.push(error); + }, + + handleCompletion: function (reason) { + self._log.debug("Stmt #" + index + " finished."); + self._pendingStatements.delete(index); + + switch (reason) { + case Ci.mozIStorageStatementCallback.REASON_FINISHED: + // If there is an onRow handler, we always instead resolve to a + // boolean indicating whether the onRow handler was called or not. + let result = onRow ? handledRow : rows; + deferred.resolve(result); + break; + + case Ci.mozIStorageStatementCallback.REASON_CANCELED: + // It is not an error if the user explicitly requested cancel via + // the onRow handler. + if (userCancelled) { + let result = onRow ? handledRow : rows; + deferred.resolve(result); + } else { + deferred.reject(new Error("Statement was cancelled.")); + } + + break; + + case Ci.mozIStorageStatementCallback.REASON_ERROR: + let error = new Error("Error(s) encountered during statement execution: " + errors.map(e => e.message).join(", ")); + error.errors = errors; + deferred.reject(error); + break; + + default: + deferred.reject(new Error("Unknown completion reason code: " + + reason)); + break; + } + }, + }); + + this._pendingStatements.set(index, pending); + return deferred.promise; + }, + + ensureOpen: function () { + if (!this._open) { + throw new Error("Connection is not open."); + } + }, + + _clearIdleShrinkTimer: function () { + if (!this._idleShrinkTimer) { + return; + } + + this._idleShrinkTimer.cancel(); + }, + + _startIdleShrinkTimer: function () { + if (!this._idleShrinkTimer) { + return; + } + + this._idleShrinkTimer.initWithCallback(this.shrinkMemory.bind(this), + this._idleShrinkMS, + this._idleShrinkTimer.TYPE_ONE_SHOT); + } +}); + +/** + * Opens a connection to a SQLite database. + * + * The following parameters can control the connection: + * + * path -- (string) The filesystem path of the database file to open. If the + * file does not exist, a new database will be created. + * + * sharedMemoryCache -- (bool) Whether multiple connections to the database + * share the same memory cache. Sharing the memory cache likely results + * in less memory utilization. However, sharing also requires connections + * to obtain a lock, possibly making database access slower. Defaults to + * true. + * + * shrinkMemoryOnConnectionIdleMS -- (integer) If defined, the connection + * will attempt to minimize its memory usage after this many + * milliseconds of connection idle. The connection is idle when no + * statements are executing. There is no default value which means no + * automatic memory minimization will occur. Please note that this is + * *not* a timer on the idle service and this could fire while the + * application is active. + * + * readOnly -- (bool) Whether to open the database with SQLITE_OPEN_READONLY + * set. If used, writing to the database will fail. Defaults to false. + * + * ignoreLockingMode -- (bool) Whether to ignore locks on the database held + * by other connections. If used, implies readOnly. Defaults to false. + * USE WITH EXTREME CAUTION. This mode WILL produce incorrect results or + * return "false positive" corruption errors if other connections write + * to the DB at the same time. + * + * FUTURE options to control: + * + * special named databases + * pragma TEMP STORE = MEMORY + * TRUNCATE JOURNAL + * SYNCHRONOUS = full + * + * @param options + * (Object) Parameters to control connection and open options. + * + * @return Promise<OpenedConnection> + */ +function openConnection(options) { + let log = Log.repository.getLogger("Sqlite.ConnectionOpener"); + + if (!options.path) { + throw new Error("path not specified in connection options."); + } + + if (isClosed) { + throw new Error("Sqlite.jsm has been shutdown. Cannot open connection to: " + options.path); + } + + // Retains absolute paths and normalizes relative as relative to profile. + let path = OS.Path.join(OS.Constants.Path.profileDir, options.path); + + let sharedMemoryCache = "sharedMemoryCache" in options ? + options.sharedMemoryCache : true; + + let openedOptions = {}; + + if ("shrinkMemoryOnConnectionIdleMS" in options) { + if (!Number.isInteger(options.shrinkMemoryOnConnectionIdleMS)) { + throw new Error("shrinkMemoryOnConnectionIdleMS must be an integer. " + + "Got: " + options.shrinkMemoryOnConnectionIdleMS); + } + + openedOptions.shrinkMemoryOnConnectionIdleMS = + options.shrinkMemoryOnConnectionIdleMS; + } + + let file = FileUtils.File(path); + let identifier = getIdentifierByPath(path); + + log.info("Opening database: " + path + " (" + identifier + ")"); + + return new Promise((resolve, reject) => { + let dbOptions = Cc["@mozilla.org/hash-property-bag;1"]. + createInstance(Ci.nsIWritablePropertyBag); + if (!sharedMemoryCache) { + dbOptions.setProperty("shared", false); + } + if (options.readOnly) { + dbOptions.setProperty("readOnly", true); + } + if (options.ignoreLockingMode) { + dbOptions.setProperty("ignoreLockingMode", true); + dbOptions.setProperty("readOnly", true); + } + + dbOptions = dbOptions.enumerator.hasMoreElements() ? dbOptions : null; + + Services.storage.openAsyncDatabase(file, dbOptions, (status, connection) => { + if (!connection) { + log.warn(`Could not open connection to ${path}: ${status}`); + reject(new Error(`Could not open connection to ${path}: ${status}`)); + return; + } + log.info("Connection opened"); + try { + resolve( + new OpenedConnection(connection.QueryInterface(Ci.mozIStorageAsyncConnection), + identifier, openedOptions)); + } catch (ex) { + log.warn("Could not open database", ex); + connection.asyncClose(); + reject(ex); + } + }); + }); +} + +/** + * Creates a clone of an existing and open Storage connection. The clone has + * the same underlying characteristics of the original connection and is + * returned in form of an OpenedConnection handle. + * + * The following parameters can control the cloned connection: + * + * connection -- (mozIStorageAsyncConnection) The original Storage connection + * to clone. It's not possible to clone connections to memory databases. + * + * readOnly -- (boolean) - If true the clone will be read-only. If the + * original connection is already read-only, the clone will be, regardless + * of this option. If the original connection is using the shared cache, + * this parameter will be ignored and the clone will be as privileged as + * the original connection. + * shrinkMemoryOnConnectionIdleMS -- (integer) If defined, the connection + * will attempt to minimize its memory usage after this many + * milliseconds of connection idle. The connection is idle when no + * statements are executing. There is no default value which means no + * automatic memory minimization will occur. Please note that this is + * *not* a timer on the idle service and this could fire while the + * application is active. + * + * + * @param options + * (Object) Parameters to control connection and clone options. + * + * @return Promise<OpenedConnection> + */ +function cloneStorageConnection(options) { + let log = Log.repository.getLogger("Sqlite.ConnectionCloner"); + + let source = options && options.connection; + if (!source) { + throw new TypeError("connection not specified in clone options."); + } + if (!source instanceof Ci.mozIStorageAsyncConnection) { + throw new TypeError("Connection must be a valid Storage connection."); + } + + if (isClosed) { + throw new Error("Sqlite.jsm has been shutdown. Cannot clone connection to: " + source.database.path); + } + + let openedOptions = {}; + + if ("shrinkMemoryOnConnectionIdleMS" in options) { + if (!Number.isInteger(options.shrinkMemoryOnConnectionIdleMS)) { + throw new TypeError("shrinkMemoryOnConnectionIdleMS must be an integer. " + + "Got: " + options.shrinkMemoryOnConnectionIdleMS); + } + openedOptions.shrinkMemoryOnConnectionIdleMS = + options.shrinkMemoryOnConnectionIdleMS; + } + + let path = source.databaseFile.path; + let identifier = getIdentifierByPath(path); + + log.info("Cloning database: " + path + " (" + identifier + ")"); + + return new Promise((resolve, reject) => { + source.asyncClone(!!options.readOnly, (status, connection) => { + if (!connection) { + log.warn("Could not clone connection: " + status); + reject(new Error("Could not clone connection: " + status)); + } + log.info("Connection cloned"); + try { + let conn = connection.QueryInterface(Ci.mozIStorageAsyncConnection); + resolve(new OpenedConnection(conn, identifier, openedOptions)); + } catch (ex) { + log.warn("Could not clone database", ex); + connection.asyncClose(); + reject(ex); + } + }); + }); +} + +/** + * Wraps an existing and open Storage connection with Sqlite.jsm API. The + * wrapped connection clone has the same underlying characteristics of the + * original connection and is returned in form of an OpenedConnection handle. + * + * Clients are responsible for closing both the Sqlite.jsm wrapper and the + * underlying mozStorage connection. + * + * The following parameters can control the wrapped connection: + * + * connection -- (mozIStorageAsyncConnection) The original Storage connection + * to wrap. + * + * @param options + * (Object) Parameters to control connection and wrap options. + * + * @return Promise<OpenedConnection> + */ +function wrapStorageConnection(options) { + let log = Log.repository.getLogger("Sqlite.ConnectionWrapper"); + + let connection = options && options.connection; + if (!connection || !(connection instanceof Ci.mozIStorageAsyncConnection)) { + throw new TypeError("connection not specified or invalid."); + } + + if (isClosed) { + throw new Error("Sqlite.jsm has been shutdown. Cannot wrap connection to: " + connection.database.path); + } + + let path = connection.databaseFile.path; + let identifier = getIdentifierByPath(path); + + log.info("Wrapping database: " + path + " (" + identifier + ")"); + return new Promise(resolve => { + try { + let conn = connection.QueryInterface(Ci.mozIStorageAsyncConnection); + let wrapper = new OpenedConnection(conn, identifier); + // We must not handle shutdown of a wrapped connection, since that is + // already handled by the opener. + wrappedConnections.add(identifier); + resolve(wrapper); + } catch (ex) { + log.warn("Could not wrap database", ex); + throw ex; + } + }); +} + +/** + * Handle on an opened SQLite database. + * + * This is essentially a glorified wrapper around mozIStorageConnection. + * However, it offers some compelling advantages. + * + * The main functions on this type are `execute` and `executeCached`. These are + * ultimately how all SQL statements are executed. It's worth explaining their + * differences. + * + * `execute` is used to execute one-shot SQL statements. These are SQL + * statements that are executed one time and then thrown away. They are useful + * for dynamically generated SQL statements and clients who don't care about + * performance (either their own or wasting resources in the overall + * application). Because of the performance considerations, it is recommended + * to avoid `execute` unless the statement you are executing will only be + * executed once or seldomly. + * + * `executeCached` is used to execute a statement that will presumably be + * executed multiple times. The statement is parsed once and stuffed away + * inside the connection instance. Subsequent calls to `executeCached` will not + * incur the overhead of creating a new statement object. This should be used + * in preference to `execute` when a specific SQL statement will be executed + * multiple times. + * + * Instances of this type are not meant to be created outside of this file. + * Instead, first open an instance of `UnopenedSqliteConnection` and obtain + * an instance of this type by calling `open`. + * + * FUTURE IMPROVEMENTS + * + * Ability to enqueue operations. Currently there can be race conditions, + * especially as far as transactions are concerned. It would be nice to have + * an enqueueOperation(func) API that serially executes passed functions. + * + * Support for SAVEPOINT (named/nested transactions) might be useful. + * + * @param connection + * (mozIStorageConnection) Underlying SQLite connection. + * @param identifier + * (string) The unique identifier of this database. It may be used for + * logging or as a key in Maps. + * @param options [optional] + * (object) Options to control behavior of connection. See + * `openConnection`. + */ +function OpenedConnection(connection, identifier, options={}) { + // Store all connection data in a field distinct from the + // witness. This enables us to store an additional reference to this + // field without preventing garbage collection of + // OpenedConnection. On garbage collection, we will still be able to + // close the database using this extra reference. + this._connectionData = new ConnectionData(connection, identifier, options); + + // Store the extra reference in a map with connection identifier as + // key. + ConnectionData.byId.set(this._connectionData._identifier, + this._connectionData); + + // Make a finalization witness. If this object is garbage collected + // before its `forget` method has been called, an event with topic + // "sqlite-finalization-witness" is broadcasted along with the + // connection identifier string of the database. + this._witness = FinalizationWitnessService.make( + "sqlite-finalization-witness", + this._connectionData._identifier); +} + +OpenedConnection.prototype = Object.freeze({ + TRANSACTION_DEFERRED: "DEFERRED", + TRANSACTION_IMMEDIATE: "IMMEDIATE", + TRANSACTION_EXCLUSIVE: "EXCLUSIVE", + + TRANSACTION_TYPES: ["DEFERRED", "IMMEDIATE", "EXCLUSIVE"], + + /** + * The integer schema version of the database. + * + * This is 0 if not schema version has been set. + * + * @return Promise<int> + */ + getSchemaVersion: function() { + let self = this; + return this.execute("PRAGMA user_version").then( + function onSuccess(result) { + if (result == null) { + return 0; + } + return JSON.stringify(result[0].getInt32(0)); + } + ); + }, + + setSchemaVersion: function(value) { + if (!Number.isInteger(value)) { + // Guarding against accidental SQLi + throw new TypeError("Schema version must be an integer. Got " + value); + } + this._connectionData.ensureOpen(); + return this.execute("PRAGMA user_version = " + value); + }, + + /** + * Close the database connection. + * + * This must be performed when you are finished with the database. + * + * Closing the database connection has the side effect of forcefully + * cancelling all active statements. Therefore, callers should ensure that + * all active statements have completed before closing the connection, if + * possible. + * + * The returned promise will be resolved once the connection is closed. + * Successive calls to close() return the same promise. + * + * IMPROVEMENT: Resolve the promise to a closed connection which can be + * reopened. + * + * @return Promise<> + */ + close: function () { + // Unless cleanup has already been done by a previous call to + // `close`, delete the database entry from map and tell the + // finalization witness to forget. + if (ConnectionData.byId.has(this._connectionData._identifier)) { + ConnectionData.byId.delete(this._connectionData._identifier); + this._witness.forget(); + } + return this._connectionData.close(); + }, + + /** + * Clones this connection to a new Sqlite one. + * + * The following parameters can control the cloned connection: + * + * @param readOnly + * (boolean) - If true the clone will be read-only. If the original + * connection is already read-only, the clone will be, regardless of + * this option. If the original connection is using the shared cache, + * this parameter will be ignored and the clone will be as privileged as + * the original connection. + * + * @return Promise<OpenedConnection> + */ + clone: function (readOnly=false) { + return this._connectionData.clone(readOnly); + }, + + executeBeforeShutdown: function(name, task) { + return this._connectionData.executeBeforeShutdown(this, name, task); + }, + + /** + * Execute a SQL statement and cache the underlying statement object. + * + * This function executes a SQL statement and also caches the underlying + * derived statement object so subsequent executions are faster and use + * less resources. + * + * This function optionally binds parameters to the statement as well as + * optionally invokes a callback for every row retrieved. + * + * By default, no parameters are bound and no callback will be invoked for + * every row. + * + * Bound parameters can be defined as an Array of positional arguments or + * an object mapping named parameters to their values. If there are no bound + * parameters, the caller can pass nothing or null for this argument. + * + * Callers are encouraged to pass objects rather than Arrays for bound + * parameters because they prevent foot guns. With positional arguments, it + * is simple to modify the parameter count or positions without fixing all + * users of the statement. Objects/named parameters are a little safer + * because changes in order alone won't result in bad things happening. + * + * When `onRow` is not specified, all returned rows are buffered before the + * returned promise is resolved. For INSERT or UPDATE statements, this has + * no effect because no rows are returned from these. However, it has + * implications for SELECT statements. + * + * If your SELECT statement could return many rows or rows with large amounts + * of data, for performance reasons it is recommended to pass an `onRow` + * handler. Otherwise, the buffering may consume unacceptable amounts of + * resources. + * + * If a `StopIteration` is thrown during execution of an `onRow` handler, + * the execution of the statement is immediately cancelled. Subsequent + * rows will not be processed and no more `onRow` invocations will be made. + * The promise is resolved immediately. + * + * If a non-`StopIteration` exception is thrown by the `onRow` handler, the + * exception is logged and processing of subsequent rows occurs as if nothing + * happened. The promise is still resolved (not rejected). + * + * The return value is a promise that will be resolved when the statement + * has completed fully. + * + * The promise will be rejected with an `Error` instance if the statement + * did not finish execution fully. The `Error` may have an `errors` property. + * If defined, it will be an Array of objects describing individual errors. + * Each object has the properties `result` and `message`. `result` is a + * numeric error code and `message` is a string description of the problem. + * + * @param name + * (string) The name of the registered statement to execute. + * @param params optional + * (Array or object) Parameters to bind. + * @param onRow optional + * (function) Callback to receive each row from result. + */ + executeCached: function (sql, params=null, onRow=null) { + if (isInvalidBoundLikeQuery(sql)) { + throw new Error("Please enter a LIKE clause with bindings"); + } + return this._connectionData.executeCached(sql, params, onRow); + }, + + /** + * Execute a one-shot SQL statement. + * + * If you find yourself feeding the same SQL string in this function, you + * should *not* use this function and instead use `executeCached`. + * + * See `executeCached` for the meaning of the arguments and extended usage info. + * + * @param sql + * (string) SQL to execute. + * @param params optional + * (Array or Object) Parameters to bind to the statement. + * @param onRow optional + * (function) Callback to receive result of a single row. + */ + execute: function (sql, params=null, onRow=null) { + if (isInvalidBoundLikeQuery(sql)) { + throw new Error("Please enter a LIKE clause with bindings"); + } + return this._connectionData.execute(sql, params, onRow); + }, + + /** + * Whether a transaction is currently in progress. + */ + get transactionInProgress() { + return this._connectionData.transactionInProgress; + }, + + /** + * Perform a transaction. + * + * ***************************************************************************** + * YOU SHOULD _NEVER_ NEST executeTransaction CALLS FOR ANY REASON, NOR + * DIRECTLY, NOR THROUGH OTHER PROMISES. + * FOR EXAMPLE, NEVER DO SOMETHING LIKE: + * yield executeTransaction(function* () { + * ...some_code... + * yield executeTransaction(function* () { // WRONG! + * ...some_code... + * }) + * yield someCodeThatExecuteTransaction(); // WRONG! + * yield neverResolvedPromise; // WRONG! + * }); + * NESTING CALLS WILL BLOCK ANY FUTURE TRANSACTION UNTIL A TIMEOUT KICKS IN. + * ***************************************************************************** + * + * A transaction is specified by a user-supplied function that is a + * generator function which can be used by Task.jsm's Task.spawn(). The + * function receives this connection instance as its argument. + * + * The supplied function is expected to yield promises. These are often + * promises created by calling `execute` and `executeCached`. If the + * generator is exhausted without any errors being thrown, the + * transaction is committed. If an error occurs, the transaction is + * rolled back. + * + * The returned value from this function is a promise that will be resolved + * once the transaction has been committed or rolled back. The promise will + * be resolved to whatever value the supplied function resolves to. If + * the transaction is rolled back, the promise is rejected. + * + * @param func + * (function) What to perform as part of the transaction. + * @param type optional + * One of the TRANSACTION_* constants attached to this type. + */ + executeTransaction: function (func, type=this.TRANSACTION_DEFERRED) { + if (this.TRANSACTION_TYPES.indexOf(type) == -1) { + throw new Error("Unknown transaction type: " + type); + } + + return this._connectionData.executeTransaction(() => func(this), type); + }, + + /** + * Whether a table exists in the database (both persistent and temporary tables). + * + * @param name + * (string) Name of the table. + * + * @return Promise<bool> + */ + tableExists: function (name) { + return this.execute( + "SELECT name FROM (SELECT * FROM sqlite_master UNION ALL " + + "SELECT * FROM sqlite_temp_master) " + + "WHERE type = 'table' AND name=?", + [name]) + .then(function onResult(rows) { + return Promise.resolve(rows.length > 0); + } + ); + }, + + /** + * Whether a named index exists (both persistent and temporary tables). + * + * @param name + * (string) Name of the index. + * + * @return Promise<bool> + */ + indexExists: function (name) { + return this.execute( + "SELECT name FROM (SELECT * FROM sqlite_master UNION ALL " + + "SELECT * FROM sqlite_temp_master) " + + "WHERE type = 'index' AND name=?", + [name]) + .then(function onResult(rows) { + return Promise.resolve(rows.length > 0); + } + ); + }, + + /** + * Free up as much memory from the underlying database connection as possible. + * + * @return Promise<> + */ + shrinkMemory: function () { + return this._connectionData.shrinkMemory(); + }, + + /** + * Discard all cached statements. + * + * Note that this relies on us being non-interruptible between + * the insertion or retrieval of a statement in the cache and its + * execution: we finalize all statements, which is only safe if + * they will not be executed again. + * + * @return (integer) the number of statements discarded. + */ + discardCachedStatements: function () { + return this._connectionData.discardCachedStatements(); + }, +}); + +this.Sqlite = { + openConnection: openConnection, + cloneStorageConnection: cloneStorageConnection, + wrapStorageConnection: wrapStorageConnection, + /** + * Shutdown barrier client. May be used by clients to perform last-minute + * cleanup prior to the shutdown of this module. + * + * See the documentation of AsyncShutdown.Barrier.prototype.client. + */ + get shutdown() { + return Barriers.shutdown.client; + } +}; diff --git a/toolkit/modules/Task.jsm b/toolkit/modules/Task.jsm new file mode 100644 index 0000000000..109294f61c --- /dev/null +++ b/toolkit/modules/Task.jsm @@ -0,0 +1,527 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ +/* 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 = [ + "Task" +]; + +/** + * This module implements a subset of "Task.js" <http://taskjs.org/>. + * + * Paraphrasing from the Task.js site, tasks make sequential, asynchronous + * operations simple, using the power of JavaScript's "yield" operator. + * + * Tasks are built upon generator functions and promises, documented here: + * + * <https://developer.mozilla.org/en/JavaScript/Guide/Iterators_and_Generators> + * <http://wiki.commonjs.org/wiki/Promises/A> + * + * The "Task.spawn" function takes a generator function and starts running it as + * a task. Every time the task yields a promise, it waits until the promise is + * fulfilled. "Task.spawn" returns a promise that is resolved when the task + * completes successfully, or is rejected if an exception occurs. + * + * ----------------------------------------------------------------------------- + * + * Cu.import("resource://gre/modules/Task.jsm"); + * + * Task.spawn(function* () { + * + * // This is our task. Let's create a promise object, wait on it and capture + * // its resolution value. + * let myPromise = getPromiseResolvedOnTimeoutWithValue(1000, "Value"); + * let result = yield myPromise; + * + * // This part is executed only after the promise above is fulfilled (after + * // one second, in this imaginary example). We can easily loop while + * // calling asynchronous functions, and wait multiple times. + * for (let i = 0; i < 3; i++) { + * result += yield getPromiseResolvedOnTimeoutWithValue(50, "!"); + * } + * + * return "Resolution result for the task: " + result; + * }).then(function (result) { + * + * // result == "Resolution result for the task: Value!!!" + * + * // The result is undefined if no value was returned. + * + * }, function (exception) { + * + * // Failure! We can inspect or report the exception. + * + * }); + * + * ----------------------------------------------------------------------------- + * + * This module implements only the "Task.js" interfaces described above, with no + * additional features to control the task externally, or do custom scheduling. + * It also provides the following extensions that simplify task usage in the + * most common cases: + * + * - The "Task.spawn" function also accepts an iterator returned by a generator + * function, in addition to a generator function. This way, you can call into + * the generator function with the parameters you want, and with "this" bound + * to the correct value. Also, "this" is never bound to the task object when + * "Task.spawn" calls the generator function. + * + * - In addition to a promise object, a task can yield the iterator returned by + * a generator function. The iterator is turned into a task automatically. + * This reduces the syntax overhead of calling "Task.spawn" explicitly when + * you want to recurse into other task functions. + * + * - The "Task.spawn" function also accepts a primitive value, or a function + * returning a primitive value, and treats the value as the result of the + * task. This makes it possible to call an externally provided function and + * spawn a task from it, regardless of whether it is an asynchronous generator + * or a synchronous function. This comes in handy when iterating over + * function lists where some items have been converted to tasks and some not. + */ + +// Globals + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; + +// For now, we're worried about add-ons using Tasks with CPOWs, so we'll +// permit them in this scope, but this support will go away soon. +Cu.permitCPOWsInScope(this); + +Cu.import("resource://gre/modules/Promise.jsm"); + +// The following error types are considered programmer errors, which should be +// reported (possibly redundantly) so as to let programmers fix their code. +const ERRORS_TO_REPORT = ["EvalError", "RangeError", "ReferenceError", "TypeError"]; + +/** + * The Task currently being executed + */ +var gCurrentTask = null; + +/** + * If `true`, capture stacks whenever entering a Task and rewrite the + * stack any exception thrown through a Task. + */ +var gMaintainStack = false; + + +/** + * Iterate through the lines of a string. + * + * @return Iterator<string> + */ +function* linesOf(string) { + let reLine = /([^\r\n])+/g; + let match; + while ((match = reLine.exec(string))) { + yield [match[0], match.index]; + } +} + +/** + * Detect whether a value is a generator. + * + * @param aValue + * The value to identify. + * @return A boolean indicating whether the value is a generator. + */ +function isGenerator(aValue) { + return Object.prototype.toString.call(aValue) == "[object Generator]"; +} + +// Task + +/** + * This object provides the public module functions. + */ +this.Task = { + /** + * Creates and starts a new task. + * + * @param aTask + * - If you specify a generator function, it is called with no + * arguments to retrieve the associated iterator. The generator + * function is a task, that is can yield promise objects to wait + * upon. + * - If you specify the iterator returned by a generator function you + * called, the generator function is also executed as a task. This + * allows you to call the function with arguments. + * - If you specify a function that is not a generator, it is called + * with no arguments, and its return value is used to resolve the + * returned promise. + * - If you specify anything else, you get a promise that is already + * resolved with the specified value. + * + * @return A promise object where you can register completion callbacks to be + * called when the task terminates. + */ + spawn: function Task_spawn(aTask) { + return createAsyncFunction(aTask).call(undefined); + }, + + /** + * Create and return an 'async function' that starts a new task. + * + * This is similar to 'spawn' except that it doesn't immediately start + * the task, it binds the task to the async function's 'this' object and + * arguments, and it requires the task to be a function. + * + * It simplifies the common pattern of implementing a method via a task, + * like this simple object with a 'greet' method that has a 'name' parameter + * and spawns a task to send a greeting and return its reply: + * + * let greeter = { + * message: "Hello, NAME!", + * greet: function(name) { + * return Task.spawn((function* () { + * return yield sendGreeting(this.message.replace(/NAME/, name)); + * }).bind(this); + * }) + * }; + * + * With Task.async, the method can be declared succinctly: + * + * let greeter = { + * message: "Hello, NAME!", + * greet: Task.async(function* (name) { + * return yield sendGreeting(this.message.replace(/NAME/, name)); + * }) + * }; + * + * While maintaining identical semantics: + * + * greeter.greet("Mitchell").then((reply) => { ... }); // behaves the same + * + * @param aTask + * The task function to start. + * + * @return A function that starts the task function and returns its promise. + */ + async: function Task_async(aTask) { + if (typeof(aTask) != "function") { + throw new TypeError("aTask argument must be a function"); + } + + return createAsyncFunction(aTask); + }, + + /** + * Constructs a special exception that, when thrown inside a legacy generator + * function (non-star generator), allows the associated task to be resolved + * with a specific value. + * + * Example: throw new Task.Result("Value"); + */ + Result: function Task_Result(aValue) { + this.value = aValue; + } +}; + +function createAsyncFunction(aTask) { + let asyncFunction = function () { + let result = aTask; + if (aTask && typeof(aTask) == "function") { + if (aTask.isAsyncFunction) { + throw new TypeError( + "Cannot use an async function in place of a promise. " + + "You should either invoke the async function first " + + "or use 'Task.spawn' instead of 'Task.async' to start " + + "the Task and return its promise."); + } + + try { + // Let's call into the function ourselves. + result = aTask.apply(this, arguments); + } catch (ex) { + if (ex instanceof Task.Result) { + return Promise.resolve(ex.value); + } + return Promise.reject(ex); + } + } + + if (isGenerator(result)) { + // This is an iterator resulting from calling a generator function. + return new TaskImpl(result).deferred.promise; + } + + // Just propagate the given value to the caller as a resolved promise. + return Promise.resolve(result); + }; + + asyncFunction.isAsyncFunction = true; + + return asyncFunction; +} + +// TaskImpl + +/** + * Executes the specified iterator as a task, and gives access to the promise + * that is fulfilled when the task terminates. + */ +function TaskImpl(iterator) { + if (gMaintainStack) { + this._stack = (new Error()).stack; + } + this.deferred = Promise.defer(); + this._iterator = iterator; + this._isStarGenerator = !("send" in iterator); + this._run(true); +} + +TaskImpl.prototype = { + /** + * Includes the promise object where task completion callbacks are registered, + * and methods to resolve or reject the promise at task completion. + */ + deferred: null, + + /** + * The iterator returned by the generator function associated with this task. + */ + _iterator: null, + + /** + * Whether this Task is using a star generator. + */ + _isStarGenerator: false, + + /** + * Main execution routine, that calls into the generator function. + * + * @param aSendResolved + * If true, indicates that we should continue into the generator + * function regularly (if we were waiting on a promise, it was + * resolved). If true, indicates that we should cause an exception to + * be thrown into the generator function (if we were waiting on a + * promise, it was rejected). + * @param aSendValue + * Resolution result or rejection exception, if any. + */ + _run: function TaskImpl_run(aSendResolved, aSendValue) { + + try { + gCurrentTask = this; + + if (this._isStarGenerator) { + if (Cu.isDeadWrapper(this._iterator)) { + this.deferred.resolve(undefined); + } else { + try { + let result = aSendResolved ? this._iterator.next(aSendValue) + : this._iterator.throw(aSendValue); + + if (result.done) { + // The generator function returned. + this.deferred.resolve(result.value); + } else { + // The generator function yielded. + this._handleResultValue(result.value); + } + } catch (ex) { + // The generator function failed with an uncaught exception. + this._handleException(ex); + } + } + } else { + try { + let yielded = aSendResolved ? this._iterator.send(aSendValue) + : this._iterator.throw(aSendValue); + this._handleResultValue(yielded); + } catch (ex) { + if (ex instanceof Task.Result) { + // The generator function threw the special exception that allows it to + // return a specific value on resolution. + this.deferred.resolve(ex.value); + } else if (ex instanceof StopIteration) { + // The generator function terminated with no specific result. + this.deferred.resolve(undefined); + } else { + // The generator function failed with an uncaught exception. + this._handleException(ex); + } + } + } + } finally { + // + // At this stage, the Task may have finished executing, or have + // walked through a `yield` or passed control to a sub-Task. + // Regardless, if we still own `gCurrentTask`, reset it. If we + // have not finished execution of this Task, re-entering `_run` + // will set `gCurrentTask` to `this` as needed. + // + // We just need to be careful here in case we hit the following + // pattern: + // + // Task.spawn(foo); + // Task.spawn(bar); + // + // Here, `foo` and `bar` may be interleaved, so when we finish + // executing `foo`, `gCurrentTask` may actually either `foo` or + // `bar`. If `gCurrentTask` has already been set to `bar`, leave + // it be and it will be reset to `null` once `bar` is complete. + // + if (gCurrentTask == this) { + gCurrentTask = null; + } + } + }, + + /** + * Handle a value yielded by a generator. + * + * @param aValue + * The yielded value to handle. + */ + _handleResultValue: function TaskImpl_handleResultValue(aValue) { + // If our task yielded an iterator resulting from calling another + // generator function, automatically spawn a task from it, effectively + // turning it into a promise that is fulfilled on task completion. + if (isGenerator(aValue)) { + aValue = Task.spawn(aValue); + } + + if (aValue && typeof(aValue.then) == "function") { + // We have a promise object now. When fulfilled, call again into this + // function to continue the task, with either a resolution or rejection + // condition. + aValue.then(this._run.bind(this, true), + this._run.bind(this, false)); + } else { + // If our task yielded a value that is not a promise, just continue and + // pass it directly as the result of the yield statement. + this._run(true, aValue); + } + }, + + /** + * Handle an uncaught exception thrown from a generator. + * + * @param aException + * The uncaught exception to handle. + */ + _handleException: function TaskImpl_handleException(aException) { + + gCurrentTask = this; + + if (aException && typeof aException == "object" && "stack" in aException) { + + let stack = aException.stack; + + if (gMaintainStack && + aException._capturedTaskStack != this._stack && + typeof stack == "string") { + + // Rewrite the stack for more readability. + + let bottomStack = this._stack; + let topStack = stack; + + stack = Task.Debugging.generateReadableStack(stack); + + aException.stack = stack; + + // If aException is reinjected in the same task and rethrown, + // we don't want to perform the rewrite again. + aException._capturedTaskStack = bottomStack; + } else if (!stack) { + stack = "Not available"; + } + + if ("name" in aException && + ERRORS_TO_REPORT.indexOf(aException.name) != -1) { + + // We suspect that the exception is a programmer error, so we now + // display it using dump(). Note that we do not use Cu.reportError as + // we assume that this is a programming error, so we do not want end + // users to see it. Also, if the programmer handles errors correctly, + // they will either treat the error or log them somewhere. + + dump("*************************\n"); + dump("A coding exception was thrown and uncaught in a Task.\n\n"); + dump("Full message: " + aException + "\n"); + dump("Full stack: " + aException.stack + "\n"); + dump("*************************\n"); + } + } + + this.deferred.reject(aException); + }, + + get callerStack() { + // Cut `this._stack` at the last line of the first block that + // contains Task.jsm, keep the tail. + for (let [line, index] of linesOf(this._stack || "")) { + if (line.indexOf("/Task.jsm:") == -1) { + return this._stack.substring(index); + } + } + return ""; + } +}; + + +Task.Debugging = { + + /** + * Control stack rewriting. + * + * If `true`, any exception thrown from a Task will be rewritten to + * provide a human-readable stack trace. Otherwise, stack traces will + * be left unchanged. + * + * There is a (small but existing) runtime cost associated to stack + * rewriting, so you should probably not activate this in production + * code. + * + * @type {bool} + */ + get maintainStack() { + return gMaintainStack; + }, + set maintainStack(x) { + if (!x) { + gCurrentTask = null; + } + return gMaintainStack = x; + }, + + /** + * Generate a human-readable stack for an error raised in + * a Task. + * + * @param {string} topStack The stack provided by the error. + * @param {string=} prefix Optionally, a prefix for each line. + */ + generateReadableStack: function(topStack, prefix = "") { + if (!gCurrentTask) { + return topStack; + } + + // Cut `topStack` at the first line that contains Task.jsm, keep the head. + let lines = []; + for (let [line] of linesOf(topStack)) { + if (line.indexOf("/Task.jsm:") != -1) { + break; + } + lines.push(prefix + line); + } + if (!prefix) { + lines.push(gCurrentTask.callerStack); + } else { + for (let [line] of linesOf(gCurrentTask.callerStack)) { + lines.push(prefix + line); + } + } + + return lines.join("\n"); + } +}; diff --git a/toolkit/modules/Timer.jsm b/toolkit/modules/Timer.jsm new file mode 100644 index 0000000000..caef68eac0 --- /dev/null +++ b/toolkit/modules/Timer.jsm @@ -0,0 +1,54 @@ +/* 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"; + +/** + * JS module implementation of setTimeout and clearTimeout. + */ + +this.EXPORTED_SYMBOLS = ["setTimeout", "clearTimeout", "setInterval", "clearInterval"]; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +// This gives us >=2^30 unique timer IDs, enough for 1 per ms for 12.4 days. +var gNextId = 1; // setTimeout and setInterval must return a positive integer + +var gTimerTable = new Map(); // int -> nsITimer + +this.setTimeout = function setTimeout(aCallback, aMilliseconds) { + let id = gNextId++; + let args = Array.slice(arguments, 2); + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(function setTimeout_timer() { + gTimerTable.delete(id); + aCallback.apply(null, args); + }, aMilliseconds, timer.TYPE_ONE_SHOT); + + gTimerTable.set(id, timer); + return id; +} + +this.setInterval = function setInterval(aCallback, aMilliseconds) { + let id = gNextId++; + let args = Array.slice(arguments, 2); + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(function setInterval_timer() { + aCallback.apply(null, args); + }, aMilliseconds, timer.TYPE_REPEATING_SLACK); + + gTimerTable.set(id, timer); + return id; +} + +this.clearInterval = this.clearTimeout = function clearTimeout(aId) { + if (gTimerTable.has(aId)) { + gTimerTable.get(aId).cancel(); + gTimerTable.delete(aId); + } +} diff --git a/toolkit/modules/Troubleshoot.jsm b/toolkit/modules/Troubleshoot.jsm new file mode 100644 index 0000000000..cc545b4c40 --- /dev/null +++ b/toolkit/modules/Troubleshoot.jsm @@ -0,0 +1,589 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = [ + "Troubleshoot", +]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/AddonManager.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/AppConstants.jsm"); + +var Experiments; +try { + Experiments = Cu.import("resource:///modules/experiments/Experiments.jsm").Experiments; +} +catch (e) { +} + +// We use a preferences whitelist to make sure we only show preferences that +// are useful for support and won't compromise the user's privacy. Note that +// entries are *prefixes*: for example, "accessibility." applies to all prefs +// under the "accessibility.*" branch. +const PREFS_WHITELIST = [ + "accessibility.", + "apz.", + "browser.cache.", + "browser.display.", + "browser.download.folderList", + "browser.download.hide_plugins_without_extensions", + "browser.download.importedFromSqlite", + "browser.download.lastDir.savePerSite", + "browser.download.manager.addToRecentDocs", + "browser.download.manager.alertOnEXEOpen", + "browser.download.manager.closeWhenDone", + "browser.download.manager.displayedHistoryDays", + "browser.download.manager.quitBehavior", + "browser.download.manager.resumeOnWakeDelay", + "browser.download.manager.retention", + "browser.download.manager.scanWhenDone", + "browser.download.manager.showAlertOnComplete", + "browser.download.manager.showWhenStarting", + "browser.download.preferred.", + "browser.download.useDownloadDir", + "browser.fixup.", + "browser.history_expire_", + "browser.link.open_newwindow", + "browser.places.", + "browser.privatebrowsing.", + "browser.search.context.loadInBackground", + "browser.search.log", + "browser.search.openintab", + "browser.search.param", + "browser.search.searchEnginesURL", + "browser.search.suggest.enabled", + "browser.search.update", + "browser.search.useDBForOrder", + "browser.sessionstore.", + "browser.startup.homepage", + "browser.tabs.", + "browser.urlbar.", + "browser.zoom.", + "dom.", + "extensions.checkCompatibility", + "extensions.lastAppVersion", + "font.", + "general.autoScroll", + "general.useragent.", + "gfx.", + "html5.", + "image.", + "javascript.", + "keyword.", + "layers.", + "layout.css.dpi", + "media.", + "mousewheel.", + "network.", + "permissions.default.image", + "places.", + "plugin.", + "plugins.", + "print.", + "privacy.", + "security.", + "services.sync.declinedEngines", + "services.sync.lastPing", + "services.sync.lastSync", + "services.sync.numClients", + "services.sync.engine.", + "social.enabled", + "storage.vacuum.last.", + "svg.", + "toolkit.startup.recent_crashes", + "ui.osk.enabled", + "ui.osk.detect_physical_keyboard", + "ui.osk.require_tablet_mode", + "ui.osk.debug.keyboardDisplayReason", + "webgl.", +]; + +// The blacklist, unlike the whitelist, is a list of regular expressions. +const PREFS_BLACKLIST = [ + /^network[.]proxy[.]/, + /[.]print_to_filename$/, + /^print[.]macosx[.]pagesetup/, +]; + +// Table of getters for various preference types. +// It's important to use getComplexValue for strings: it returns Unicode (wchars), getCharPref returns UTF-8 encoded chars. +const PREFS_GETTERS = {}; + +PREFS_GETTERS[Ci.nsIPrefBranch.PREF_STRING] = (prefs, name) => prefs.getComplexValue(name, Ci.nsISupportsString).data; +PREFS_GETTERS[Ci.nsIPrefBranch.PREF_INT] = (prefs, name) => prefs.getIntPref(name); +PREFS_GETTERS[Ci.nsIPrefBranch.PREF_BOOL] = (prefs, name) => prefs.getBoolPref(name); + +// Return the preferences filtered by PREFS_BLACKLIST and PREFS_WHITELIST lists +// and also by the custom 'filter'-ing function. +function getPrefList(filter) { + filter = filter || (name => true); + function getPref(name) { + let type = Services.prefs.getPrefType(name); + if (!(type in PREFS_GETTERS)) + throw new Error("Unknown preference type " + type + " for " + name); + return PREFS_GETTERS[type](Services.prefs, name); + } + + return PREFS_WHITELIST.reduce(function(prefs, branch) { + Services.prefs.getChildList(branch).forEach(function(name) { + if (filter(name) && !PREFS_BLACKLIST.some(re => re.test(name))) + prefs[name] = getPref(name); + }); + return prefs; + }, {}); +} + +this.Troubleshoot = { + + /** + * Captures a snapshot of data that may help troubleshooters troubleshoot + * trouble. + * + * @param done A function that will be asynchronously called when the + * snapshot completes. It will be passed the snapshot object. + */ + snapshot: function snapshot(done) { + let snapshot = {}; + let numPending = Object.keys(dataProviders).length; + function providerDone(providerName, providerData) { + snapshot[providerName] = providerData; + if (--numPending == 0) + // Ensure that done is always and truly called asynchronously. + Services.tm.mainThread.dispatch(done.bind(null, snapshot), + Ci.nsIThread.DISPATCH_NORMAL); + } + for (let name in dataProviders) { + try { + dataProviders[name](providerDone.bind(null, name)); + } + catch (err) { + let msg = "Troubleshoot data provider failed: " + name + "\n" + err; + Cu.reportError(msg); + providerDone(name, msg); + } + } + }, + + kMaxCrashAge: 3 * 24 * 60 * 60 * 1000, // 3 days +}; + +// Each data provider is a name => function mapping. When a snapshot is +// captured, each provider's function is called, and it's the function's job to +// generate the provider's data. The function is passed a "done" callback, and +// when done, it must pass its data to the callback. The resulting snapshot +// object will contain a name => data entry for each provider. +var dataProviders = { + + application: function application(done) { + + let sysInfo = Cc["@mozilla.org/system-info;1"]. + getService(Ci.nsIPropertyBag2); + + let data = { + name: Services.appinfo.name, + osVersion: sysInfo.getProperty("name") + " " + sysInfo.getProperty("version"), + version: AppConstants.MOZ_APP_VERSION_DISPLAY, + buildID: Services.appinfo.appBuildID, + userAgent: Cc["@mozilla.org/network/protocol;1?name=http"]. + getService(Ci.nsIHttpProtocolHandler). + userAgent, + safeMode: Services.appinfo.inSafeMode, + }; + + if (AppConstants.MOZ_UPDATER) + data.updateChannel = Cu.import("resource://gre/modules/UpdateUtils.jsm", {}).UpdateUtils.UpdateChannel; + + try { + data.vendor = Services.prefs.getCharPref("app.support.vendor"); + } + catch (e) {} + let urlFormatter = Cc["@mozilla.org/toolkit/URLFormatterService;1"]. + getService(Ci.nsIURLFormatter); + try { + data.supportURL = urlFormatter.formatURLPref("app.support.baseURL"); + } + catch (e) {} + + data.numTotalWindows = 0; + data.numRemoteWindows = 0; + let winEnumer = Services.wm.getEnumerator("navigator:browser"); + while (winEnumer.hasMoreElements()) { + data.numTotalWindows++; + let remote = winEnumer.getNext(). + QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIWebNavigation). + QueryInterface(Ci.nsILoadContext). + useRemoteTabs; + if (remote) { + data.numRemoteWindows++; + } + } + + data.remoteAutoStart = Services.appinfo.browserTabsRemoteAutostart; + + try { + let e10sStatus = Cc["@mozilla.org/supports-PRUint64;1"] + .createInstance(Ci.nsISupportsPRUint64); + let appinfo = Services.appinfo.QueryInterface(Ci.nsIObserver); + appinfo.observe(e10sStatus, "getE10SBlocked", ""); + data.autoStartStatus = e10sStatus.data; + } catch (e) { + data.autoStartStatus = -1; + } + + done(data); + }, + + extensions: function extensions(done) { + AddonManager.getAddonsByTypes(["extension"], function (extensions) { + extensions.sort(function (a, b) { + if (a.isActive != b.isActive) + return b.isActive ? 1 : -1; + + // In some unfortunate cases addon names can be null. + let aname = a.name || null; + let bname = b.name || null; + let lc = aname.localeCompare(bname); + if (lc != 0) + return lc; + if (a.version != b.version) + return a.version > b.version ? 1 : -1; + return 0; + }); + let props = ["name", "version", "isActive", "id"]; + done(extensions.map(function (ext) { + return props.reduce(function (extData, prop) { + extData[prop] = ext[prop]; + return extData; + }, {}); + })); + }); + }, + + experiments: function experiments(done) { + if (Experiments === undefined) { + done([]); + return; + } + + // getExperiments promises experiment history + Experiments.instance().getExperiments().then( + experiments => done(experiments) + ); + }, + + modifiedPreferences: function modifiedPreferences(done) { + done(getPrefList(name => Services.prefs.prefHasUserValue(name))); + }, + + lockedPreferences: function lockedPreferences(done) { + done(getPrefList(name => Services.prefs.prefIsLocked(name))); + }, + + graphics: function graphics(done) { + function statusMsgForFeature(feature) { + // We return an array because in the tryNewerDriver case we need to + // include the suggested version, which the consumer likely needs to plug + // into a format string from a localization file. Rather than returning + // a string in some cases and an array in others, return an array always. + let msg = [""]; + try { + var status = gfxInfo.getFeatureStatus(feature); + } + catch (e) {} + switch (status) { + case Ci.nsIGfxInfo.FEATURE_BLOCKED_DEVICE: + case Ci.nsIGfxInfo.FEATURE_DISCOURAGED: + msg = ["blockedGfxCard"]; + break; + case Ci.nsIGfxInfo.FEATURE_BLOCKED_OS_VERSION: + msg = ["blockedOSVersion"]; + break; + case Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION: + try { + var suggestedDriverVersion = + gfxInfo.getFeatureSuggestedDriverVersion(feature); + } + catch (e) {} + msg = suggestedDriverVersion ? + ["tryNewerDriver", suggestedDriverVersion] : + ["blockedDriver"]; + break; + case Ci.nsIGfxInfo.FEATURE_BLOCKED_MISMATCHED_VERSION: + msg = ["blockedMismatchedVersion"]; + break; + } + return msg; + } + + let data = {}; + + try { + // nsIGfxInfo may not be implemented on some platforms. + var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo); + } + catch (e) {} + + let promises = []; + // done will be called upon all pending promises being resolved. + // add your pending promise to promises when adding new ones. + function completed() { + Promise.all(promises).then(() => done(data)); + } + + data.numTotalWindows = 0; + data.numAcceleratedWindows = 0; + let winEnumer = Services.ww.getWindowEnumerator(); + while (winEnumer.hasMoreElements()) { + let winUtils = winEnumer.getNext(). + QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindowUtils); + try { + // NOTE: windowless browser's windows should not be reported in the graphics troubleshoot report + if (winUtils.layerManagerType == "None") { + continue; + } + data.numTotalWindows++; + data.windowLayerManagerType = winUtils.layerManagerType; + data.windowLayerManagerRemote = winUtils.layerManagerRemote; + } + catch (e) { + continue; + } + if (data.windowLayerManagerType != "Basic") + data.numAcceleratedWindows++; + } + + let winUtils = Services.wm.getMostRecentWindow(""). + QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindowUtils) + data.supportsHardwareH264 = "Unknown"; + let promise = winUtils.supportsHardwareH264Decoding; + promise.then(function(v) { + data.supportsHardwareH264 = v; + }); + promises.push(promise); + + data.currentAudioBackend = winUtils.currentAudioBackend; + + if (!data.numAcceleratedWindows && gfxInfo) { + let win = AppConstants.platform == "win"; + let feature = win ? gfxInfo.FEATURE_DIRECT3D_9_LAYERS : + gfxInfo.FEATURE_OPENGL_LAYERS; + data.numAcceleratedWindowsMessage = statusMsgForFeature(feature); + } + + if (!gfxInfo) { + completed(); + return; + } + + // keys are the names of attributes on nsIGfxInfo, values become the names + // of the corresponding properties in our data object. A null value means + // no change. This is needed so that the names of properties in the data + // object are the same as the names of keys in aboutSupport.properties. + let gfxInfoProps = { + adapterDescription: null, + adapterVendorID: null, + adapterDeviceID: null, + adapterSubsysID: null, + adapterRAM: null, + adapterDriver: "adapterDrivers", + adapterDriverVersion: "driverVersion", + adapterDriverDate: "driverDate", + + adapterDescription2: null, + adapterVendorID2: null, + adapterDeviceID2: null, + adapterSubsysID2: null, + adapterRAM2: null, + adapterDriver2: "adapterDrivers2", + adapterDriverVersion2: "driverVersion2", + adapterDriverDate2: "driverDate2", + isGPU2Active: null, + + D2DEnabled: "direct2DEnabled", + DWriteEnabled: "directWriteEnabled", + DWriteVersion: "directWriteVersion", + cleartypeParameters: "clearTypeParameters", + }; + + for (let prop in gfxInfoProps) { + try { + data[gfxInfoProps[prop] || prop] = gfxInfo[prop]; + } + catch (e) {} + } + + if (("direct2DEnabled" in data) && !data.direct2DEnabled) + data.direct2DEnabledMessage = + statusMsgForFeature(Ci.nsIGfxInfo.FEATURE_DIRECT2D); + + + let doc = + Cc["@mozilla.org/xmlextras/domparser;1"] + .createInstance(Ci.nsIDOMParser) + .parseFromString("<html/>", "text/html"); + + function GetWebGLInfo(contextType) { + let canvas = doc.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + + + let creationError = null; + + canvas.addEventListener( + "webglcontextcreationerror", + + function(e) { + creationError = e.statusMessage; + }, + + false + ); + + let gl = null; + try { + gl = canvas.getContext(contextType); + } + catch (e) { + if (!creationError) { + creationError = e.toString(); + } + } + if (!gl) + return creationError || "(no info)"; + + + let infoExt = gl.getExtension("WEBGL_debug_renderer_info"); + // This extension is unconditionally available to chrome. No need to check. + let vendor = gl.getParameter(infoExt.UNMASKED_VENDOR_WEBGL); + let renderer = gl.getParameter(infoExt.UNMASKED_RENDERER_WEBGL); + + let contextInfo = vendor + " -- " + renderer; + + + // Eagerly free resources. + let loseExt = gl.getExtension("WEBGL_lose_context"); + loseExt.loseContext(); + + + return contextInfo; + } + + + data.webglRenderer = GetWebGLInfo("webgl"); + data.webgl2Renderer = GetWebGLInfo("webgl2"); + + + let infoInfo = gfxInfo.getInfo(); + if (infoInfo) + data.info = infoInfo; + + let failureCount = {}; + let failureIndices = {}; + + let failures = gfxInfo.getFailures(failureCount, failureIndices); + if (failures.length) { + data.failures = failures; + if (failureIndices.value.length == failures.length) { + data.indices = failureIndices.value; + } + } + + data.featureLog = gfxInfo.getFeatureLog(); + data.crashGuards = gfxInfo.getActiveCrashGuards(); + + completed(); + }, + + javaScript: function javaScript(done) { + let data = {}; + let winEnumer = Services.ww.getWindowEnumerator(); + if (winEnumer.hasMoreElements()) + data.incrementalGCEnabled = winEnumer.getNext(). + QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindowUtils). + isIncrementalGCEnabled(); + done(data); + }, + + accessibility: function accessibility(done) { + let data = {}; + data.isActive = Cc["@mozilla.org/xre/app-info;1"]. + getService(Ci.nsIXULRuntime). + accessibilityEnabled; + try { + data.forceDisabled = + Services.prefs.getIntPref("accessibility.force_disabled"); + } + catch (e) {} + done(data); + }, + + libraryVersions: function libraryVersions(done) { + let data = {}; + let verInfo = Cc["@mozilla.org/security/nssversion;1"]. + getService(Ci.nsINSSVersion); + for (let prop in verInfo) { + let match = /^([^_]+)_((Min)?Version)$/.exec(prop); + if (match) { + let verProp = match[2][0].toLowerCase() + match[2].substr(1); + data[match[1]] = data[match[1]] || {}; + data[match[1]][verProp] = verInfo[prop]; + } + } + done(data); + }, + + userJS: function userJS(done) { + let userJSFile = Services.dirsvc.get("PrefD", Ci.nsIFile); + userJSFile.append("user.js"); + done({ + exists: userJSFile.exists() && userJSFile.fileSize > 0, + }); + } +}; + +if (AppConstants.MOZ_CRASHREPORTER) { + dataProviders.crashes = function crashes(done) { + let CrashReports = Cu.import("resource://gre/modules/CrashReports.jsm").CrashReports; + let reports = CrashReports.getReports(); + let now = new Date(); + let reportsNew = reports.filter(report => (now - report.date < Troubleshoot.kMaxCrashAge)); + let reportsSubmitted = reportsNew.filter(report => (!report.pending)); + let reportsPendingCount = reportsNew.length - reportsSubmitted.length; + let data = {submitted : reportsSubmitted, pending : reportsPendingCount}; + done(data); + } +} + +if (AppConstants.MOZ_SANDBOX) { + dataProviders.sandbox = function sandbox(done) { + let data = {}; + if (AppConstants.platform == "linux") { + const keys = ["hasSeccompBPF", "hasSeccompTSync", + "hasPrivilegedUserNamespaces", "hasUserNamespaces", + "canSandboxContent", "canSandboxMedia"]; + + let sysInfo = Cc["@mozilla.org/system-info;1"]. + getService(Ci.nsIPropertyBag2); + for (let key of keys) { + if (sysInfo.hasKey(key)) { + data[key] = sysInfo.getPropertyAsBool(key); + } + } + } + + if (AppConstants.MOZ_CONTENT_SANDBOX) { + data.contentSandboxLevel = + Services.prefs.getIntPref("security.sandbox.content.level"); + } + + done(data); + } +} diff --git a/toolkit/modules/UpdateUtils.jsm b/toolkit/modules/UpdateUtils.jsm new file mode 100644 index 0000000000..ef84753647 --- /dev/null +++ b/toolkit/modules/UpdateUtils.jsm @@ -0,0 +1,392 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["UpdateUtils"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/ctypes.jsm"); + +const FILE_UPDATE_LOCALE = "update.locale"; +const PREF_APP_DISTRIBUTION = "distribution.id"; +const PREF_APP_DISTRIBUTION_VERSION = "distribution.version"; +const PREF_APP_B2G_VERSION = "b2g.version"; +const PREF_APP_UPDATE_CUSTOM = "app.update.custom"; +const PREF_APP_UPDATE_IMEI_HASH = "app.update.imei_hash"; + + +this.UpdateUtils = { + /** + * Read the update channel from defaults only. We do this to ensure that + * the channel is tightly coupled with the application and does not apply + * to other instances of the application that may use the same profile. + * + * @param [optional] aIncludePartners + * Whether or not to include the partner bits. Default: true. + */ + getUpdateChannel(aIncludePartners = true) { + let channel = AppConstants.MOZ_UPDATE_CHANNEL; + let defaults = Services.prefs.getDefaultBranch(null); + try { + channel = defaults.getCharPref("app.update.channel"); + } catch (e) { + // use default value when pref not found + } + + if (aIncludePartners) { + try { + let partners = Services.prefs.getChildList("app.partner.").sort(); + if (partners.length) { + channel += "-cck"; + partners.forEach(function (prefName) { + channel += "-" + Services.prefs.getCharPref(prefName); + }); + } + } catch (e) { + Cu.reportError(e); + } + } + + return channel; + }, + + get UpdateChannel() { + return this.getUpdateChannel(); + }, + + /** + * Formats a URL by replacing %...% values with OS, build and locale specific + * values. + * + * @param url + * The URL to format. + * @return The formatted URL. + */ + formatUpdateURL(url) { + url = url.replace(/%PRODUCT%/g, Services.appinfo.name); + url = url.replace(/%VERSION%/g, Services.appinfo.version); + url = url.replace(/%BUILD_ID%/g, Services.appinfo.appBuildID); + url = url.replace(/%BUILD_TARGET%/g, Services.appinfo.OS + "_" + this.ABI); + url = url.replace(/%OS_VERSION%/g, this.OSVersion); + url = url.replace(/%SYSTEM_CAPABILITIES%/g, gSystemCapabilities); + if (/%LOCALE%/.test(url)) { + url = url.replace(/%LOCALE%/g, this.Locale); + } + url = url.replace(/%CHANNEL%/g, this.UpdateChannel); + url = url.replace(/%PLATFORM_VERSION%/g, Services.appinfo.platformVersion); + url = url.replace(/%DISTRIBUTION%/g, + getDistributionPrefValue(PREF_APP_DISTRIBUTION)); + url = url.replace(/%DISTRIBUTION_VERSION%/g, + getDistributionPrefValue(PREF_APP_DISTRIBUTION_VERSION)); + url = url.replace(/%CUSTOM%/g, Preferences.get(PREF_APP_UPDATE_CUSTOM, "")); + url = url.replace(/\+/g, "%2B"); + + if (AppConstants.platform == "gonk") { + let sysLibs = {}; + Cu.import("resource://gre/modules/systemlibs.js", sysLibs); + let productDevice = sysLibs.libcutils.property_get("ro.product.device"); + let buildType = sysLibs.libcutils.property_get("ro.build.type"); + url = url.replace(/%PRODUCT_MODEL%/g, + sysLibs.libcutils.property_get("ro.product.model")); + if (buildType == "user" || buildType == "userdebug") { + url = url.replace(/%PRODUCT_DEVICE%/g, productDevice); + } else { + url = url.replace(/%PRODUCT_DEVICE%/g, productDevice + "-" + buildType); + } + url = url.replace(/%B2G_VERSION%/g, + Preferences.get(PREF_APP_B2G_VERSION, null)); + url = url.replace(/%IMEI%/g, + Preferences.get(PREF_APP_UPDATE_IMEI_HASH, "default")); + } + + return url; + } +}; + +/* Get the distribution pref values, from defaults only */ +function getDistributionPrefValue(aPrefName) { + var prefValue = "default"; + + try { + prefValue = Services.prefs.getDefaultBranch(null).getCharPref(aPrefName); + } catch (e) { + // use default when pref not found + } + + return prefValue; +} + +/** + * Gets the locale from the update.locale file for replacing %LOCALE% in the + * update url. The update.locale file can be located in the application + * directory or the GRE directory with preference given to it being located in + * the application directory. + */ +XPCOMUtils.defineLazyGetter(UpdateUtils, "Locale", function() { + let channel; + let locale; + for (let res of ['app', 'gre']) { + channel = NetUtil.newChannel({ + uri: "resource://" + res + "/" + FILE_UPDATE_LOCALE, + contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_XMLHTTPREQUEST, + loadUsingSystemPrincipal: true + }); + try { + let inputStream = channel.open2(); + locale = NetUtil.readInputStreamToString(inputStream, inputStream.available()); + } catch (e) {} + if (locale) + return locale.trim(); + } + + Cu.reportError(FILE_UPDATE_LOCALE + " file doesn't exist in either the " + + "application or GRE directories"); + + return null; +}); + +/** + * Provides adhoc system capability information for application update. + */ +XPCOMUtils.defineLazyGetter(this, "gSystemCapabilities", function aus_gSC() { + if (AppConstants.platform == "win") { + const PF_MMX_INSTRUCTIONS_AVAILABLE = 3; // MMX + const PF_XMMI_INSTRUCTIONS_AVAILABLE = 6; // SSE + const PF_XMMI64_INSTRUCTIONS_AVAILABLE = 10; // SSE2 + const PF_SSE3_INSTRUCTIONS_AVAILABLE = 13; // SSE3 + + let lib = ctypes.open("kernel32.dll"); + let IsProcessorFeaturePresent = lib.declare("IsProcessorFeaturePresent", + ctypes.winapi_abi, + ctypes.int32_t, /* success */ + ctypes.uint32_t); /* DWORD */ + let instructionSet = "unknown"; + try { + if (IsProcessorFeaturePresent(PF_SSE3_INSTRUCTIONS_AVAILABLE)) { + instructionSet = "SSE3"; + } else if (IsProcessorFeaturePresent(PF_XMMI64_INSTRUCTIONS_AVAILABLE)) { + instructionSet = "SSE2"; + } else if (IsProcessorFeaturePresent(PF_XMMI_INSTRUCTIONS_AVAILABLE)) { + instructionSet = "SSE"; + } else if (IsProcessorFeaturePresent(PF_MMX_INSTRUCTIONS_AVAILABLE)) { + instructionSet = "MMX"; + } + } catch (e) { + instructionSet = "error"; + Cu.reportError("Error getting processor instruction set. " + + "Exception: " + e); + } + + lib.close(); + return instructionSet; + } + + if (AppConstants == "linux") { + let instructionSet = "unknown"; + if (navigator.cpuHasSSE2) { + instructionSet = "SSE2"; + } + return instructionSet; + } + + return "NA" +}); + +/* Windows only getter that returns the processor architecture. */ +XPCOMUtils.defineLazyGetter(this, "gWinCPUArch", function aus_gWinCPUArch() { + // Get processor architecture + let arch = "unknown"; + + const WORD = ctypes.uint16_t; + const DWORD = ctypes.uint32_t; + + // This structure is described at: + // http://msdn.microsoft.com/en-us/library/ms724958%28v=vs.85%29.aspx + const SYSTEM_INFO = new ctypes.StructType('SYSTEM_INFO', + [ + {wProcessorArchitecture: WORD}, + {wReserved: WORD}, + {dwPageSize: DWORD}, + {lpMinimumApplicationAddress: ctypes.voidptr_t}, + {lpMaximumApplicationAddress: ctypes.voidptr_t}, + {dwActiveProcessorMask: DWORD.ptr}, + {dwNumberOfProcessors: DWORD}, + {dwProcessorType: DWORD}, + {dwAllocationGranularity: DWORD}, + {wProcessorLevel: WORD}, + {wProcessorRevision: WORD} + ]); + + let kernel32 = false; + try { + kernel32 = ctypes.open("Kernel32"); + } catch (e) { + Cu.reportError("Unable to open kernel32! Exception: " + e); + } + + if (kernel32) { + try { + let GetNativeSystemInfo = kernel32.declare("GetNativeSystemInfo", + ctypes.default_abi, + ctypes.void_t, + SYSTEM_INFO.ptr); + let winSystemInfo = SYSTEM_INFO(); + // Default to unknown + winSystemInfo.wProcessorArchitecture = 0xffff; + + GetNativeSystemInfo(winSystemInfo.address()); + switch (winSystemInfo.wProcessorArchitecture) { + case 9: + arch = "x64"; + break; + case 6: + arch = "IA64"; + break; + case 0: + arch = "x86"; + break; + } + } catch (e) { + Cu.reportError("Error getting processor architecture. " + + "Exception: " + e); + } finally { + kernel32.close(); + } + } + + return arch; +}); + +XPCOMUtils.defineLazyGetter(UpdateUtils, "ABI", function() { + let abi = null; + try { + abi = Services.appinfo.XPCOMABI; + } + catch (e) { + Cu.reportError("XPCOM ABI unknown"); + } + + if (AppConstants.platform == "macosx") { + // Mac universal build should report a different ABI than either macppc + // or mactel. + let macutils = Cc["@mozilla.org/xpcom/mac-utils;1"]. + getService(Ci.nsIMacUtils); + + if (macutils.isUniversalBinary) { + abi += "-u-" + macutils.architecturesInBinary; + } + } else if (AppConstants.platform == "win") { + // Windows build should report the CPU architecture that it's running on. + abi += "-" + gWinCPUArch; + } + return abi; +}); + +XPCOMUtils.defineLazyGetter(UpdateUtils, "OSVersion", function() { + let osVersion; + try { + osVersion = Services.sysinfo.getProperty("name") + " " + + Services.sysinfo.getProperty("version"); + } + catch (e) { + Cu.reportError("OS Version unknown."); + } + + if (osVersion) { + if (AppConstants.platform == "win") { + const BYTE = ctypes.uint8_t; + const WORD = ctypes.uint16_t; + const DWORD = ctypes.uint32_t; + const WCHAR = ctypes.char16_t; + const BOOL = ctypes.int; + + // This structure is described at: + // http://msdn.microsoft.com/en-us/library/ms724833%28v=vs.85%29.aspx + const SZCSDVERSIONLENGTH = 128; + const OSVERSIONINFOEXW = new ctypes.StructType('OSVERSIONINFOEXW', + [ + {dwOSVersionInfoSize: DWORD}, + {dwMajorVersion: DWORD}, + {dwMinorVersion: DWORD}, + {dwBuildNumber: DWORD}, + {dwPlatformId: DWORD}, + {szCSDVersion: ctypes.ArrayType(WCHAR, SZCSDVERSIONLENGTH)}, + {wServicePackMajor: WORD}, + {wServicePackMinor: WORD}, + {wSuiteMask: WORD}, + {wProductType: BYTE}, + {wReserved: BYTE} + ]); + + // This structure is described at: + // http://msdn.microsoft.com/en-us/library/ms724958%28v=vs.85%29.aspx + const SYSTEM_INFO = new ctypes.StructType('SYSTEM_INFO', + [ + {wProcessorArchitecture: WORD}, + {wReserved: WORD}, + {dwPageSize: DWORD}, + {lpMinimumApplicationAddress: ctypes.voidptr_t}, + {lpMaximumApplicationAddress: ctypes.voidptr_t}, + {dwActiveProcessorMask: DWORD.ptr}, + {dwNumberOfProcessors: DWORD}, + {dwProcessorType: DWORD}, + {dwAllocationGranularity: DWORD}, + {wProcessorLevel: WORD}, + {wProcessorRevision: WORD} + ]); + + let kernel32 = false; + try { + kernel32 = ctypes.open("Kernel32"); + } catch (e) { + Cu.reportError("Unable to open kernel32! " + e); + osVersion += ".unknown (unknown)"; + } + + if (kernel32) { + try { + // Get Service pack info + try { + let GetVersionEx = kernel32.declare("GetVersionExW", + ctypes.default_abi, + BOOL, + OSVERSIONINFOEXW.ptr); + let winVer = OSVERSIONINFOEXW(); + winVer.dwOSVersionInfoSize = OSVERSIONINFOEXW.size; + + if (0 !== GetVersionEx(winVer.address())) { + osVersion += "." + winVer.wServicePackMajor + + "." + winVer.wServicePackMinor; + } else { + Cu.reportError("Unknown failure in GetVersionEX (returned 0)"); + osVersion += ".unknown"; + } + } catch (e) { + Cu.reportError("Error getting service pack information. Exception: " + e); + osVersion += ".unknown"; + } + } finally { + kernel32.close(); + } + + // Add processor architecture + osVersion += " (" + gWinCPUArch + ")"; + } + } + + try { + osVersion += " (" + Services.sysinfo.getProperty("secondaryLibrary") + ")"; + } + catch (e) { + // Not all platforms have a secondary widget library, so an error is nothing to worry about. + } + osVersion = encodeURIComponent(osVersion); + } + return osVersion; +}); diff --git a/toolkit/modules/WebChannel.jsm b/toolkit/modules/WebChannel.jsm new file mode 100644 index 0000000000..a32bea0d42 --- /dev/null +++ b/toolkit/modules/WebChannel.jsm @@ -0,0 +1,328 @@ +/* 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/. */ + +/** + * WebChannel is an abstraction that uses the Message Manager and Custom Events + * to create a two-way communication channel between chrome and content code. + */ + +this.EXPORTED_SYMBOLS = ["WebChannel", "WebChannelBroker"]; + +const ERRNO_UNKNOWN_ERROR = 999; +const ERROR_UNKNOWN = "UNKNOWN_ERROR"; + + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + + +/** + * WebChannelBroker is a global object that helps manage WebChannel objects. + * This object handles channel registration, origin validation and message multiplexing. + */ + +var WebChannelBroker = Object.create({ + /** + * Register a new channel that callbacks messages + * based on proper origin and channel name + * + * @param channel {WebChannel} + */ + registerChannel: function (channel) { + if (!this._channelMap.has(channel)) { + this._channelMap.set(channel); + } else { + Cu.reportError("Failed to register the channel. Channel already exists."); + } + + // attach the global message listener if needed + if (!this._messageListenerAttached) { + this._messageListenerAttached = true; + this._manager.addMessageListener("WebChannelMessageToChrome", this._listener.bind(this)); + } + }, + + /** + * Unregister a channel + * + * @param channelToRemove {WebChannel} + * WebChannel to remove from the channel map + * + * Removes the specified channel from the channel map + */ + unregisterChannel: function (channelToRemove) { + if (!this._channelMap.delete(channelToRemove)) { + Cu.reportError("Failed to unregister the channel. Channel not found."); + } + }, + + /** + * @param event {Event} + * Message Manager event + * @private + */ + _listener: function (event) { + let data = event.data; + let sendingContext = { + browser: event.target, + eventTarget: event.objects.eventTarget, + principal: event.principal, + }; + // data must be a string except for a few legacy origins allowed by browser-content.js. + if (typeof data == "string") { + try { + data = JSON.parse(data); + } catch (e) { + Cu.reportError("Failed to parse WebChannel data as a JSON object"); + return; + } + } + + if (data && data.id) { + if (!event.principal) { + this._sendErrorEventToContent(data.id, sendingContext, "Message principal missing"); + } else { + let validChannelFound = false; + data.message = data.message || {}; + + for (var channel of this._channelMap.keys()) { + if (channel.id === data.id && + channel._originCheckCallback(event.principal)) { + validChannelFound = true; + channel.deliver(data, sendingContext); + } + } + + // if no valid origins send an event that there is no such valid channel + if (!validChannelFound) { + this._sendErrorEventToContent(data.id, sendingContext, "No Such Channel"); + } + } + } else { + Cu.reportError("WebChannel channel id missing"); + } + }, + /** + * The global message manager operates on every <browser> + */ + _manager: Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager), + /** + * Boolean used to detect if the global message manager event is already attached + */ + _messageListenerAttached: false, + /** + * Object to store pairs of message origins and callback functions + */ + _channelMap: new Map(), + /** + * + * @param id {String} + * The WebChannel id to include in the message + * @param sendingContext {Object} + * Message sending context + * @param [errorMsg] {String} + * Error message + * @private + */ + _sendErrorEventToContent: function (id, sendingContext, errorMsg) { + let { browser: targetBrowser, eventTarget, principal: targetPrincipal } = sendingContext; + + errorMsg = errorMsg || "Web Channel Broker error"; + + if (targetBrowser && targetBrowser.messageManager) { + targetBrowser.messageManager.sendAsyncMessage("WebChannelMessageToContent", { + id: id, + error: errorMsg, + }, { eventTarget: eventTarget }, targetPrincipal); + } else { + Cu.reportError("Failed to send a WebChannel error. Target invalid."); + } + Cu.reportError(id.toString() + " error message. " + errorMsg); + }, +}); + + +/** + * Creates a new WebChannel that listens and sends messages over some channel id + * + * @param id {String} + * WebChannel id + * @param originOrPermission {nsIURI/string} + * If an nsIURI, a valid origin that should be part of requests for + * this channel. If a string, a permission for which the permission + * manager will be checked to determine if the request is allowed. Note + * that in addition to the permission manager check, the request must + * be made over https:// + * @constructor + */ +this.WebChannel = function(id, originOrPermission) { + if (!id || !originOrPermission) { + throw new Error("WebChannel id and originOrPermission are required."); + } + + this.id = id; + // originOrPermission can be either an nsIURI or a string representing a + // permission name. + if (typeof originOrPermission == "string") { + this._originCheckCallback = requestPrincipal => { + // The permission manager operates on domain names rather than true + // origins (bug 1066517). To mitigate that, we explicitly check that + // the scheme is https://. + let uri = Services.io.newURI(requestPrincipal.originNoSuffix, null, null); + if (uri.scheme != "https") { + return false; + } + // OK - we have https - now we can check the permission. + let perm = Services.perms.testExactPermissionFromPrincipal(requestPrincipal, + originOrPermission); + return perm == Ci.nsIPermissionManager.ALLOW_ACTION; + } + } else { + // a simple URI, so just check for an exact match. + this._originCheckCallback = requestPrincipal => { + return originOrPermission.prePath === requestPrincipal.originNoSuffix; + } + } + this._originOrPermission = originOrPermission; +}; + +this.WebChannel.prototype = { + + /** + * WebChannel id + */ + id: null, + + /** + * The originOrPermission value passed to the constructor, mainly for + * debugging and tests. + */ + _originOrPermission: null, + + /** + * Callback that will be called with the principal of an incoming message + * to check if the request should be dispatched to the listeners. + */ + _originCheckCallback: null, + + /** + * WebChannelBroker that manages WebChannels + */ + _broker: WebChannelBroker, + + /** + * Callback that will be called with the contents of an incoming message + */ + _deliverCallback: null, + + /** + * Registers the callback for messages on this channel + * Registers the channel itself with the WebChannelBroker + * + * @param callback {Function} + * Callback that will be called when there is a message + * @param {String} id + * The WebChannel id that was used for this message + * @param {Object} message + * The message itself + * @param sendingContext {Object} + * The sending context of the source of the message. Can be passed to + * `send` to respond to a message. + * @param sendingContext.browser {browser} + * The <browser> object that captured the + * WebChannelMessageToChrome. + * @param sendingContext.eventTarget {EventTarget} + * The <EventTarget> where the message was sent. + * @param sendingContext.principal {Principal} + * The <Principal> of the EventTarget where the + * message was sent. + */ + listen: function (callback) { + if (this._deliverCallback) { + throw new Error("Failed to listen. Listener already attached."); + } else if (!callback) { + throw new Error("Failed to listen. Callback argument missing."); + } else { + this._deliverCallback = callback; + this._broker.registerChannel(this); + } + }, + + /** + * Resets the callback for messages on this channel + * Removes the channel from the WebChannelBroker + */ + stopListening: function () { + this._broker.unregisterChannel(this); + this._deliverCallback = null; + }, + + /** + * Sends messages over the WebChannel id using the "WebChannelMessageToContent" event + * + * @param message {Object} + * The message object that will be sent + * @param target {Object} + * A <target> with the information of where to send the message. + * @param target.browser {browser} + * The <browser> object with a "messageManager" that will + * be used to send the message. + * @param target.principal {Principal} + * Principal of the target. Prevents messages from + * being dispatched to unexpected origins. The system principal + * can be specified to send to any target. + * @param [target.eventTarget] {EventTarget} + * Optional eventTarget within the browser, use to send to a + * specific element, e.g., an iframe. + */ + send: function (message, target) { + let { browser, principal, eventTarget } = target; + + if (message && browser && browser.messageManager && principal) { + browser.messageManager.sendAsyncMessage("WebChannelMessageToContent", { + id: this.id, + message: message + }, { eventTarget }, principal); + } else if (!message) { + Cu.reportError("Failed to send a WebChannel message. Message not set."); + } else { + Cu.reportError("Failed to send a WebChannel message. Target invalid."); + } + }, + + /** + * Deliver WebChannel messages to the set "_channelCallback" + * + * @param data {Object} + * Message data + * @param sendingContext {Object} + * Message sending context. + * @param sendingContext.browser {browser} + * The <browser> object that captured the + * WebChannelMessageToChrome. + * @param sendingContext.eventTarget {EventTarget} + * The <EventTarget> where the message was sent. + * @param sendingContext.principal {Principal} + * The <Principal> of the EventTarget where the message was sent. + * + */ + deliver: function(data, sendingContext) { + if (this._deliverCallback) { + try { + this._deliverCallback(data.id, data.message, sendingContext); + } catch (ex) { + this.send({ + errno: ERRNO_UNKNOWN_ERROR, + error: ex.message ? ex.message : ERROR_UNKNOWN + }, sendingContext); + Cu.reportError("Failed to execute WebChannel callback:"); + Cu.reportError(ex); + } + } else { + Cu.reportError("No callback set for this channel."); + } + } +}; diff --git a/toolkit/modules/WindowDraggingUtils.jsm b/toolkit/modules/WindowDraggingUtils.jsm new file mode 100644 index 0000000000..0cc2e88e90 --- /dev/null +++ b/toolkit/modules/WindowDraggingUtils.jsm @@ -0,0 +1,99 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/AppConstants.jsm"); + +const HAVE_CSS_WINDOW_DRAG_SUPPORT = ["win", "macosx"].includes(AppConstants.platform); + +this.EXPORTED_SYMBOLS = [ "WindowDraggingElement" ]; + +this.WindowDraggingElement = function WindowDraggingElement(elem) { + this._elem = elem; + this._window = elem.ownerDocument.defaultView; + if (HAVE_CSS_WINDOW_DRAG_SUPPORT && !this.isPanel()) { + return; + } + + this._elem.addEventListener("mousedown", this, false); +}; + +WindowDraggingElement.prototype = { + mouseDownCheck: function(e) { return true; }, + dragTags: ["box", "hbox", "vbox", "spacer", "label", "statusbarpanel", "stack", + "toolbaritem", "toolbarseparator", "toolbarspring", "toolbarspacer", + "radiogroup", "deck", "scrollbox", "arrowscrollbox", "tabs"], + shouldDrag: function(aEvent) { + if (aEvent.button != 0 || + this._window.fullScreen || + !this.mouseDownCheck.call(this._elem, aEvent) || + aEvent.defaultPrevented) + return false; + + let target = aEvent.originalTarget, parent = aEvent.originalTarget; + + // The target may be inside an embedded iframe or browser. (bug 615152) + if (target.ownerDocument.defaultView != this._window) + return false; + + while (parent != this._elem) { + let mousethrough = parent.getAttribute("mousethrough"); + if (mousethrough == "always") + target = parent.parentNode; + else if (mousethrough == "never") + break; + parent = parent.parentNode; + } + while (target != this._elem) { + if (this.dragTags.indexOf(target.localName) == -1) + return false; + target = target.parentNode; + } + return true; + }, + isPanel : function() { + return this._elem instanceof Components.interfaces.nsIDOMXULElement && + this._elem.localName == "panel"; + }, + handleEvent: function(aEvent) { + let isPanel = this.isPanel(); + switch (aEvent.type) { + case "mousedown": + if (!this.shouldDrag(aEvent)) + return; + + if (/^gtk/i.test(AppConstants.MOZ_WIDGET_TOOLKIT)) { + // On GTK, there is a toolkit-level function which handles + // window dragging, which must be used. + this._window.beginWindowMove(aEvent, isPanel ? this._elem : null); + break; + } + if (isPanel) { + let screenRect = this._elem.getOuterScreenRect(); + this._deltaX = aEvent.screenX - screenRect.left; + this._deltaY = aEvent.screenY - screenRect.top; + } + else { + this._deltaX = aEvent.screenX - this._window.screenX; + this._deltaY = aEvent.screenY - this._window.screenY; + } + this._draggingWindow = true; + this._window.addEventListener("mousemove", this, false); + this._window.addEventListener("mouseup", this, false); + break; + case "mousemove": + if (this._draggingWindow) { + let toDrag = this.isPanel() ? this._elem : this._window; + toDrag.moveTo(aEvent.screenX - this._deltaX, aEvent.screenY - this._deltaY); + } + break; + case "mouseup": + if (this._draggingWindow) { + this._draggingWindow = false; + this._window.removeEventListener("mousemove", this, false); + this._window.removeEventListener("mouseup", this, false); + } + break; + } + } +} diff --git a/toolkit/modules/WindowsRegistry.jsm b/toolkit/modules/WindowsRegistry.jsm new file mode 100644 index 0000000000..518d90d2b9 --- /dev/null +++ b/toolkit/modules/WindowsRegistry.jsm @@ -0,0 +1,90 @@ +/* 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"; +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +this.EXPORTED_SYMBOLS = ["WindowsRegistry"]; + +var WindowsRegistry = { + /** + * Safely reads a value from the registry. + * + * @param aRoot + * The root registry to use. + * @param aPath + * The registry path to the key. + * @param aKey + * The key name. + * @param [aRegistryNode=0] + * Optionally set to nsIWindowsRegKey.WOW64_64 (or nsIWindowsRegKey.WOW64_32) + * to access a 64-bit (32-bit) key from either a 32-bit or 64-bit application. + * @return The key value or undefined if it doesn't exist. If the key is + * a REG_MULTI_SZ, an array is returned. + */ + readRegKey: function(aRoot, aPath, aKey, aRegistryNode=0) { + const kRegMultiSz = 7; + const kMode = Ci.nsIWindowsRegKey.ACCESS_READ | aRegistryNode; + let registry = Cc["@mozilla.org/windows-registry-key;1"]. + createInstance(Ci.nsIWindowsRegKey); + try { + registry.open(aRoot, aPath, kMode); + if (registry.hasValue(aKey)) { + let type = registry.getValueType(aKey); + switch (type) { + case kRegMultiSz: + // nsIWindowsRegKey doesn't support REG_MULTI_SZ type out of the box. + let str = registry.readStringValue(aKey); + return str.split("\0").filter(v => v); + case Ci.nsIWindowsRegKey.TYPE_STRING: + return registry.readStringValue(aKey); + case Ci.nsIWindowsRegKey.TYPE_INT: + return registry.readIntValue(aKey); + default: + throw new Error("Unsupported registry value."); + } + } + } catch (ex) { + } finally { + registry.close(); + } + return undefined; + }, + + /** + * Safely removes a key from the registry. + * + * @param aRoot + * The root registry to use. + * @param aPath + * The registry path to the key. + * @param aKey + * The key name. + * @param [aRegistryNode=0] + * Optionally set to nsIWindowsRegKey.WOW64_64 (or nsIWindowsRegKey.WOW64_32) + * to access a 64-bit (32-bit) key from either a 32-bit or 64-bit application. + * @return True if the key was removed or never existed, false otherwise. + */ + removeRegKey: function(aRoot, aPath, aKey, aRegistryNode=0) { + let registry = Cc["@mozilla.org/windows-registry-key;1"]. + createInstance(Ci.nsIWindowsRegKey); + let result = false; + try { + let mode = Ci.nsIWindowsRegKey.ACCESS_QUERY_VALUE | + Ci.nsIWindowsRegKey.ACCESS_SET_VALUE | + aRegistryNode; + registry.open(aRoot, aPath, mode); + if (registry.hasValue(aKey)) { + registry.removeValue(aKey); + result = !registry.hasValue(aKey); + } else { + result = true; + } + } catch (ex) { + } finally { + registry.close(); + return result; + } + } +}; diff --git a/toolkit/modules/ZipUtils.jsm b/toolkit/modules/ZipUtils.jsm new file mode 100644 index 0000000000..13f9173f54 --- /dev/null +++ b/toolkit/modules/ZipUtils.jsm @@ -0,0 +1,223 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = [ "ZipUtils" ]; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); + + +// The maximum amount of file data to buffer at a time during file extraction +const EXTRACTION_BUFFER = 1024 * 512; + + +/** + * Asynchronously writes data from an nsIInputStream to an OS.File instance. + * The source stream and OS.File are closed regardless of whether the operation + * succeeds or fails. + * Returns a promise that will be resolved when complete. + * + * @param aPath + * The name of the file being extracted for logging purposes. + * @param aStream + * The source nsIInputStream. + * @param aFile + * The open OS.File instance to write to. + */ +function saveStreamAsync(aPath, aStream, aFile) { + let deferred = Promise.defer(); + + // Read the input stream on a background thread + let sts = Cc["@mozilla.org/network/stream-transport-service;1"]. + getService(Ci.nsIStreamTransportService); + let transport = sts.createInputTransport(aStream, -1, -1, true); + let input = transport.openInputStream(0, 0, 0) + .QueryInterface(Ci.nsIAsyncInputStream); + let source = Cc["@mozilla.org/binaryinputstream;1"]. + createInstance(Ci.nsIBinaryInputStream); + source.setInputStream(input); + + + function readFailed(error) { + try { + aStream.close(); + } + catch (e) { + logger.error("Failed to close JAR stream for " + aPath); + } + + aFile.close().then(function() { + deferred.reject(error); + }, function(e) { + logger.error("Failed to close file for " + aPath); + deferred.reject(error); + }); + } + + function readData() { + try { + let count = Math.min(source.available(), EXTRACTION_BUFFER); + let data = new Uint8Array(count); + source.readArrayBuffer(count, data.buffer); + + aFile.write(data, { bytes: count }).then(function() { + input.asyncWait(readData, 0, 0, Services.tm.currentThread); + }, readFailed); + } + catch (e) { + if (e.result == Cr.NS_BASE_STREAM_CLOSED) + deferred.resolve(aFile.close()); + else + readFailed(e); + } + } + + input.asyncWait(readData, 0, 0, Services.tm.currentThread); + + return deferred.promise; +} + + +this.ZipUtils = { + + /** + * Asynchronously extracts files from a ZIP file into a directory. + * Returns a promise that will be resolved when the extraction is complete. + * + * @param aZipFile + * The source ZIP file that contains the add-on. + * @param aDir + * The nsIFile to extract to. + */ + extractFilesAsync: function ZipUtils_extractFilesAsync(aZipFile, aDir) { + let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"]. + createInstance(Ci.nsIZipReader); + + try { + zipReader.open(aZipFile); + } + catch (e) { + return Promise.reject(e); + } + + return Task.spawn(function* () { + // Get all of the entries in the zip and sort them so we create directories + // before files + let entries = zipReader.findEntries(null); + let names = []; + while (entries.hasMore()) + names.push(entries.getNext()); + names.sort(); + + for (let name of names) { + let entryName = name; + let zipentry = zipReader.getEntry(name); + let path = OS.Path.join(aDir.path, ...name.split("/")); + + if (zipentry.isDirectory) { + try { + yield OS.File.makeDir(path); + } + catch (e) { + dump("extractFilesAsync: failed to create directory " + path + "\n"); + throw e; + } + } + else { + let options = { unixMode: zipentry.permissions | FileUtils.PERMS_FILE }; + try { + let file = yield OS.File.open(path, { truncate: true }, options); + if (zipentry.realSize == 0) + yield file.close(); + else + yield saveStreamAsync(path, zipReader.getInputStream(entryName), file); + } + catch (e) { + dump("extractFilesAsync: failed to extract file " + path + "\n"); + throw e; + } + } + } + + zipReader.close(); + }).then(null, (e) => { + zipReader.close(); + throw e; + }); + }, + + /** + * Extracts files from a ZIP file into a directory. + * + * @param aZipFile + * The source ZIP file that contains the add-on. + * @param aDir + * The nsIFile to extract to. + */ + extractFiles: function ZipUtils_extractFiles(aZipFile, aDir) { + function getTargetFile(aDir, entry) { + let target = aDir.clone(); + entry.split("/").forEach(function(aPart) { + target.append(aPart); + }); + return target; + } + + let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"]. + createInstance(Ci.nsIZipReader); + zipReader.open(aZipFile); + + try { + // create directories first + let entries = zipReader.findEntries("*/"); + while (entries.hasMore()) { + let entryName = entries.getNext(); + let target = getTargetFile(aDir, entryName); + if (!target.exists()) { + try { + target.create(Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY); + } + catch (e) { + dump("extractFiles: failed to create target directory for extraction file = " + target.path + "\n"); + } + } + } + + entries = zipReader.findEntries(null); + while (entries.hasMore()) { + let entryName = entries.getNext(); + let target = getTargetFile(aDir, entryName); + if (target.exists()) + continue; + + zipReader.extract(entryName, target); + try { + target.permissions |= FileUtils.PERMS_FILE; + } + catch (e) { + dump("Failed to set permissions " + FileUtils.PERMS_FILE.toString(8) + " on " + target.path + " " + e + "\n"); + } + } + } + finally { + zipReader.close(); + } + } + +}; diff --git a/toolkit/modules/addons/.eslintrc.js b/toolkit/modules/addons/.eslintrc.js new file mode 100644 index 0000000000..019759c87b --- /dev/null +++ b/toolkit/modules/addons/.eslintrc.js @@ -0,0 +1,15 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": "../../components/extensions/.eslintrc.js", + + "globals": { + "addEventListener": false, + "addMessageListener": false, + "removeEventListener": false, + "sendAsyncMessage": false, + "AddonManagerPermissions": false, + + "initialProcessData": true, + }, +}; diff --git a/toolkit/modules/addons/MatchPattern.jsm b/toolkit/modules/addons/MatchPattern.jsm new file mode 100644 index 0000000000..4dff81fd23 --- /dev/null +++ b/toolkit/modules/addons/MatchPattern.jsm @@ -0,0 +1,352 @@ +/* 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"; + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +this.EXPORTED_SYMBOLS = ["MatchPattern", "MatchGlobs", "MatchURLFilters"]; + +/* globals MatchPattern, MatchGlobs */ + +const PERMITTED_SCHEMES = ["http", "https", "file", "ftp", "data"]; +const PERMITTED_SCHEMES_REGEXP = PERMITTED_SCHEMES.join("|"); + +// This function converts a glob pattern (containing * and possibly ? +// as wildcards) to a regular expression. +function globToRegexp(pat, allowQuestion) { + // Escape everything except ? and *. + pat = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + + if (allowQuestion) { + pat = pat.replace(/\?/g, "."); + } else { + pat = pat.replace(/\?/g, "\\?"); + } + pat = pat.replace(/\*/g, ".*"); + return new RegExp("^" + pat + "$"); +} + +// These patterns follow the syntax in +// https://developer.chrome.com/extensions/match_patterns +function SingleMatchPattern(pat) { + if (pat == "<all_urls>") { + this.schemes = PERMITTED_SCHEMES; + this.hostMatch = () => true; + this.pathMatch = () => true; + } else if (!pat) { + this.schemes = []; + } else { + let re = new RegExp(`^(${PERMITTED_SCHEMES_REGEXP}|\\*)://(\\*|\\*\\.[^*/]+|[^*/]+|)(/.*)$`); + let match = re.exec(pat); + if (!match) { + Cu.reportError(`Invalid match pattern: '${pat}'`); + this.schemes = []; + return; + } + + if (match[1] == "*") { + this.schemes = ["http", "https"]; + } else { + this.schemes = [match[1]]; + } + + // We allow the host to be empty for file URLs. + if (match[2] == "" && this.schemes[0] != "file") { + Cu.reportError(`Invalid match pattern: '${pat}'`); + this.schemes = []; + return; + } + + this.host = match[2]; + this.hostMatch = this.getHostMatcher(match[2]); + + let pathMatch = globToRegexp(match[3], false); + this.pathMatch = pathMatch.test.bind(pathMatch); + } +} + +SingleMatchPattern.prototype = { + getHostMatcher(host) { + // This code ignores the port, as Chrome does. + if (host == "*") { + return () => true; + } + if (host.startsWith("*.")) { + let suffix = host.substr(2); + let dotSuffix = "." + suffix; + + return ({host}) => host === suffix || host.endsWith(dotSuffix); + } + return uri => uri.host === host; + }, + + matches(uri, ignorePath = false) { + return ( + this.schemes.includes(uri.scheme) && + this.hostMatch(uri) && + (ignorePath || ( + this.pathMatch(uri.cloneIgnoringRef().path) + )) + ); + }, +}; + +this.MatchPattern = function(pat) { + this.pat = pat; + if (!pat) { + this.matchers = []; + } else if (pat instanceof String || typeof(pat) == "string") { + this.matchers = [new SingleMatchPattern(pat)]; + } else { + this.matchers = pat.map(p => new SingleMatchPattern(p)); + } +}; + +MatchPattern.prototype = { + // |uri| should be an nsIURI. + matches(uri) { + return this.matchers.some(matcher => matcher.matches(uri)); + }, + + matchesIgnoringPath(uri) { + return this.matchers.some(matcher => matcher.matches(uri, true)); + }, + + // Checks that this match pattern grants access to read the given + // cookie. |cookie| should be an |nsICookie2| instance. + matchesCookie(cookie) { + // First check for simple matches. + let secureURI = NetUtil.newURI(`https://${cookie.rawHost}/`); + if (this.matchesIgnoringPath(secureURI)) { + return true; + } + + let plainURI = NetUtil.newURI(`http://${cookie.rawHost}/`); + if (!cookie.isSecure && this.matchesIgnoringPath(plainURI)) { + return true; + } + + if (!cookie.isDomain) { + return false; + } + + // Things get tricker for domain cookies. The extension needs to be able + // to read any cookies that could be read any host it has permissions + // for. This means that our normal host matching checks won't work, + // since the pattern "*://*.foo.example.com/" doesn't match ".example.com", + // but it does match "bar.foo.example.com", which can read cookies + // with the domain ".example.com". + // + // So, instead, we need to manually check our filters, and accept any + // with hosts that end with our cookie's host. + + let {host, isSecure} = cookie; + + for (let matcher of this.matchers) { + let schemes = matcher.schemes; + if (schemes.includes("https") || (!isSecure && schemes.includes("http"))) { + if (matcher.host.endsWith(host)) { + return true; + } + } + } + + return false; + }, + + serialize() { + return this.pat; + }, +}; + +// Globs can match everything. Be careful, this DOES NOT filter by allowed schemes! +this.MatchGlobs = function(globs) { + this.original = globs; + if (globs) { + this.regexps = Array.from(globs, (glob) => globToRegexp(glob, true)); + } else { + this.regexps = []; + } +}; + +MatchGlobs.prototype = { + matches(str) { + return this.regexps.some(regexp => regexp.test(str)); + }, + serialize() { + return this.original; + }, +}; + +// Match WebNavigation URL Filters. +this.MatchURLFilters = function(filters) { + if (!Array.isArray(filters)) { + throw new TypeError("filters should be an array"); + } + + if (filters.length == 0) { + throw new Error("filters array should not be empty"); + } + + this.filters = filters; +}; + +MatchURLFilters.prototype = { + matches(url) { + let uri = NetUtil.newURI(url); + // Set uriURL to an empty object (needed because some schemes, e.g. about doesn't support nsIURL). + let uriURL = {}; + if (uri instanceof Ci.nsIURL) { + uriURL = uri; + } + + // Set host to a empty string by default (needed so that schemes without an host, + // e.g. about, can pass an empty string for host based event filtering as expected). + let host = ""; + try { + host = uri.host; + } catch (e) { + // 'uri.host' throws an exception with some uri schemes (e.g. about). + } + + let port; + try { + port = uri.port; + } catch (e) { + // 'uri.port' throws an exception with some uri schemes (e.g. about), + // in which case it will be |undefined|. + } + + let data = { + // NOTE: This properties are named after the name of their related + // filters (e.g. `pathContains/pathEquals/...` will be tested against the + // `data.path` property, and the same is done for the `host`, `query` and `url` + // components as well). + path: uriURL.filePath, + query: uriURL.query, + host, + port, + url, + }; + + // If any of the filters matches, matches returns true. + return this.filters.some(filter => this.matchURLFilter({filter, data, uri, uriURL})); + }, + + matchURLFilter({filter, data, uri, uriURL}) { + // Test for scheme based filtering. + if (filter.schemes) { + // Return false if none of the schemes matches. + if (!filter.schemes.some((scheme) => uri.schemeIs(scheme))) { + return false; + } + } + + // Test for exact port matching or included in a range of ports. + if (filter.ports) { + let port = data.port; + if (port === -1) { + // NOTE: currently defaultPort for "resource" and "chrome" schemes defaults to -1, + // for "about", "data" and "javascript" schemes defaults to undefined. + if (["resource", "chrome"].includes(uri.scheme)) { + port = undefined; + } else { + port = Services.io.getProtocolHandler(uri.scheme).defaultPort; + } + } + + // Return false if none of the ports (or port ranges) is verified + return filter.ports.some((filterPort) => { + if (Array.isArray(filterPort)) { + let [lower, upper] = filterPort; + return port >= lower && port <= upper; + } + + return port === filterPort; + }); + } + + // Filters on host, url, path, query: + // hostContains, hostEquals, hostSuffix, hostPrefix, + // urlContains, urlEquals, ... + for (let urlComponent of ["host", "path", "query", "url"]) { + if (!this.testMatchOnURLComponent({urlComponent, data, filter})) { + return false; + } + } + + // urlMatches is a regular expression string and it is tested for matches + // on the "url without the ref". + if (filter.urlMatches) { + let urlWithoutRef = uri.specIgnoringRef; + if (!urlWithoutRef.match(filter.urlMatches)) { + return false; + } + } + + // originAndPathMatches is a regular expression string and it is tested for matches + // on the "url without the query and the ref". + if (filter.originAndPathMatches) { + let urlWithoutQueryAndRef = uri.resolve(uriURL.filePath); + // The above 'uri.resolve(...)' will be null for some URI schemes + // (e.g. about). + // TODO: handle schemes which will not be able to resolve the filePath + // (e.g. for "about:blank", 'urlWithoutQueryAndRef' should be "about:blank" instead + // of null) + if (!urlWithoutQueryAndRef || + !urlWithoutQueryAndRef.match(filter.originAndPathMatches)) { + return false; + } + } + + return true; + }, + + testMatchOnURLComponent({urlComponent: key, data, filter}) { + // Test for equals. + // NOTE: an empty string should not be considered a filter to skip. + if (filter[`${key}Equals`] != null) { + if (data[key] !== filter[`${key}Equals`]) { + return false; + } + } + + // Test for contains. + if (filter[`${key}Contains`]) { + let value = (key == "host" ? "." : "") + data[key]; + if (!data[key] || !value.includes(filter[`${key}Contains`])) { + return false; + } + } + + // Test for prefix. + if (filter[`${key}Prefix`]) { + if (!data[key] || !data[key].startsWith(filter[`${key}Prefix`])) { + return false; + } + } + + // Test for suffix. + if (filter[`${key}Suffix`]) { + if (!data[key] || !data[key].endsWith(filter[`${key}Suffix`])) { + return false; + } + } + + return true; + }, + + serialize() { + return this.filters; + }, +}; diff --git a/toolkit/modules/addons/WebNavigation.jsm b/toolkit/modules/addons/WebNavigation.jsm new file mode 100644 index 0000000000..6302a9d790 --- /dev/null +++ b/toolkit/modules/addons/WebNavigation.jsm @@ -0,0 +1,370 @@ +/* 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"; + +const EXPORTED_SYMBOLS = ["WebNavigation"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow", + "resource:///modules/RecentWindow.jsm"); + +// Maximum amount of time that can be passed and still consider +// the data recent (similar to how is done in nsNavHistory, +// e.g. nsNavHistory::CheckIsRecentEvent, but with a lower threshold value). +const RECENT_DATA_THRESHOLD = 5 * 1000000; + +// TODO: +// onCreatedNavigationTarget + +var Manager = { + // Map[string -> Map[listener -> URLFilter]] + listeners: new Map(), + + init() { + // Collect recent tab transition data in a WeakMap: + // browser -> tabTransitionData + this.recentTabTransitionData = new WeakMap(); + Services.obs.addObserver(this, "autocomplete-did-enter-text", true); + + Services.mm.addMessageListener("Content:Click", this); + Services.mm.addMessageListener("Extension:DOMContentLoaded", this); + Services.mm.addMessageListener("Extension:StateChange", this); + Services.mm.addMessageListener("Extension:DocumentChange", this); + Services.mm.addMessageListener("Extension:HistoryChange", this); + + Services.mm.loadFrameScript("resource://gre/modules/WebNavigationContent.js", true); + }, + + uninit() { + // Stop collecting recent tab transition data and reset the WeakMap. + Services.obs.removeObserver(this, "autocomplete-did-enter-text", true); + this.recentTabTransitionData = new WeakMap(); + + Services.mm.removeMessageListener("Content:Click", this); + Services.mm.removeMessageListener("Extension:StateChange", this); + Services.mm.removeMessageListener("Extension:DocumentChange", this); + Services.mm.removeMessageListener("Extension:HistoryChange", this); + Services.mm.removeMessageListener("Extension:DOMContentLoaded", this); + + Services.mm.removeDelayedFrameScript("resource://gre/modules/WebNavigationContent.js"); + Services.mm.broadcastAsyncMessage("Extension:DisableWebNavigation"); + }, + + addListener(type, listener, filters) { + if (this.listeners.size == 0) { + this.init(); + } + + if (!this.listeners.has(type)) { + this.listeners.set(type, new Map()); + } + let listeners = this.listeners.get(type); + listeners.set(listener, filters); + }, + + removeListener(type, listener) { + let listeners = this.listeners.get(type); + if (!listeners) { + return; + } + listeners.delete(listener); + if (listeners.size == 0) { + this.listeners.delete(type); + } + + if (this.listeners.size == 0) { + this.uninit(); + } + }, + + /** + * Support nsIObserver interface to observe the urlbar autocomplete events used + * to keep track of the urlbar user interaction. + */ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]), + + /** + * Observe autocomplete-did-enter-text topic to track the user interaction with + * the awesome bar. + * + * @param {nsIAutoCompleteInput} subject + * @param {string} topic + * @param {string} data + */ + observe: function(subject, topic, data) { + if (topic == "autocomplete-did-enter-text") { + this.onURLBarAutoCompletion(subject); + } + }, + + /** + * Recognize the type of urlbar user interaction (e.g. typing a new url, + * clicking on an url generated from a searchengine or a keyword, or a + * bookmark found by the urlbar autocompletion). + * + * @param {nsIAutoCompleteInput} input + */ + onURLBarAutoCompletion(input) { + if (input && input instanceof Ci.nsIAutoCompleteInput) { + // We are only interested in urlbar autocompletion events + if (input.id !== "urlbar") { + return; + } + + let controller = input.popup.view.QueryInterface(Ci.nsIAutoCompleteController); + let idx = input.popup.selectedIndex; + + let tabTransistionData = { + from_address_bar: true, + }; + + if (idx < 0 || idx >= controller.matchCount) { + // Recognize when no valid autocomplete results has been selected. + tabTransistionData.typed = true; + } else { + let value = controller.getValueAt(idx); + let action = input._parseActionUrl(value); + + if (action) { + // Detect keywork and generated and more typed scenarios. + switch (action.type) { + case "keyword": + tabTransistionData.keyword = true; + break; + case "searchengine": + case "searchsuggestion": + tabTransistionData.generated = true; + break; + case "visiturl": + // Visiturl are autocompletion results related to + // history suggestions. + tabTransistionData.typed = true; + break; + case "remotetab": + // Remote tab are autocomplete results related to + // tab urls from a remote synchronized Firefox. + tabTransistionData.typed = true; + break; + case "switchtab": + // This "switchtab" autocompletion should be ignored, because + // it is not related to a navigation. + return; + default: + // Fallback on "typed" if unable to detect a known moz-action type. + tabTransistionData.typed = true; + } + } else { + // Special handling for bookmark urlbar autocompletion + // (which happens when we got a null action and a valid selectedIndex) + let styles = new Set(controller.getStyleAt(idx).split(/\s+/)); + + if (styles.has("bookmark")) { + tabTransistionData.auto_bookmark = true; + } else { + // Fallback on "typed" if unable to detect a specific actionType + // (and when in the styles there are "autofill" or "history"). + tabTransistionData.typed = true; + } + } + } + + this.setRecentTabTransitionData(tabTransistionData); + } + }, + + /** + * Keep track of a recent user interaction and cache it in a + * map associated to the current selected tab. + * + * @param {object} tabTransitionData + * @param {boolean} [tabTransitionData.auto_bookmark] + * @param {boolean} [tabTransitionData.from_address_bar] + * @param {boolean} [tabTransitionData.generated] + * @param {boolean} [tabTransitionData.keyword] + * @param {boolean} [tabTransitionData.link] + * @param {boolean} [tabTransitionData.typed] + */ + setRecentTabTransitionData(tabTransitionData) { + let window = RecentWindow.getMostRecentBrowserWindow(); + if (window && window.gBrowser && window.gBrowser.selectedTab && + window.gBrowser.selectedTab.linkedBrowser) { + let browser = window.gBrowser.selectedTab.linkedBrowser; + + // Get recent tab transition data to update if any. + let prevData = this.getAndForgetRecentTabTransitionData(browser); + + let newData = Object.assign( + {time: Date.now()}, + prevData, + tabTransitionData + ); + this.recentTabTransitionData.set(browser, newData); + } + }, + + /** + * Retrieve recent data related to a recent user interaction give a + * given tab's linkedBrowser (only if is is more recent than the + * `RECENT_DATA_THRESHOLD`). + * + * NOTE: this method is used to retrieve the tab transition data + * collected when one of the `onCommitted`, `onHistoryStateUpdated` + * or `onReferenceFragmentUpdated` events has been received. + * + * @param {XULBrowserElement} browser + * @returns {object} + */ + getAndForgetRecentTabTransitionData(browser) { + let data = this.recentTabTransitionData.get(browser); + this.recentTabTransitionData.delete(browser); + + // Return an empty object if there isn't any tab transition data + // or if it's less recent than RECENT_DATA_THRESHOLD. + if (!data || (data.time - Date.now()) > RECENT_DATA_THRESHOLD) { + return {}; + } + + return data; + }, + + /** + * Receive messages from the WebNavigationContent.js framescript + * over message manager events. + */ + receiveMessage({name, data, target}) { + switch (name) { + case "Extension:StateChange": + this.onStateChange(target, data); + break; + + case "Extension:DocumentChange": + this.onDocumentChange(target, data); + break; + + case "Extension:HistoryChange": + this.onHistoryChange(target, data); + break; + + case "Extension:DOMContentLoaded": + this.onLoad(target, data); + break; + + case "Content:Click": + this.onContentClick(target, data); + break; + } + }, + + onContentClick(target, data) { + // We are interested only on clicks to links which are not "add to bookmark" commands + if (data.href && !data.bookmark) { + let ownerWin = target.ownerDocument.defaultView; + let where = ownerWin.whereToOpenLink(data); + if (where == "current") { + this.setRecentTabTransitionData({link: true}); + } + } + }, + + onStateChange(browser, data) { + let stateFlags = data.stateFlags; + if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) { + let url = data.requestURL; + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + this.fire("onBeforeNavigate", browser, data, {url}); + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + if (Components.isSuccessCode(data.status)) { + this.fire("onCompleted", browser, data, {url}); + } else { + let error = `Error code ${data.status}`; + this.fire("onErrorOccurred", browser, data, {error, url}); + } + } + } + }, + + onDocumentChange(browser, data) { + let extra = { + url: data.location, + // Transition data which is coming from the content process. + frameTransitionData: data.frameTransitionData, + tabTransitionData: this.getAndForgetRecentTabTransitionData(browser), + }; + + this.fire("onCommitted", browser, data, extra); + }, + + onHistoryChange(browser, data) { + let extra = { + url: data.location, + // Transition data which is coming from the content process. + frameTransitionData: data.frameTransitionData, + tabTransitionData: this.getAndForgetRecentTabTransitionData(browser), + }; + + if (data.isReferenceFragmentUpdated) { + this.fire("onReferenceFragmentUpdated", browser, data, extra); + } else if (data.isHistoryStateUpdated) { + this.fire("onHistoryStateUpdated", browser, data, extra); + } + }, + + onLoad(browser, data) { + this.fire("onDOMContentLoaded", browser, data, {url: data.url}); + }, + + fire(type, browser, data, extra) { + let listeners = this.listeners.get(type); + if (!listeners) { + return; + } + + let details = { + browser, + windowId: data.windowId, + }; + + if (data.parentWindowId) { + details.parentWindowId = data.parentWindowId; + } + + for (let prop in extra) { + details[prop] = extra[prop]; + } + + for (let [listener, filters] of listeners) { + // Call the listener if the listener has no filter or if its filter matches. + if (!filters || filters.matches(extra.url)) { + listener(details); + } + } + }, +}; + +const EVENTS = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + // "onCreatedNavigationTarget", +]; + +var WebNavigation = {}; + +for (let event of EVENTS) { + WebNavigation[event] = { + addListener: Manager.addListener.bind(Manager, event), + removeListener: Manager.removeListener.bind(Manager, event), + }; +} diff --git a/toolkit/modules/addons/WebNavigationContent.js b/toolkit/modules/addons/WebNavigationContent.js new file mode 100644 index 0000000000..cea4a97b38 --- /dev/null +++ b/toolkit/modules/addons/WebNavigationContent.js @@ -0,0 +1,272 @@ +"use strict"; + +/* globals docShell */ + +var Ci = Components.interfaces; + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames", + "resource://gre/modules/WebNavigationFrames.jsm"); + +function loadListener(event) { + let document = event.target; + let window = document.defaultView; + let url = document.documentURI; + let windowId = WebNavigationFrames.getWindowId(window); + let parentWindowId = WebNavigationFrames.getParentWindowId(window); + sendAsyncMessage("Extension:DOMContentLoaded", {windowId, parentWindowId, url}); +} + +addEventListener("DOMContentLoaded", loadListener); +addMessageListener("Extension:DisableWebNavigation", () => { + removeEventListener("DOMContentLoaded", loadListener); +}); + +var FormSubmitListener = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, + Ci.nsIFormSubmitObserver, + Ci.nsISupportsWeakReference]), + init() { + this.formSubmitWindows = new WeakSet(); + Services.obs.addObserver(FormSubmitListener, "earlyformsubmit", false); + }, + + uninit() { + Services.obs.removeObserver(FormSubmitListener, "earlyformsubmit", false); + this.formSubmitWindows = new WeakSet(); + }, + + notify: function(form, window, actionURI) { + try { + this.formSubmitWindows.add(window); + } catch (e) { + Cu.reportError("Error in FormSubmitListener.notify"); + } + }, + + hasAndForget: function(window) { + let has = this.formSubmitWindows.has(window); + this.formSubmitWindows.delete(window); + return has; + }, +}; + +var WebProgressListener = { + init: function() { + // This WeakMap (DOMWindow -> nsIURI) keeps track of the pathname and hash + // of the previous location for all the existent docShells. + this.previousURIMap = new WeakMap(); + + // Populate the above previousURIMap by iterating over the docShells tree. + for (let currentDocShell of WebNavigationFrames.iterateDocShellTree(docShell)) { + let win = currentDocShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + let {currentURI} = currentDocShell.QueryInterface(Ci.nsIWebNavigation); + + this.previousURIMap.set(win, currentURI); + } + + // This WeakSet of DOMWindows keeps track of the attempted refresh. + this.refreshAttemptedDOMWindows = new WeakSet(); + + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW | + Ci.nsIWebProgress.NOTIFY_REFRESH | + Ci.nsIWebProgress.NOTIFY_LOCATION); + }, + + uninit() { + if (!docShell) { + return; + } + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.removeProgressListener(this); + }, + + onRefreshAttempted: function onRefreshAttempted(webProgress, URI, delay, sameURI) { + this.refreshAttemptedDOMWindows.add(webProgress.DOMWindow); + + // If this function doesn't return true, the attempted refresh will be blocked. + return true; + }, + + onStateChange: function onStateChange(webProgress, request, stateFlags, status) { + let {originalURI, URI: locationURI} = request.QueryInterface(Ci.nsIChannel); + + // Prevents "about", "chrome", "resource" and "moz-extension" URI schemes to be + // reported with the resolved "file" or "jar" URIs. (see Bug 1246125 for rationale) + if (locationURI.schemeIs("file") || locationURI.schemeIs("jar")) { + let shouldUseOriginalURI = originalURI.schemeIs("about") || + originalURI.schemeIs("chrome") || + originalURI.schemeIs("resource") || + originalURI.schemeIs("moz-extension"); + + locationURI = shouldUseOriginalURI ? originalURI : locationURI; + } + + this.sendStateChange({webProgress, locationURI, stateFlags, status}); + + // Based on the docs of the webNavigation.onCommitted event, it should be raised when: + // "The document might still be downloading, but at least part of + // the document has been received" + // and for some reason we don't fire onLocationChange for the + // initial navigation of a sub-frame. + // For the above two reasons, when the navigation event is related to + // a sub-frame we process the document change here and + // then send an "Extension:DocumentChange" message to the main process, + // where it will be turned into a webNavigation.onCommitted event. + // (see Bug 1264936 and Bug 125662 for rationale) + if ((webProgress.DOMWindow.top != webProgress.DOMWindow) && + (stateFlags & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT)) { + this.sendDocumentChange({webProgress, locationURI, request}); + } + }, + + onLocationChange: function onLocationChange(webProgress, request, locationURI, flags) { + let {DOMWindow} = webProgress; + + // Get the previous URI loaded in the DOMWindow. + let previousURI = this.previousURIMap.get(DOMWindow); + + // Update the URI in the map with the new locationURI. + this.previousURIMap.set(DOMWindow, locationURI); + + let isSameDocument = (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT); + + // When a frame navigation doesn't change the current loaded document + // (which can be due to history.pushState/replaceState or to a changed hash in the url), + // it is reported only to the onLocationChange, for this reason + // we process the history change here and then we are going to send + // an "Extension:HistoryChange" to the main process, where it will be turned + // into a webNavigation.onHistoryStateUpdated/onReferenceFragmentUpdated event. + if (isSameDocument) { + this.sendHistoryChange({webProgress, previousURI, locationURI, request}); + } else if (webProgress.DOMWindow.top == webProgress.DOMWindow) { + // We have to catch the document changes from top level frames here, + // where we can detect the "server redirect" transition. + // (see Bug 1264936 and Bug 125662 for rationale) + this.sendDocumentChange({webProgress, locationURI, request}); + } + }, + + sendStateChange({webProgress, locationURI, stateFlags, status}) { + let data = { + requestURL: locationURI.spec, + windowId: webProgress.DOMWindowID, + parentWindowId: WebNavigationFrames.getParentWindowId(webProgress.DOMWindow), + status, + stateFlags, + }; + + sendAsyncMessage("Extension:StateChange", data); + }, + + sendDocumentChange({webProgress, locationURI, request}) { + let {loadType, DOMWindow} = webProgress; + let frameTransitionData = this.getFrameTransitionData({loadType, request, DOMWindow}); + + let data = { + frameTransitionData, + location: locationURI ? locationURI.spec : "", + windowId: webProgress.DOMWindowID, + parentWindowId: WebNavigationFrames.getParentWindowId(webProgress.DOMWindow), + }; + + sendAsyncMessage("Extension:DocumentChange", data); + }, + + sendHistoryChange({webProgress, previousURI, locationURI, request}) { + let {loadType, DOMWindow} = webProgress; + + let isHistoryStateUpdated = false; + let isReferenceFragmentUpdated = false; + + let pathChanged = !(previousURI && locationURI.equalsExceptRef(previousURI)); + let hashChanged = !(previousURI && previousURI.ref == locationURI.ref); + + // When the location changes but the document is the same: + // - path not changed and hash changed -> |onReferenceFragmentUpdated| + // (even if it changed using |history.pushState|) + // - path not changed and hash not changed -> |onHistoryStateUpdated| + // (only if it changes using |history.pushState|) + // - path changed -> |onHistoryStateUpdated| + + if (!pathChanged && hashChanged) { + isReferenceFragmentUpdated = true; + } else if (loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE) { + isHistoryStateUpdated = true; + } else if (loadType & Ci.nsIDocShell.LOAD_CMD_HISTORY) { + isHistoryStateUpdated = true; + } + + if (isHistoryStateUpdated || isReferenceFragmentUpdated) { + let frameTransitionData = this.getFrameTransitionData({loadType, request, DOMWindow}); + + let data = { + frameTransitionData, + isHistoryStateUpdated, isReferenceFragmentUpdated, + location: locationURI ? locationURI.spec : "", + windowId: webProgress.DOMWindowID, + parentWindowId: WebNavigationFrames.getParentWindowId(webProgress.DOMWindow), + }; + + sendAsyncMessage("Extension:HistoryChange", data); + } + }, + + getFrameTransitionData({loadType, request, DOMWindow}) { + let frameTransitionData = {}; + + if (loadType & Ci.nsIDocShell.LOAD_CMD_HISTORY) { + frameTransitionData.forward_back = true; + } + + if (loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD) { + frameTransitionData.reload = true; + } + + if (request instanceof Ci.nsIChannel) { + if (request.loadInfo.redirectChain.length) { + frameTransitionData.server_redirect = true; + } + } + + if (FormSubmitListener.hasAndForget(DOMWindow)) { + frameTransitionData.form_submit = true; + } + + if (this.refreshAttemptedDOMWindows.has(DOMWindow)) { + this.refreshAttemptedDOMWindows.delete(DOMWindow); + frameTransitionData.client_redirect = true; + } + + return frameTransitionData; + }, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsIWebProgressListener2, + Ci.nsISupportsWeakReference, + ]), +}; + +var disabled = false; +WebProgressListener.init(); +FormSubmitListener.init(); +addEventListener("unload", () => { + if (!disabled) { + disabled = true; + WebProgressListener.uninit(); + FormSubmitListener.uninit(); + } +}); +addMessageListener("Extension:DisableWebNavigation", () => { + if (!disabled) { + disabled = true; + WebProgressListener.uninit(); + FormSubmitListener.uninit(); + } +}); diff --git a/toolkit/modules/addons/WebNavigationFrames.jsm b/toolkit/modules/addons/WebNavigationFrames.jsm new file mode 100644 index 0000000000..5efa6d1048 --- /dev/null +++ b/toolkit/modules/addons/WebNavigationFrames.jsm @@ -0,0 +1,142 @@ +/* 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"; + +const EXPORTED_SYMBOLS = ["WebNavigationFrames"]; + +var Ci = Components.interfaces; + +/* exported WebNavigationFrames */ + +function getWindowId(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; +} + +function getParentWindowId(window) { + return getWindowId(window.parent); +} + +/** + * Retrieve the DOMWindow associated to the docShell passed as parameter. + * + * @param {nsIDocShell} docShell - the docShell that we want to get the DOMWindow from. + * @returns {nsIDOMWindow} - the DOMWindow associated to the docShell. + */ +function docShellToWindow(docShell) { + return docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); +} + +/** + * The FrameDetail object which represents a frame in WebExtensions APIs. + * + * @typedef {Object} FrameDetail + * @inner + * @property {number} windowId - Represents the numeric id which identify the frame in its tab. + * @property {number} parentWindowId - Represents the numeric id which identify the parent frame. + * @property {string} url - Represents the current location URL loaded in the frame. + * @property {boolean} errorOccurred - Indicates whether an error is occurred during the last load + * happened on this frame (NOT YET SUPPORTED). + */ + +/** + * Convert a docShell object into its internal FrameDetail representation. + * + * @param {nsIDocShell} docShell - the docShell object to be converted into a FrameDetail JSON object. + * @returns {FrameDetail} the FrameDetail JSON object which represents the docShell. + */ +function convertDocShellToFrameDetail(docShell) { + let window = docShellToWindow(docShell); + + return { + windowId: getWindowId(window), + parentWindowId: getParentWindowId(window), + url: window.location.href, + }; +} + +/** + * A generator function which iterates over a docShell tree, given a root docShell. + * + * @param {nsIDocShell} docShell - the root docShell object + * @returns {Iterator<DocShell>} the FrameDetail JSON object which represents the docShell. + */ +function* iterateDocShellTree(docShell) { + let docShellsEnum = docShell.getDocShellEnumerator( + Ci.nsIDocShellTreeItem.typeContent, + Ci.nsIDocShell.ENUMERATE_FORWARDS + ); + + while (docShellsEnum.hasMoreElements()) { + yield docShellsEnum.getNext(); + } + + return null; +} + +/** + * Returns the frame ID of the given window. If the window is the + * top-level content window, its frame ID is 0. Otherwise, its frame ID + * is its outer window ID. + * + * @param {Window} window - The window to retrieve the frame ID for. + * @returns {number} + */ +function getFrameId(window) { + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + + if (!docShell.sameTypeParent) { + return 0; + } + + let utils = window.getInterface(Ci.nsIDOMWindowUtils); + return utils.outerWindowID; +} + +/** + * Search for a frame starting from the passed root docShell and + * convert it to its related frame detail representation. + * + * @param {number} frameId - the frame ID of the frame to retrieve, as + * described in getFrameId. + * @param {nsIDocShell} rootDocShell - the root docShell object + * @returns {nsIDocShell?} the docShell with the given frameId, or null + * if no match. + */ +function findDocShell(frameId, rootDocShell) { + for (let docShell of iterateDocShellTree(rootDocShell)) { + if (frameId == getFrameId(docShellToWindow(docShell))) { + return docShell; + } + } + + return null; +} + +var WebNavigationFrames = { + iterateDocShellTree, + + findDocShell, + + getFrame(docShell, frameId) { + let result = findDocShell(frameId, docShell); + if (result) { + return convertDocShellToFrameDetail(result); + } + return null; + }, + + getFrameId, + + getAllFrames(docShell) { + return Array.from(iterateDocShellTree(docShell), convertDocShellToFrameDetail); + }, + + getWindowId, + getParentWindowId, +}; diff --git a/toolkit/modules/addons/WebRequest.jsm b/toolkit/modules/addons/WebRequest.jsm new file mode 100644 index 0000000000..c720dae5da --- /dev/null +++ b/toolkit/modules/addons/WebRequest.jsm @@ -0,0 +1,918 @@ +/* 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"; + +const EXPORTED_SYMBOLS = ["WebRequest"]; + +/* exported WebRequest */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +const {nsIHttpActivityObserver, nsISocketTransport} = Ci; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionUtils", + "resource://gre/modules/ExtensionUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebRequestCommon", + "resource://gre/modules/WebRequestCommon.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebRequestUpload", + "resource://gre/modules/WebRequestUpload.jsm"); + +XPCOMUtils.defineLazyGetter(this, "ExtensionError", () => ExtensionUtils.ExtensionError); + +function attachToChannel(channel, key, data) { + if (channel instanceof Ci.nsIWritablePropertyBag2) { + let wrapper = {wrappedJSObject: data}; + channel.setPropertyAsInterface(key, wrapper); + } + return data; +} + +function extractFromChannel(channel, key) { + if (channel instanceof Ci.nsIPropertyBag2 && channel.hasKey(key)) { + let data = channel.get(key); + return data && data.wrappedJSObject; + } + return null; +} + +function getData(channel) { + const key = "mozilla.webRequest.data"; + return extractFromChannel(channel, key) || attachToChannel(channel, key, {}); +} + +var RequestId = { + count: 1, + create(channel = null) { + let id = (this.count++).toString(); + if (channel) { + getData(channel).requestId = id; + } + return id; + }, + + get(channel) { + return channel && getData(channel).requestId || this.create(channel); + }, +}; + +function runLater(job) { + Services.tm.currentThread.dispatch(job, Ci.nsIEventTarget.DISPATCH_NORMAL); +} + +function parseFilter(filter) { + if (!filter) { + filter = {}; + } + + // FIXME: Support windowId filtering. + return {urls: filter.urls || null, types: filter.types || null}; +} + +function parseExtra(extra, allowed = []) { + if (extra) { + for (let ex of extra) { + if (allowed.indexOf(ex) == -1) { + throw new ExtensionError(`Invalid option ${ex}`); + } + } + } + + let result = {}; + for (let al of allowed) { + if (extra && extra.indexOf(al) != -1) { + result[al] = true; + } + } + return result; +} + +function mergeStatus(data, channel, event) { + try { + data.statusCode = channel.responseStatus; + let statusText = channel.responseStatusText; + let maj = {}; + let min = {}; + channel.QueryInterface(Ci.nsIHttpChannelInternal).getResponseVersion(maj, min); + data.statusLine = `HTTP/${maj.value}.${min.value} ${data.statusCode} ${statusText}`; + } catch (e) { + // NS_ERROR_NOT_AVAILABLE might be thrown if it's an internal redirect, happening before + // any actual HTTP traffic. Otherwise, let's report. + if (event !== "onRedirect" || e.result !== Cr.NS_ERROR_NOT_AVAILABLE) { + Cu.reportError(`webRequest Error: ${e} trying to merge status in ${event}@${channel.name}`); + } + } +} + +function isThenable(value) { + return value && typeof value === "object" && typeof value.then === "function"; +} + +class HeaderChanger { + constructor(channel) { + this.channel = channel; + + this.originalHeaders = new Map(); + this.visitHeaders((name, value) => { + this.originalHeaders.set(name.toLowerCase(), value); + }); + } + + toArray() { + return Array.from(this.originalHeaders, + ([name, value]) => ({name, value})); + } + + validateHeaders(headers) { + // We should probably use schema validation for this. + + if (!Array.isArray(headers)) { + return false; + } + + return headers.every(header => { + if (typeof header !== "object" || header === null) { + return false; + } + + if (typeof header.name !== "string") { + return false; + } + + return (typeof header.value === "string" || + Array.isArray(header.binaryValue)); + }); + } + + applyChanges(headers) { + if (!this.validateHeaders(headers)) { + /* globals uneval */ + Cu.reportError(`Invalid header array: ${uneval(headers)}`); + return; + } + + let newHeaders = new Set(headers.map( + ({name}) => name.toLowerCase())); + + // Remove missing headers. + for (let name of this.originalHeaders.keys()) { + if (!newHeaders.has(name)) { + this.setHeader(name, ""); + } + } + + // Set new or changed headers. + for (let {name, value, binaryValue} of headers) { + if (binaryValue) { + value = String.fromCharCode(...binaryValue); + } + if (value !== this.originalHeaders.get(name.toLowerCase())) { + this.setHeader(name, value); + } + } + } +} + +class RequestHeaderChanger extends HeaderChanger { + setHeader(name, value) { + try { + this.channel.setRequestHeader(name, value, false); + } catch (e) { + Cu.reportError(new Error(`Error setting request header ${name}: ${e}`)); + } + } + + visitHeaders(visitor) { + if (this.channel instanceof Ci.nsIHttpChannel) { + this.channel.visitRequestHeaders(visitor); + } + } +} + +class ResponseHeaderChanger extends HeaderChanger { + setHeader(name, value) { + try { + if (name.toLowerCase() === "content-type" && value) { + // The Content-Type header value can't be modified, so we + // set the channel's content type directly, instead, and + // record that we made the change for the sake of + // subsequent observers. + this.channel.contentType = value; + + getData(this.channel).contentType = value; + } else { + this.channel.setResponseHeader(name, value, false); + } + } catch (e) { + Cu.reportError(new Error(`Error setting response header ${name}: ${e}`)); + } + } + + visitHeaders(visitor) { + if (this.channel instanceof Ci.nsIHttpChannel) { + try { + this.channel.visitResponseHeaders((name, value) => { + if (name.toLowerCase() === "content-type") { + value = getData(this.channel).contentType || value; + } + + visitor(name, value); + }); + } catch (e) { + // Throws if response headers aren't available yet. + } + } + } +} + +var HttpObserverManager; + +var ContentPolicyManager = { + policyData: new Map(), + policies: new Map(), + idMap: new Map(), + nextId: 0, + + init() { + Services.ppmm.initialProcessData.webRequestContentPolicies = this.policyData; + + Services.ppmm.addMessageListener("WebRequest:ShouldLoad", this); + Services.mm.addMessageListener("WebRequest:ShouldLoad", this); + }, + + receiveMessage(msg) { + let browser = msg.target instanceof Ci.nsIDOMXULElement ? msg.target : null; + + let requestId = RequestId.create(); + for (let id of msg.data.ids) { + let callback = this.policies.get(id); + if (!callback) { + // It's possible that this listener has been removed and the + // child hasn't learned yet. + continue; + } + let response = null; + let listenerKind = "onStop"; + let data = Object.assign({requestId, browser}, msg.data); + delete data.ids; + try { + response = callback(data); + if (response) { + if (response.cancel) { + listenerKind = "onError"; + data.error = "NS_ERROR_ABORT"; + return {cancel: true}; + } + // FIXME: Need to handle redirection here (for non-HTTP URIs only) + } + } catch (e) { + Cu.reportError(e); + } finally { + runLater(() => this.runChannelListener(listenerKind, data)); + } + } + + return {}; + }, + + runChannelListener(kind, data) { + let listeners = HttpObserverManager.listeners[kind]; + let uri = BrowserUtils.makeURI(data.url); + let policyType = data.type; + for (let [callback, opts] of listeners.entries()) { + if (!HttpObserverManager.shouldRunListener(policyType, uri, opts.filter)) { + continue; + } + callback(data); + } + }, + + addListener(callback, opts) { + // Clone opts, since we're going to modify them for IPC. + opts = Object.assign({}, opts); + let id = this.nextId++; + opts.id = id; + if (opts.filter.urls) { + opts.filter = Object.assign({}, opts.filter); + opts.filter.urls = opts.filter.urls.serialize(); + } + Services.ppmm.broadcastAsyncMessage("WebRequest:AddContentPolicy", opts); + + this.policyData.set(id, opts); + + this.policies.set(id, callback); + this.idMap.set(callback, id); + }, + + removeListener(callback) { + let id = this.idMap.get(callback); + Services.ppmm.broadcastAsyncMessage("WebRequest:RemoveContentPolicy", {id}); + + this.policyData.delete(id); + this.idMap.delete(callback); + this.policies.delete(id); + }, +}; +ContentPolicyManager.init(); + +function StartStopListener(manager, loadContext) { + this.manager = manager; + this.loadContext = loadContext; + this.orig = null; +} + +StartStopListener.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIRequestObserver, + Ci.nsIStreamListener]), + + onStartRequest: function(request, context) { + this.manager.onStartRequest(request, this.loadContext); + this.orig.onStartRequest(request, context); + }, + + onStopRequest(request, context, statusCode) { + try { + this.orig.onStopRequest(request, context, statusCode); + } catch (e) { + Cu.reportError(e); + } + this.manager.onStopRequest(request, this.loadContext); + }, + + onDataAvailable(...args) { + return this.orig.onDataAvailable(...args); + }, +}; + +var ChannelEventSink = { + _classDescription: "WebRequest channel event sink", + _classID: Components.ID("115062f8-92f1-11e5-8b7f-080027b0f7ec"), + _contractID: "@mozilla.org/webrequest/channel-event-sink;1", + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIChannelEventSink, + Ci.nsIFactory]), + + init() { + Components.manager.QueryInterface(Ci.nsIComponentRegistrar) + .registerFactory(this._classID, this._classDescription, this._contractID, this); + }, + + register() { + let catMan = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager); + catMan.addCategoryEntry("net-channel-event-sinks", this._contractID, this._contractID, false, true); + }, + + unregister() { + let catMan = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager); + catMan.deleteCategoryEntry("net-channel-event-sinks", this._contractID, false); + }, + + // nsIChannelEventSink implementation + asyncOnChannelRedirect(oldChannel, newChannel, flags, redirectCallback) { + runLater(() => redirectCallback.onRedirectVerifyCallback(Cr.NS_OK)); + try { + HttpObserverManager.onChannelReplaced(oldChannel, newChannel); + } catch (e) { + // we don't wanna throw: it would abort the redirection + } + }, + + // nsIFactory implementation + createInstance(outer, iid) { + if (outer) { + throw Cr.NS_ERROR_NO_AGGREGATION; + } + return this.QueryInterface(iid); + }, +}; + +ChannelEventSink.init(); + +HttpObserverManager = { + modifyInitialized: false, + examineInitialized: false, + redirectInitialized: false, + activityInitialized: false, + needTracing: false, + + listeners: { + opening: new Map(), + modify: new Map(), + afterModify: new Map(), + headersReceived: new Map(), + onRedirect: new Map(), + onStart: new Map(), + onError: new Map(), + onStop: new Map(), + }, + + get activityDistributor() { + return Cc["@mozilla.org/network/http-activity-distributor;1"].getService(Ci.nsIHttpActivityDistributor); + }, + + addOrRemove() { + let needModify = this.listeners.opening.size || this.listeners.modify.size || this.listeners.afterModify.size; + if (needModify && !this.modifyInitialized) { + this.modifyInitialized = true; + Services.obs.addObserver(this, "http-on-modify-request", false); + } else if (!needModify && this.modifyInitialized) { + this.modifyInitialized = false; + Services.obs.removeObserver(this, "http-on-modify-request"); + } + this.needTracing = this.listeners.onStart.size || + this.listeners.onError.size || + this.listeners.onStop.size; + + let needExamine = this.needTracing || + this.listeners.headersReceived.size; + + if (needExamine && !this.examineInitialized) { + this.examineInitialized = true; + Services.obs.addObserver(this, "http-on-examine-response", false); + Services.obs.addObserver(this, "http-on-examine-cached-response", false); + Services.obs.addObserver(this, "http-on-examine-merged-response", false); + } else if (!needExamine && this.examineInitialized) { + this.examineInitialized = false; + Services.obs.removeObserver(this, "http-on-examine-response"); + Services.obs.removeObserver(this, "http-on-examine-cached-response"); + Services.obs.removeObserver(this, "http-on-examine-merged-response"); + } + + let needRedirect = this.listeners.onRedirect.size; + if (needRedirect && !this.redirectInitialized) { + this.redirectInitialized = true; + ChannelEventSink.register(); + } else if (!needRedirect && this.redirectInitialized) { + this.redirectInitialized = false; + ChannelEventSink.unregister(); + } + + let needActivity = this.listeners.onError.size; + if (needActivity && !this.activityInitialized) { + this.activityInitialized = true; + this.activityDistributor.addObserver(this); + } else if (!needActivity && this.activityInitialized) { + this.activityInitialized = false; + this.activityDistributor.removeObserver(this); + } + }, + + addListener(kind, callback, opts) { + this.listeners[kind].set(callback, opts); + this.addOrRemove(); + }, + + removeListener(kind, callback) { + this.listeners[kind].delete(callback); + this.addOrRemove(); + }, + + getLoadContext(channel) { + try { + return channel.QueryInterface(Ci.nsIChannel) + .notificationCallbacks + .getInterface(Components.interfaces.nsILoadContext); + } catch (e) { + try { + return channel.loadGroup + .notificationCallbacks + .getInterface(Components.interfaces.nsILoadContext); + } catch (e) { + return null; + } + } + }, + + observe(subject, topic, data) { + let channel = subject.QueryInterface(Ci.nsIHttpChannel); + switch (topic) { + case "http-on-modify-request": + let loadContext = this.getLoadContext(channel); + + this.runChannelListener(channel, loadContext, "opening"); + break; + case "http-on-examine-cached-response": + case "http-on-examine-merged-response": + getData(channel).fromCache = true; + // falls through + case "http-on-examine-response": + this.examine(channel, topic, data); + break; + } + }, + + // We map activity values with tentative error names, e.g. "STATUS_RESOLVING" => "NS_ERROR_NET_ON_RESOLVING". + get activityErrorsMap() { + let prefix = /^(?:ACTIVITY_SUBTYPE_|STATUS_)/; + let map = new Map(); + for (let iface of [nsIHttpActivityObserver, nsISocketTransport]) { + for (let c of Object.keys(iface).filter(name => prefix.test(name))) { + map.set(iface[c], c.replace(prefix, "NS_ERROR_NET_ON_")); + } + } + delete this.activityErrorsMap; + this.activityErrorsMap = map; + return this.activityErrorsMap; + }, + GOOD_LAST_ACTIVITY: nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_HEADER, + observeActivity(channel, activityType, activitySubtype /* , aTimestamp, aExtraSizeData, aExtraStringData */) { + let channelData = getData(channel); + let lastActivity = channelData.lastActivity || 0; + if (activitySubtype === nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE && + lastActivity && lastActivity !== this.GOOD_LAST_ACTIVITY) { + let loadContext = this.getLoadContext(channel); + if (!this.errorCheck(channel, loadContext, channelData)) { + this.runChannelListener(channel, loadContext, "onError", + {error: this.activityErrorsMap.get(lastActivity) || + `NS_ERROR_NET_UNKNOWN_${lastActivity}`}); + } + } else if (lastActivity !== this.GOOD_LAST_ACTIVITY && + lastActivity !== nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE) { + channelData.lastActivity = activitySubtype; + } + }, + + shouldRunListener(policyType, uri, filter) { + return WebRequestCommon.typeMatches(policyType, filter.types) && + WebRequestCommon.urlMatches(uri, filter.urls); + }, + + get resultsMap() { + delete this.resultsMap; + this.resultsMap = new Map(Object.keys(Cr).map(name => [Cr[name], name])); + return this.resultsMap; + }, + maybeError(channel, extraData = null, channelData = null) { + if (!(extraData && extraData.error)) { + if (!Components.isSuccessCode(channel.status)) { + extraData = {error: this.resultsMap.get(channel.status)}; + } + } + return extraData; + }, + errorCheck(channel, loadContext, channelData = null) { + let errorData = this.maybeError(channel, null, channelData); + if (errorData) { + this.runChannelListener(channel, loadContext, "onError", errorData); + } + return errorData; + }, + + /** + * Resumes the channel if it is currently suspended due to this + * listener. + * + * @param {nsIChannel} channel + * The channel to possibly suspend. + */ + maybeResume(channel) { + let data = getData(channel); + if (data.suspended) { + channel.resume(); + data.suspended = false; + } + }, + + /** + * Suspends the channel if it is not currently suspended due to this + * listener. Returns true if the channel was suspended as a result of + * this call. + * + * @param {nsIChannel} channel + * The channel to possibly suspend. + * @returns {boolean} + * True if this call resulted in the channel being suspended. + */ + maybeSuspend(channel) { + let data = getData(channel); + if (!data.suspended) { + channel.suspend(); + data.suspended = true; + return true; + } + }, + + getRequestData(channel, loadContext, policyType, extraData) { + let {loadInfo} = channel; + + let data = { + requestId: RequestId.get(channel), + url: channel.URI.spec, + method: channel.requestMethod, + browser: loadContext && loadContext.topFrameElement, + type: WebRequestCommon.typeForPolicyType(policyType), + fromCache: getData(channel).fromCache, + windowId: 0, + parentWindowId: 0, + }; + + if (loadInfo) { + let originPrincipal = loadInfo.triggeringPrincipal; + if (originPrincipal.URI) { + data.originUrl = originPrincipal.URI.spec; + } + + // If there is no loadingPrincipal, check that the request is not going to + // inherit a system principal. triggeringPrincipal is the context that + // initiated the load, but is not necessarily the principal that the + // request results in, only rely on that if no other principal is available. + let {isSystemPrincipal} = Services.scriptSecurityManager; + let isTopLevel = !loadInfo.loadingPrincipal && !!data.browser; + data.isSystemPrincipal = !isTopLevel && + isSystemPrincipal(loadInfo.loadingPrincipal || + loadInfo.principalToInherit || + loadInfo.triggeringPrincipal); + + if (loadInfo.frameOuterWindowID) { + Object.assign(data, { + windowId: loadInfo.frameOuterWindowID, + parentWindowId: loadInfo.outerWindowID, + }); + } else { + Object.assign(data, { + windowId: loadInfo.outerWindowID, + parentWindowId: loadInfo.parentOuterWindowID, + }); + } + } + + if (channel instanceof Ci.nsIHttpChannelInternal) { + try { + data.ip = channel.remoteAddress; + } catch (e) { + // The remoteAddress getter throws if the address is unavailable, + // but ip is an optional property so just ignore the exception. + } + } + + return Object.assign(data, extraData); + }, + + runChannelListener(channel, loadContext = null, kind, extraData = null) { + let handlerResults = []; + let requestHeaders; + let responseHeaders; + + try { + if (this.activityInitialized) { + let channelData = getData(channel); + if (kind === "onError") { + if (channelData.errorNotified) { + return; + } + channelData.errorNotified = true; + } else if (this.errorCheck(channel, loadContext, channelData)) { + return; + } + } + + let {loadInfo} = channel; + let policyType = (loadInfo ? loadInfo.externalContentPolicyType + : Ci.nsIContentPolicy.TYPE_OTHER); + + let includeStatus = (["headersReceived", "onRedirect", "onStart", "onStop"].includes(kind) && + channel instanceof Ci.nsIHttpChannel); + + let commonData = null; + let uri = channel.URI; + let requestBody; + for (let [callback, opts] of this.listeners[kind].entries()) { + if (!this.shouldRunListener(policyType, uri, opts.filter)) { + continue; + } + + if (!commonData) { + commonData = this.getRequestData(channel, loadContext, policyType, extraData); + } + let data = Object.assign({}, commonData); + + if (opts.requestHeaders) { + requestHeaders = requestHeaders || new RequestHeaderChanger(channel); + data.requestHeaders = requestHeaders.toArray(); + } + + if (opts.responseHeaders) { + responseHeaders = responseHeaders || new ResponseHeaderChanger(channel); + data.responseHeaders = responseHeaders.toArray(); + } + + if (opts.requestBody) { + requestBody = requestBody || WebRequestUpload.createRequestBody(channel); + data.requestBody = requestBody; + } + + if (includeStatus) { + mergeStatus(data, channel, kind); + } + + try { + let result = callback(data); + + if (result && typeof result === "object" && opts.blocking + && !AddonManagerPermissions.isHostPermitted(uri.host) + && (!loadInfo || !loadInfo.loadingPrincipal + || !loadInfo.loadingPrincipal.URI + || !AddonManagerPermissions.isHostPermitted(loadInfo.loadingPrincipal.URI.host))) { + handlerResults.push({opts, result}); + } + } catch (e) { + Cu.reportError(e); + } + } + } catch (e) { + Cu.reportError(e); + } + + return this.applyChanges(kind, channel, loadContext, handlerResults, + requestHeaders, responseHeaders); + }, + + applyChanges: Task.async(function* (kind, channel, loadContext, handlerResults, requestHeaders, responseHeaders) { + let asyncHandlers = handlerResults.filter(({result}) => isThenable(result)); + let isAsync = asyncHandlers.length > 0; + let shouldResume = false; + + try { + if (isAsync) { + shouldResume = this.maybeSuspend(channel); + + for (let value of asyncHandlers) { + try { + value.result = yield value.result; + } catch (e) { + Cu.reportError(e); + value.result = {}; + } + } + } + + for (let {opts, result} of handlerResults) { + if (!result || typeof result !== "object") { + continue; + } + + if (result.cancel) { + this.maybeResume(channel); + channel.cancel(Cr.NS_ERROR_ABORT); + + this.errorCheck(channel, loadContext); + return; + } + + if (result.redirectUrl) { + try { + this.maybeResume(channel); + + channel.redirectTo(BrowserUtils.makeURI(result.redirectUrl)); + return; + } catch (e) { + Cu.reportError(e); + } + } + + if (opts.requestHeaders && result.requestHeaders && requestHeaders) { + requestHeaders.applyChanges(result.requestHeaders); + } + + if (opts.responseHeaders && result.responseHeaders && responseHeaders) { + responseHeaders.applyChanges(result.responseHeaders); + } + } + + if (kind === "opening") { + yield this.runChannelListener(channel, loadContext, "modify"); + } else if (kind === "modify") { + yield this.runChannelListener(channel, loadContext, "afterModify"); + } + } catch (e) { + Cu.reportError(e); + } + + // Only resume the channel if it was suspended by this call. + if (shouldResume) { + this.maybeResume(channel); + } + }), + + examine(channel, topic, data) { + let loadContext = this.getLoadContext(channel); + + if (this.needTracing) { + // Check whether we've already added a listener to this channel, + // so we don't wind up chaining multiple listeners. + let channelData = getData(channel); + if (!channelData.hasListener && channel instanceof Ci.nsITraceableChannel) { + let responseStatus = channel.responseStatus; + // skip redirections, https://bugzilla.mozilla.org/show_bug.cgi?id=728901#c8 + if (responseStatus < 300 || responseStatus >= 400) { + let listener = new StartStopListener(this, loadContext); + let orig = channel.setNewListener(listener); + listener.orig = orig; + channelData.hasListener = true; + } + } + } + + this.runChannelListener(channel, loadContext, "headersReceived"); + }, + + onChannelReplaced(oldChannel, newChannel) { + this.runChannelListener(oldChannel, this.getLoadContext(oldChannel), + "onRedirect", {redirectUrl: newChannel.URI.spec}); + }, + + onStartRequest(channel, loadContext) { + this.runChannelListener(channel, loadContext, "onStart"); + }, + + onStopRequest(channel, loadContext) { + this.runChannelListener(channel, loadContext, "onStop"); + }, +}; + +var onBeforeRequest = { + get allowedOptions() { + delete this.allowedOptions; + this.allowedOptions = ["blocking"]; + if (!AppConstants.RELEASE_OR_BETA) { + this.allowedOptions.push("requestBody"); + } + return this.allowedOptions; + }, + addListener(callback, filter = null, opt_extraInfoSpec = null) { + let opts = parseExtra(opt_extraInfoSpec, this.allowedOptions); + opts.filter = parseFilter(filter); + ContentPolicyManager.addListener(callback, opts); + HttpObserverManager.addListener("opening", callback, opts); + }, + + removeListener(callback) { + HttpObserverManager.removeListener("opening", callback); + ContentPolicyManager.removeListener(callback); + }, +}; + +function HttpEvent(internalEvent, options) { + this.internalEvent = internalEvent; + this.options = options; +} + +HttpEvent.prototype = { + addListener(callback, filter = null, opt_extraInfoSpec = null) { + let opts = parseExtra(opt_extraInfoSpec, this.options); + opts.filter = parseFilter(filter); + HttpObserverManager.addListener(this.internalEvent, callback, opts); + }, + + removeListener(callback) { + HttpObserverManager.removeListener(this.internalEvent, callback); + }, +}; + +var onBeforeSendHeaders = new HttpEvent("modify", ["requestHeaders", "blocking"]); +var onSendHeaders = new HttpEvent("afterModify", ["requestHeaders"]); +var onHeadersReceived = new HttpEvent("headersReceived", ["blocking", "responseHeaders"]); +var onBeforeRedirect = new HttpEvent("onRedirect", ["responseHeaders"]); +var onResponseStarted = new HttpEvent("onStart", ["responseHeaders"]); +var onCompleted = new HttpEvent("onStop", ["responseHeaders"]); +var onErrorOccurred = new HttpEvent("onError"); + +var WebRequest = { + // http-on-modify observer for HTTP(S), content policy for the other protocols (notably, data:) + onBeforeRequest: onBeforeRequest, + + // http-on-modify observer. + onBeforeSendHeaders: onBeforeSendHeaders, + + // http-on-modify observer. + onSendHeaders: onSendHeaders, + + // http-on-examine-*observer. + onHeadersReceived: onHeadersReceived, + + // nsIChannelEventSink. + onBeforeRedirect: onBeforeRedirect, + + // OnStartRequest channel listener. + onResponseStarted: onResponseStarted, + + // OnStopRequest channel listener. + onCompleted: onCompleted, + + // nsIHttpActivityObserver. + onErrorOccurred: onErrorOccurred, +}; + +Services.ppmm.loadProcessScript("resource://gre/modules/WebRequestContent.js", true); diff --git a/toolkit/modules/addons/WebRequestCommon.jsm b/toolkit/modules/addons/WebRequestCommon.jsm new file mode 100644 index 0000000000..9359f4ff7c --- /dev/null +++ b/toolkit/modules/addons/WebRequestCommon.jsm @@ -0,0 +1,57 @@ +/* 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"; + +const EXPORTED_SYMBOLS = ["WebRequestCommon"]; + +/* exported WebRequestCommon */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +var WebRequestCommon = { + typeForPolicyType(type) { + switch (type) { + case Ci.nsIContentPolicy.TYPE_DOCUMENT: return "main_frame"; + case Ci.nsIContentPolicy.TYPE_SUBDOCUMENT: return "sub_frame"; + case Ci.nsIContentPolicy.TYPE_STYLESHEET: return "stylesheet"; + case Ci.nsIContentPolicy.TYPE_SCRIPT: return "script"; + case Ci.nsIContentPolicy.TYPE_IMAGE: return "image"; + case Ci.nsIContentPolicy.TYPE_OBJECT: return "object"; + case Ci.nsIContentPolicy.TYPE_OBJECT_SUBREQUEST: return "object_subrequest"; + case Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST: return "xmlhttprequest"; + case Ci.nsIContentPolicy.TYPE_XBL: return "xbl"; + case Ci.nsIContentPolicy.TYPE_XSLT: return "xslt"; + case Ci.nsIContentPolicy.TYPE_PING: return "ping"; + case Ci.nsIContentPolicy.TYPE_BEACON: return "beacon"; + case Ci.nsIContentPolicy.TYPE_DTD: return "xml_dtd"; + case Ci.nsIContentPolicy.TYPE_FONT: return "font"; + case Ci.nsIContentPolicy.TYPE_MEDIA: return "media"; + case Ci.nsIContentPolicy.TYPE_WEBSOCKET: return "websocket"; + case Ci.nsIContentPolicy.TYPE_CSP_REPORT: return "csp_report"; + case Ci.nsIContentPolicy.TYPE_IMAGESET: return "imageset"; + case Ci.nsIContentPolicy.TYPE_WEB_MANIFEST: return "web_manifest"; + default: return "other"; + } + }, + + typeMatches(policyType, filterTypes) { + if (filterTypes === null) { + return true; + } + + return filterTypes.indexOf(this.typeForPolicyType(policyType)) != -1; + }, + + urlMatches(uri, urlFilter) { + if (urlFilter === null) { + return true; + } + + return urlFilter.matches(uri); + }, +}; diff --git a/toolkit/modules/addons/WebRequestContent.js b/toolkit/modules/addons/WebRequestContent.js new file mode 100644 index 0000000000..219675e5b9 --- /dev/null +++ b/toolkit/modules/addons/WebRequestContent.js @@ -0,0 +1,192 @@ +/* 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"; + +var Ci = Components.interfaces; +var Cc = Components.classes; +var Cu = Components.utils; +var Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern", + "resource://gre/modules/MatchPattern.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebRequestCommon", + "resource://gre/modules/WebRequestCommon.jsm"); + +const IS_HTTP = /^https?:/; + +var ContentPolicy = { + _classDescription: "WebRequest content policy", + _classID: Components.ID("938e5d24-9ccc-4b55-883e-c252a41f7ce9"), + _contractID: "@mozilla.org/webrequest/policy;1", + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIContentPolicy, + Ci.nsIFactory, + Ci.nsISupportsWeakReference]), + + init() { + let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + registrar.registerFactory(this._classID, this._classDescription, this._contractID, this); + + this.contentPolicies = new Map(); + Services.cpmm.addMessageListener("WebRequest:AddContentPolicy", this); + Services.cpmm.addMessageListener("WebRequest:RemoveContentPolicy", this); + + if (initialProcessData && initialProcessData.webRequestContentPolicies) { + for (let data of initialProcessData.webRequestContentPolicies.values()) { + this.addContentPolicy(data); + } + } + }, + + addContentPolicy({id, blocking, filter}) { + if (this.contentPolicies.size == 0) { + this.register(); + } + if (filter.urls) { + filter.urls = new MatchPattern(filter.urls); + } + this.contentPolicies.set(id, {blocking, filter}); + }, + + receiveMessage(msg) { + switch (msg.name) { + case "WebRequest:AddContentPolicy": + this.addContentPolicy(msg.data); + break; + + case "WebRequest:RemoveContentPolicy": + this.contentPolicies.delete(msg.data.id); + if (this.contentPolicies.size == 0) { + this.unregister(); + } + break; + } + }, + + register() { + let catMan = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager); + catMan.addCategoryEntry("content-policy", this._contractID, this._contractID, false, true); + }, + + unregister() { + let catMan = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager); + catMan.deleteCategoryEntry("content-policy", this._contractID, false); + }, + + shouldLoad(policyType, contentLocation, requestOrigin, + node, mimeTypeGuess, extra, requestPrincipal) { + if (requestPrincipal && + Services.scriptSecurityManager.isSystemPrincipal(requestPrincipal)) { + return Ci.nsIContentPolicy.ACCEPT; + } + let url = contentLocation.spec; + if (IS_HTTP.test(url)) { + // We'll handle this in our parent process HTTP observer. + return Ci.nsIContentPolicy.ACCEPT; + } + + let block = false; + let ids = []; + for (let [id, {blocking, filter}] of this.contentPolicies.entries()) { + if (WebRequestCommon.typeMatches(policyType, filter.types) && + WebRequestCommon.urlMatches(contentLocation, filter.urls)) { + if (blocking) { + block = true; + } + ids.push(id); + } + } + + if (!ids.length) { + return Ci.nsIContentPolicy.ACCEPT; + } + + let windowId = 0; + let parentWindowId = -1; + let mm = Services.cpmm; + + function getWindowId(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + } + + if (policyType == Ci.nsIContentPolicy.TYPE_SUBDOCUMENT || + (node instanceof Ci.nsIDOMXULElement && node.localName == "browser")) { + // Chrome sets frameId to the ID of the sub-window. But when + // Firefox loads an iframe, it sets |node| to the <iframe> + // element, whose window is the parent window. We adopt the + // Chrome behavior here. + node = node.contentWindow; + } + + if (node) { + let window; + if (node instanceof Ci.nsIDOMWindow) { + window = node; + } else { + let doc; + if (node.ownerDocument) { + doc = node.ownerDocument; + } else { + doc = node; + } + window = doc.defaultView; + } + + windowId = getWindowId(window); + if (window.parent !== window) { + parentWindowId = getWindowId(window.parent); + } + + let ir = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIInterfaceRequestor); + try { + // If e10s is disabled, this throws NS_NOINTERFACE for closed tabs. + mm = ir.getInterface(Ci.nsIContentFrameMessageManager); + } catch (e) { + if (e.result != Cr.NS_NOINTERFACE) { + throw e; + } + } + } + + let data = {ids, + url, + type: WebRequestCommon.typeForPolicyType(policyType), + windowId, + parentWindowId}; + if (requestOrigin) { + data.originUrl = requestOrigin.spec; + } + if (block) { + let rval = mm.sendSyncMessage("WebRequest:ShouldLoad", data); + if (rval.length == 1 && rval[0].cancel) { + return Ci.nsIContentPolicy.REJECT; + } + } else { + mm.sendAsyncMessage("WebRequest:ShouldLoad", data); + } + + return Ci.nsIContentPolicy.ACCEPT; + }, + + shouldProcess: function(contentType, contentLocation, requestOrigin, insecNode, mimeType, extra) { + return Ci.nsIContentPolicy.ACCEPT; + }, + + createInstance: function(outer, iid) { + if (outer) { + throw Cr.NS_ERROR_NO_AGGREGATION; + } + return this.QueryInterface(iid); + }, +}; + +ContentPolicy.init(); diff --git a/toolkit/modules/addons/WebRequestUpload.jsm b/toolkit/modules/addons/WebRequestUpload.jsm new file mode 100644 index 0000000000..789ce683f5 --- /dev/null +++ b/toolkit/modules/addons/WebRequestUpload.jsm @@ -0,0 +1,321 @@ +/* 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"; + +const EXPORTED_SYMBOLS = ["WebRequestUpload"]; + +/* exported WebRequestUpload */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +var WebRequestUpload; + +function rewind(stream) { + try { + if (stream instanceof Ci.nsISeekableStream) { + stream.seek(0, 0); + } + } catch (e) { + // It might be already closed, e.g. because of a previous error. + } +} + +function parseFormData(stream, channel, lenient = false) { + const BUFFER_SIZE = 8192; // Empirically it seemed a good compromise. + + let mimeStream = null; + + if (stream instanceof Ci.nsIMIMEInputStream && stream.data) { + mimeStream = stream; + stream = stream.data; + } + let multiplexStream = null; + if (stream instanceof Ci.nsIMultiplexInputStream) { + multiplexStream = stream; + } + + let touchedStreams = new Set(); + + function createTextStream(stream) { + let textStream = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(Ci.nsIConverterInputStream); + textStream.init(stream, "UTF-8", 0, lenient ? textStream.DEFAULT_REPLACEMENT_CHARACTER : 0); + if (stream instanceof Ci.nsISeekableStream) { + touchedStreams.add(stream); + } + return textStream; + } + + let streamIdx = 0; + function nextTextStream() { + for (; streamIdx < multiplexStream.count;) { + let currentStream = multiplexStream.getStream(streamIdx++); + if (currentStream instanceof Ci.nsIStringInputStream) { + touchedStreams.add(multiplexStream); + return createTextStream(currentStream); + } + } + return null; + } + + let textStream; + if (multiplexStream) { + textStream = nextTextStream(); + } else { + textStream = createTextStream(mimeStream || stream); + } + + if (!textStream) { + return null; + } + + function readString() { + if (textStream) { + let textBuffer = {}; + textStream.readString(BUFFER_SIZE, textBuffer); + return textBuffer.value; + } + return ""; + } + + function multiplexRead() { + let str = readString(); + if (!str) { + textStream = nextTextStream(); + if (textStream) { + str = multiplexRead(); + } + } + return str; + } + + let readChunk; + if (multiplexStream) { + readChunk = multiplexRead; + } else { + readChunk = readString; + } + + function appendFormData(formData, name, value) { + if (name in formData) { + formData[name].push(value); + } else { + formData[name] = [value]; + } + } + + function parseMultiPart(firstChunk, boundary = "") { + let formData = Object.create(null); + + if (!boundary) { + let match = firstChunk.match(/^--\S+/); + if (!match) { + return null; + } + boundary = match[0]; + } + + let unslash = (s) => s.replace(/\\"/g, '"'); + let tail = ""; + for (let chunk = firstChunk; + chunk || tail; + chunk = readChunk()) { + let parts; + if (chunk) { + chunk = tail + chunk; + parts = chunk.split(boundary); + tail = parts.pop(); + } else { + parts = [tail]; + tail = ""; + } + + for (let part of parts) { + let match = part.match(/^\r\nContent-Disposition: form-data; name="(.*)"\r\n(?:Content-Type: (\S+))?.*\r\n/i); + if (!match) { + continue; + } + let [header, name, contentType] = match; + if (contentType) { + let fileName; + // Since escaping inside Content-Disposition subfields is still poorly defined and buggy (see Bug 136676), + // currently we always consider backslash-prefixed quotes as escaped even if that's not generally true + // (i.e. in a field whose value actually ends with a backslash). + // Therefore in this edge case we may end coalescing name and filename, which is marginally better than + // potentially truncating the name field at the wrong point, at least from a XSS filter POV. + match = name.match(/^(.*[^\\])"; filename="(.*)/); + if (match) { + [, name, fileName] = match; + } + appendFormData(formData, unslash(name), fileName ? unslash(fileName) : ""); + } else { + appendFormData(formData, unslash(name), part.slice(header.length, -2)); + } + } + } + + return formData; + } + + function parseUrlEncoded(firstChunk) { + let formData = Object.create(null); + + let tail = ""; + for (let chunk = firstChunk; + chunk || tail; + chunk = readChunk()) { + let pairs; + if (chunk) { + chunk = tail + chunk.trim(); + pairs = chunk.split("&"); + tail = pairs.pop(); + } else { + chunk = tail; + tail = ""; + pairs = [chunk]; + } + for (let pair of pairs) { + let [name, value] = pair.replace(/\+/g, " ").split("=").map(decodeURIComponent); + appendFormData(formData, name, value); + } + } + + return formData; + } + + try { + let chunk = readChunk(); + + if (multiplexStream) { + touchedStreams.add(multiplexStream); + return parseMultiPart(chunk); + } + let contentType; + if (/^Content-Type:/i.test(chunk)) { + contentType = chunk.replace(/^Content-Type:\s*/i, ""); + chunk = chunk.slice(chunk.indexOf("\r\n\r\n") + 4); + } else { + try { + contentType = channel.getRequestHeader("Content-Type"); + } catch (e) { + Cu.reportError(e); + return null; + } + } + + let match = contentType.match(/^(?:multipart\/form-data;\s*boundary=(\S*)|application\/x-www-form-urlencoded\s)/i); + if (match) { + let boundary = match[1]; + if (boundary) { + return parseMultiPart(chunk, boundary); + } + return parseUrlEncoded(chunk); + } + } finally { + for (let stream of touchedStreams) { + rewind(stream); + } + } + + return null; +} + +function createFormData(stream, channel) { + try { + rewind(stream); + return parseFormData(stream, channel); + } catch (e) { + Cu.reportError(e); + } finally { + rewind(stream); + } + return null; +} + +function convertRawData(outerStream) { + let raw = []; + let totalBytes = 0; + + // Here we read the stream up to WebRequestUpload.MAX_RAW_BYTES, returning false if we had to truncate the result. + function readAll(stream) { + let unbuffered = stream.unbufferedStream || stream; + if (unbuffered instanceof Ci.nsIFileInputStream) { + raw.push({file: "<file>"}); // Full paths not supported yet for naked files (follow up bug) + return true; + } + rewind(stream); + + let binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(Ci.nsIBinaryInputStream); + binaryStream.setInputStream(stream); + const MAX_BYTES = WebRequestUpload.MAX_RAW_BYTES; + try { + for (let available; (available = binaryStream.available());) { + let size = Math.min(MAX_BYTES - totalBytes, available); + let bytes = new ArrayBuffer(size); + binaryStream.readArrayBuffer(size, bytes); + let chunk = {bytes}; + raw.push(chunk); + totalBytes += size; + + if (totalBytes >= MAX_BYTES) { + if (size < available) { + chunk.truncated = true; + chunk.originalSize = available; + return false; + } + break; + } + } + } finally { + rewind(stream); + } + return true; + } + + let unbuffered = outerStream; + if (outerStream instanceof Ci.nsIStreamBufferAccess) { + unbuffered = outerStream.unbufferedStream; + } + + if (unbuffered instanceof Ci.nsIMultiplexInputStream) { + for (let i = 0, count = unbuffered.count; i < count; i++) { + if (!readAll(unbuffered.getStream(i))) { + break; + } + } + } else { + readAll(outerStream); + } + + return raw; +} + +WebRequestUpload = { + createRequestBody(channel) { + let requestBody = null; + if (channel instanceof Ci.nsIUploadChannel && channel.uploadStream) { + try { + let stream = channel.uploadStream.QueryInterface(Ci.nsISeekableStream); + let formData = createFormData(stream, channel); + if (formData) { + requestBody = {formData}; + } else { + requestBody = {raw: convertRawData(stream), lenientFormData: createFormData(stream, channel, true)}; + } + } catch (e) { + Cu.reportError(e); + requestBody = {error: e.message || String(e)}; + } + requestBody = Object.freeze(requestBody); + } + return requestBody; + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter(WebRequestUpload, "MAX_RAW_BYTES", "webextensions.webRequest.requestBodyMaxRawBytes"); diff --git a/toolkit/modules/debug.js b/toolkit/modules/debug.js new file mode 100644 index 0000000000..de26ce6ff6 --- /dev/null +++ b/toolkit/modules/debug.js @@ -0,0 +1,79 @@ +/* vim:set ts=2 sw=2 sts=2 ci et: */ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ + +/* 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/. */ + +// This file contains functions that are useful for debugging purposes from +// within JavaScript code. + +this.EXPORTED_SYMBOLS = ["NS_ASSERT"]; + +var gTraceOnAssert = true; + +/** + * This function provides a simple assertion function for JavaScript. + * If the condition is true, this function will do nothing. If the + * condition is false, then the message will be printed to the console + * and an alert will appear showing a stack trace, so that the (alpha + * or nightly) user can file a bug containing it. For future enhancements, + * see bugs 330077 and 330078. + * + * To suppress the dialogs, you can run with the environment variable + * XUL_ASSERT_PROMPT set to 0 (if unset, this defaults to 1). + * + * @param condition represents the condition that we're asserting to be + * true when we call this function--should be + * something that can be evaluated as a boolean. + * @param message a string to be displayed upon failure of the assertion + */ + +this.NS_ASSERT = function NS_ASSERT(condition, message) { + if (condition) + return; + + var releaseBuild = true; + var defB = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefService) + .getDefaultBranch(null); + try { + switch (defB.getCharPref("app.update.channel")) { + case "nightly": + case "aurora": + case "beta": + case "default": + releaseBuild = false; + } + } catch (ex) {} + + var caller = arguments.callee.caller; + var assertionText = "ASSERT: " + message + "\n"; + + // Report the error to the console + Components.utils.reportError(assertionText); + + if (releaseBuild) { + return; + } + + // dump the stack to stdout too in non-release builds + var stackText = ""; + if (gTraceOnAssert) { + stackText = "Stack Trace: \n"; + var count = 0; + while (caller) { + stackText += count++ + ":" + caller.name + "("; + for (var i = 0; i < caller.arguments.length; ++i) { + var arg = caller.arguments[i]; + stackText += arg; + if (i < caller.arguments.length - 1) + stackText += ","; + } + stackText += ")\n"; + caller = caller.arguments.callee.caller; + } + } + + dump(assertionText + stackText); +} diff --git a/toolkit/modules/docs/AsyncShutdown.rst b/toolkit/modules/docs/AsyncShutdown.rst new file mode 100644 index 0000000000..78e3e6c458 --- /dev/null +++ b/toolkit/modules/docs/AsyncShutdown.rst @@ -0,0 +1,264 @@ +.. _AsyncShutdown: + +============== +AsyncShutdown +============== + +During shutdown of the process, subsystems are closed one after another. ``AsyncShutdown`` is a module dedicated to express shutdown-time dependencies between: + +- services and their clients; +- shutdown phases (e.g. profile-before-change) and their clients. + +.. _AsyncShutdown_Barriers: + +Barriers: Expressing shutdown dependencies towards a service +============================================================ + +Consider a service FooService. At some point during the shutdown of the process, this service needs to: + +- inform its clients that it is about to shut down; +- wait until the clients have completed their final operations based on FooService (often asynchronously); +- only then shut itself down. + +This may be expressed as an instance of ``AsyncShutdown.Barrier``. An instance of ``AsyncShutdown.Barrier`` provides: + +- a capability ``client`` that may be published to clients, to let them register or unregister blockers; +- methods for the owner of the barrier to let it consult the state of blockers and wait until all client-registered blockers have been resolved. + +Shutdown timeouts +----------------- + +By design, an instance of ``AsyncShutdown.Barrier`` will cause a crash +if it takes more than 60 seconds `awake` for its clients to lift or +remove their blockers (`awake` meaning that seconds during which the +computer is asleep or too busy to do anything are not counted). This +mechanism helps ensure that we do not leave the process in a state in +which it can neither proceed with shutdown nor be relaunched. + +If the CrashReporter is enabled, this crash will report: + +- the name of the barrier that failed; +- for each blocker that has not been released yet: + + - the name of the blocker; + - the state of the blocker, if a state function has been provided (see :ref:`AsyncShutdown.Barrier.state`). + +Example 1: Simple Barrier client +-------------------------------- + +The following snippet presents an example of a client of FooService that has a shutdown dependency upon FooService. In this case, the client wishes to ensure that FooService is not shutdown before some state has been reached. An example is clients that need write data asynchronously and need to ensure that they have fully written their state to disk before shutdown, even if due to some user manipulation shutdown takes place immediately. + +.. code-block:: javascript + + // Some client of FooService called FooClient + + Components.utils.import("resource://gre/modules/FooService.jsm", this); + + // FooService.shutdown is the `client` capability of a `Barrier`. + // See example 2 for the definition of `FooService.shutdown` + FooService.shutdown.addBlocker( + "FooClient: Need to make sure that we have reached some state", + () => promiseReachedSomeState + ); + // promiseReachedSomeState should be an instance of Promise resolved once + // we have reached the expected state + +Example 2: Simple Barrier owner +------------------------------- + +The following snippet presents an example of a service FooService that +wishes to ensure that all clients have had a chance to complete any +outstanding operations before FooService shuts down. + +.. code-block:: javascript + + // Module FooService + + Components.utils.import("resource://gre/modules/AsyncShutdown.jsm", this); + Components.utils.import("resource://gre/modules/Task.jsm", this); + + this.exports = ["FooService"]; + + let shutdown = new AsyncShutdown.Barrier("FooService: Waiting for clients before shutting down"); + + // Export the `client` capability, to let clients register shutdown blockers + FooService.shutdown = shutdown.client; + + // This Task should be triggered at some point during shutdown, generally + // as a client to another Barrier or Phase. Triggering this Task is not covered + // in this snippet. + let onshutdown = Task.async(function*() { + // Wait for all registered clients to have lifted the barrier + yield shutdown.wait(); + + // Now deactivate FooService itself. + // ... + }); + +Frequently, a service that owns a ``AsyncShutdown.Barrier`` is itself a client of another Barrier. + +.. _AsyncShutdown.Barrier.state: + +Example 3: More sophisticated Barrier client +-------------------------------------------- + +The following snippet presents FooClient2, a more sophisticated client of FooService that needs to perform a number of operations during shutdown but before the shutdown of FooService. Also, given that this client is more sophisticated, we provide a function returning the state of FooClient2 during shutdown. If for some reason FooClient2's blocker is never lifted, this state can be reported as part of a crash report. + +.. code-block:: javascript + + // Some client of FooService called FooClient2 + + Components.utils.import("resource://gre/modules/FooService.jsm", this); + + FooService.shutdown.addBlocker( + "FooClient2: Collecting data, writing it to disk and shutting down", + () => Blocker.wait(), + () => Blocker.state + ); + + let Blocker = { + // This field contains information on the status of the blocker. + // It can be any JSON serializable object. + state: "Not started", + + wait: Task.async(function*() { + // This method is called once FooService starts informing its clients that + // FooService wishes to shut down. + + // Update the state as we go. If the Barrier is used in conjunction with + // a Phase, this state will be reported as part of a crash report if FooClient fails + // to shutdown properly. + this.state = "Starting"; + + let data = yield collectSomeData(); + this.state = "Data collection complete"; + + try { + yield writeSomeDataToDisk(data); + this.state = "Data successfully written to disk"; + } catch (ex) { + this.state = "Writing data to disk failed, proceeding with shutdown: " + ex; + } + + yield FooService.oneLastCall(); + this.state = "Ready"; + }.bind(this) + }; + + +Example 4: A service with both internal and external dependencies +----------------------------------------------------------------- + + .. code-block:: javascript + + // Module FooService2 + + Components.utils.import("resource://gre/modules/AsyncShutdown.jsm", this); + Components.utils.import("resource://gre/modules/Task.jsm", this); + Components.utils.import("resource://gre/modules/Promise.jsm", this); + + this.exports = ["FooService2"]; + + let shutdown = new AsyncShutdown.Barrier("FooService2: Waiting for clients before shutting down"); + + // Export the `client` capability, to let clients register shutdown blockers + FooService2.shutdown = shutdown.client; + + // A second barrier, used to avoid shutting down while any connections are open. + let connections = new AsyncShutdown.Barrier("FooService2: Waiting for all FooConnections to be closed before shutting down"); + + let isClosed = false; + + FooService2.openFooConnection = function(name) { + if (isClosed) { + throw new Error("FooService2 is closed"); + } + + let deferred = Promise.defer(); + connections.client.addBlocker("FooService2: Waiting for connection " + name + " to close", deferred.promise); + + // ... + + + return { + // ... + // Some FooConnection object. Presumably, it will have additional methods. + // ... + close: function() { + // ... + // Perform any operation necessary for closing + // ... + + // Don't hoard blockers. + connections.client.removeBlocker(deferred.promise); + + // The barrier MUST be lifted, even if removeBlocker has been called. + deferred.resolve(); + } + }; + }; + + + // This Task should be triggered at some point during shutdown, generally + // as a client to another Barrier. Triggering this Task is not covered + // in this snippet. + let onshutdown = Task.async(function*() { + // Wait for all registered clients to have lifted the barrier. + // These clients may open instances of FooConnection if they need to. + yield shutdown.wait(); + + // Now stop accepting any other connection request. + isClosed = true; + + // Wait for all instances of FooConnection to be closed. + yield connections.wait(); + + // Now finish shutting down FooService2 + // ... + }); + +.. _AsyncShutdown_phases: + +Phases: Expressing dependencies towards phases of shutdown +========================================================== + +The shutdown of a process takes place by phase, such as: + +- ``profileBeforeChange`` (once this phase is complete, there is no guarantee that the process has access to a profile directory); +- ``webWorkersShutdown`` (once this phase is complete, JavaScript does not have access to workers anymore); +- ... + +Much as services, phases have clients. For instance, all users of web workers MUST have finished using their web workers before the end of phase ``webWorkersShutdown``. + +Module ``AsyncShutdown`` provides pre-defined barriers for a set of +well-known phases. Each of the barriers provided blocks the corresponding shutdown +phase until all clients have lifted their blockers. + +List of phases +-------------- + +``AsyncShutdown.profileChangeTeardown`` + + The client capability for clients wishing to block asynchronously + during observer notification "profile-change-teardown". + + +``AsyncShutdown.profileBeforeChange`` + + The client capability for clients wishing to block asynchronously + during observer notification "profile-change-teardown". Once the + barrier is resolved, clients other than Telemetry MUST NOT access + files in the profile directory and clients MUST NOT use Telemetry + anymore. + +``AsyncShutdown.sendTelemetry`` + + The client capability for clients wishing to block asynchronously + during observer notification "profile-before-change-telemetry". + Once the barrier is resolved, Telemetry must stop its operations. + +``AsyncShutdown.webWorkersShutdown`` + + The client capability for clients wishing to block asynchronously + during observer notification "web-workers-shutdown". Once the phase + is complete, clients MUST NOT use web workers. diff --git a/toolkit/modules/docs/index.rst b/toolkit/modules/docs/index.rst new file mode 100644 index 0000000000..8e476c86a9 --- /dev/null +++ b/toolkit/modules/docs/index.rst @@ -0,0 +1,10 @@ +=============== +Toolkit modules +=============== + +The ``/toolkit/modules`` directory contains a number of self-contained toolkit modules considered small enough that they do not deserve individual directories. + +.. toctree:: + :maxdepth: 1 + + AsyncShutdown diff --git a/toolkit/modules/moz.build b/toolkit/modules/moz.build new file mode 100644 index 0000000000..9e08fe9f0d --- /dev/null +++ b/toolkit/modules/moz.build @@ -0,0 +1,157 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +XPCSHELL_TESTS_MANIFESTS += ['tests/xpcshell/xpcshell.ini'] +BROWSER_CHROME_MANIFESTS += ['tests/browser/browser.ini'] +MOCHITEST_MANIFESTS += ['tests/mochitest/mochitest.ini'] +MOCHITEST_CHROME_MANIFESTS += ['tests/chrome/chrome.ini'] + +TESTING_JS_MODULES += [ + 'tests/MockDocument.jsm', + 'tests/PromiseTestUtils.jsm', + 'tests/xpcshell/TestIntegration.jsm', +] + +SPHINX_TREES['toolkit_modules'] = 'docs' + +EXTRA_JS_MODULES += [ + 'addons/MatchPattern.jsm', + 'addons/WebNavigation.jsm', + 'addons/WebNavigationContent.js', + 'addons/WebNavigationFrames.jsm', + 'addons/WebRequest.jsm', + 'addons/WebRequestCommon.jsm', + 'addons/WebRequestContent.js', + 'addons/WebRequestUpload.jsm', + 'AsyncPrefs.jsm', + 'Battery.jsm', + 'BinarySearch.jsm', + 'BrowserUtils.jsm', + 'CanonicalJSON.jsm', + 'CertUtils.jsm', + 'CharsetMenu.jsm', + 'ClientID.jsm', + 'Color.jsm', + 'Console.jsm', + 'DateTimePickerHelper.jsm', + 'debug.js', + 'DeferredTask.jsm', + 'Deprecated.jsm', + 'FileUtils.jsm', + 'Finder.jsm', + 'FinderHighlighter.jsm', + 'FinderIterator.jsm', + 'FormLikeFactory.jsm', + 'Geometry.jsm', + 'GMPInstallManager.jsm', + 'GMPUtils.jsm', + 'Http.jsm', + 'InlineSpellChecker.jsm', + 'InlineSpellCheckerContent.jsm', + 'Integration.jsm', + 'JSONFile.jsm', + 'LoadContextInfo.jsm', + 'Locale.jsm', + 'Log.jsm', + 'Memory.jsm', + 'NewTabUtils.jsm', + 'NLP.jsm', + 'ObjectUtils.jsm', + 'PageMenu.jsm', + 'PageMetadata.jsm', + 'PermissionsUtils.jsm', + 'PopupNotifications.jsm', + 'Preferences.jsm', + 'PrivateBrowsingUtils.jsm', + 'ProfileAge.jsm', + 'Promise-backend.js', + 'Promise.jsm', + 'PromiseMessage.jsm', + 'PromiseUtils.jsm', + 'PropertyListUtils.jsm', + 'RemoteController.jsm', + 'RemoteFinder.jsm', + 'RemotePageManager.jsm', + 'RemoteSecurityUI.jsm', + 'RemoteWebProgress.jsm', + 'ResetProfile.jsm', + 'ResponsivenessMonitor.jsm', + 'secondscreen/PresentationApp.jsm', + 'secondscreen/RokuApp.jsm', + 'secondscreen/SimpleServiceDiscovery.jsm', + 'SelectContentHelper.jsm', + 'SelectParentHelper.jsm', + 'ServiceRequest.jsm', + 'Services.jsm', + 'SessionRecorder.jsm', + 'sessionstore/FormData.jsm', + 'sessionstore/ScrollPosition.jsm', + 'sessionstore/XPathGenerator.jsm', + 'ShortcutUtils.jsm', + 'Sntp.jsm', + 'SpatialNavigation.jsm', + 'Sqlite.jsm', + 'Task.jsm', + 'Timer.jsm', + 'Troubleshoot.jsm', + 'UpdateUtils.jsm', + 'WebChannel.jsm', + 'WindowDraggingUtils.jsm', + 'ZipUtils.jsm', +] +EXTRA_JS_MODULES.third_party.jsesc += ['third_party/jsesc/jsesc.js'] +EXTRA_JS_MODULES.sessionstore += ['sessionstore/Utils.jsm'] + +if CONFIG['MOZ_WIDGET_TOOLKIT'] in ('windows', 'cocoa'): + DEFINES['CAN_DRAW_IN_TITLEBAR'] = 1 + +if CONFIG['MOZ_WIDGET_TOOLKIT'] in ('windows', 'gtk2', 'gtk3'): + DEFINES['MENUBAR_CAN_AUTOHIDE'] = 1 + +if CONFIG['MOZ_WIDGET_TOOLKIT'] in ('windows', 'gtk2', 'gtk3', 'cocoa'): + DEFINES['HAVE_SHELL_SERVICE'] = 1 + +EXTRA_PP_JS_MODULES += [ + 'AppConstants.jsm', +] + +if 'Android' != CONFIG['OS_TARGET']: + EXTRA_JS_MODULES += [ + 'LightweightThemeConsumer.jsm', + ] + + DIRS += [ + 'subprocess', + ] +else: + DEFINES['ANDROID'] = True + + +if CONFIG['MOZ_WIDGET_TOOLKIT'] == 'windows': + EXTRA_JS_MODULES += [ + 'WindowsRegistry.jsm', + ] + +for var in ('ANDROID_PACKAGE_NAME', + 'MOZ_APP_NAME', + 'MOZ_APP_VERSION', + 'MOZ_APP_VERSION_DISPLAY', + 'MOZ_MACBUNDLE_NAME', + 'MOZ_WIDGET_TOOLKIT', + 'DLL_PREFIX', + 'DLL_SUFFIX', + 'DEBUG_JS_MODULES'): + DEFINES[var] = CONFIG[var] + +for var in ('MOZ_TOOLKIT_SEARCH', + 'MOZ_SYSTEM_NSS', + 'MOZ_UPDATER', + 'MOZ_SWITCHBOARD', + 'MOZ_SERVICES_CLOUDSYNC'): + if CONFIG[var]: + DEFINES[var] = True + +DEFINES['TOPOBJDIR'] = TOPOBJDIR diff --git a/toolkit/modules/secondscreen/PresentationApp.jsm b/toolkit/modules/secondscreen/PresentationApp.jsm new file mode 100644 index 0000000000..b7d8e05a87 --- /dev/null +++ b/toolkit/modules/secondscreen/PresentationApp.jsm @@ -0,0 +1,190 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +/* 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 = ["PresentationApp"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "sysInfo", () => { + return Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2); +}); + +const DEBUG = false; + +const STATE_UNINIT = "uninitialized" // RemoteMedia status +const STATE_STARTED = "started"; // RemoteMedia status +const STATE_PAUSED = "paused"; // RemoteMedia status +const STATE_SHUTDOWN = "shutdown"; // RemoteMedia status + +function debug(msg) { + Services.console.logStringMessage("PresentationApp: " + msg); +} + +// PresentationApp is a wrapper for interacting with a Presentation Receiver Device. +function PresentationApp(service, request) { + this.service = service; + this.request = request; +} + +PresentationApp.prototype = { + start: function start(callback) { + this.request.startWithDevice(this.service.uuid) + .then((session) => { + this._session = session; + if (callback) { + session.addEventListener('connect', () => { + callback(true); + }); + } + }, () => { + if (callback) { + callback(false); + } + }); + }, + + stop: function stop(callback) { + if (this._session && this._session.state === "connected") { + this._session.terminate(); + } + + delete this._session; + + if (callback) { + callback(true); + } + }, + + remoteMedia: function remoteMedia(callback, listener) { + if (callback) { + if (!this._session) { + callback(); + return; + } + + callback(new RemoteMedia(this._session, listener)); + } + } +} + +/* RemoteMedia provides a wrapper for using Presentation API to control Firefox TV app. + * The server implementation must be built into the Firefox TV receiver app. + * see https://github.com/mozilla-b2g/gaia/tree/master/tv_apps/fling-player + */ +function RemoteMedia(session, listener) { + this._session = session ; + this._listener = listener; + this._status = STATE_UNINIT; + + this._session.addEventListener("message", this); + this._session.addEventListener("terminate", this); + + if (this._listener && "onRemoteMediaStart" in this._listener) { + Services.tm.mainThread.dispatch((function() { + this._listener.onRemoteMediaStart(this); + }).bind(this), Ci.nsIThread.DISPATCH_NORMAL); + } +} + +RemoteMedia.prototype = { + _seq: 0, + + handleEvent: function(e) { + switch (e.type) { + case "message": + this._onmessage(e); + break; + case "terminate": + this._onterminate(e); + break; + } + }, + + _onmessage: function(e) { + DEBUG && debug("onmessage: " + e.data); + if (this.status === STATE_SHUTDOWN) { + return; + } + + if (e.data.indexOf("stopped") > -1) { + if (this.status !== STATE_PAUSED) { + this._status = STATE_PAUSED; + if (this._listener && "onRemoteMediaStatus" in this._listener) { + this._listener.onRemoteMediaStatus(this); + } + } + } else if (e.data.indexOf("playing") > -1) { + if (this.status !== STATE_STARTED) { + this._status = STATE_STARTED; + if (this._listener && "onRemoteMediaStatus" in this._listener) { + this._listener.onRemoteMediaStatus(this); + } + } + } + }, + + _onterminate: function(e) { + DEBUG && debug("onterminate: " + this._session.state); + this._status = STATE_SHUTDOWN; + if (this._listener && "onRemoteMediaStop" in this._listener) { + this._listener.onRemoteMediaStop(this); + } + }, + + _sendCommand: function(command, data) { + let msg = { + 'type': command, + 'seq': ++this._seq + }; + + if (data) { + for (var k in data) { + msg[k] = data[k]; + } + } + + let raw = JSON.stringify(msg); + DEBUG && debug("send command: " + raw); + + this._session.send(raw); + }, + + shutdown: function shutdown() { + DEBUG && debug("RemoteMedia - shutdown"); + this._sendCommand("close"); + }, + + play: function play() { + DEBUG && debug("RemoteMedia - play"); + this._sendCommand("play"); + }, + + pause: function pause() { + DEBUG && debug("RemoteMedia - pause"); + this._sendCommand("pause"); + }, + + load: function load(data) { + DEBUG && debug("RemoteMedia - load: " + data); + this._sendCommand("load", { "url": data.source }); + + let deviceName; + if (Services.appinfo.widgetToolkit == "android") { + deviceName = sysInfo.get("device"); + } else { + deviceName = sysInfo.get("host"); + } + this._sendCommand("device-info", { "displayName": deviceName }); + }, + + get status() { + return this._status; + } +} diff --git a/toolkit/modules/secondscreen/RokuApp.jsm b/toolkit/modules/secondscreen/RokuApp.jsm new file mode 100644 index 0000000000..b37a688cd6 --- /dev/null +++ b/toolkit/modules/secondscreen/RokuApp.jsm @@ -0,0 +1,230 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +/* 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 = ["RokuApp"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/AppConstants.jsm"); + +function log(msg) { + // Services.console.logStringMessage(msg); +} + +const PROTOCOL_VERSION = 1; + +/* RokuApp is a wrapper for interacting with a Roku channel. + * The basic interactions all use a REST API. + * spec: http://sdkdocs.roku.com/display/sdkdoc/External+Control+Guide + */ +function RokuApp(service) { + this.service = service; + this.resourceURL = this.service.location; + this.app = AppConstants.RELEASE_OR_BETA ? "Firefox" : "Firefox Nightly"; + this.mediaAppID = -1; +} + +RokuApp.prototype = { + status: function status(callback) { + // We have no way to know if the app is running, so just return "unknown" + // but we use this call to fetch the mediaAppID for the given app name + let url = this.resourceURL + "query/apps"; + let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest); + xhr.open("GET", url, true); + xhr.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + xhr.overrideMimeType("text/xml"); + + xhr.addEventListener("load", (function() { + if (xhr.status == 200) { + let doc = xhr.responseXML; + let apps = doc.querySelectorAll("app"); + for (let app of apps) { + if (app.textContent == this.app) { + this.mediaAppID = app.id; + } + } + } + + // Since ECP has no way of telling us if an app is running, we always return "unknown" + if (callback) { + callback({ state: "unknown" }); + } + }).bind(this), false); + + xhr.addEventListener("error", (function() { + if (callback) { + callback({ state: "unknown" }); + } + }).bind(this), false); + + xhr.send(null); + }, + + start: function start(callback) { + // We need to make sure we have cached the mediaAppID + if (this.mediaAppID == -1) { + this.status(function() { + // If we found the mediaAppID, use it to make a new start call + if (this.mediaAppID != -1) { + this.start(callback); + } else { + // We failed to start the app, so let the caller know + callback(false); + } + }.bind(this)); + return; + } + + // Start a given app with any extra query data. Each app uses it's own data scheme. + // NOTE: Roku will also pass "source=external-control" as a param + let url = this.resourceURL + "launch/" + this.mediaAppID + "?version=" + parseInt(PROTOCOL_VERSION); + let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest); + xhr.open("POST", url, true); + xhr.overrideMimeType("text/plain"); + + xhr.addEventListener("load", (function() { + if (callback) { + callback(xhr.status === 200); + } + }).bind(this), false); + + xhr.addEventListener("error", (function() { + if (callback) { + callback(false); + } + }).bind(this), false); + + xhr.send(null); + }, + + stop: function stop(callback) { + // Roku doesn't seem to support stopping an app, so let's just go back to + // the Home screen + let url = this.resourceURL + "keypress/Home"; + let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest); + xhr.open("POST", url, true); + xhr.overrideMimeType("text/plain"); + + xhr.addEventListener("load", (function() { + if (callback) { + callback(xhr.status === 200); + } + }).bind(this), false); + + xhr.addEventListener("error", (function() { + if (callback) { + callback(false); + } + }).bind(this), false); + + xhr.send(null); + }, + + remoteMedia: function remoteMedia(callback, listener) { + if (this.mediaAppID != -1) { + if (callback) { + callback(new RemoteMedia(this.resourceURL, listener)); + } + } else if (callback) { + callback(); + } + } +} + +/* RemoteMedia provides a wrapper for using TCP socket to control Roku apps. + * The server implementation must be built into the Roku receiver app. + */ +function RemoteMedia(url, listener) { + this._url = url; + this._listener = listener; + this._status = "uninitialized"; + + let serverURI = Services.io.newURI(this._url, null, null); + this._socket = Cc["@mozilla.org/network/socket-transport-service;1"].getService(Ci.nsISocketTransportService).createTransport(null, 0, serverURI.host, 9191, null); + this._outputStream = this._socket.openOutputStream(0, 0, 0); + + this._scriptableStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(Ci.nsIScriptableInputStream); + + this._inputStream = this._socket.openInputStream(0, 0, 0); + this._pump = Cc["@mozilla.org/network/input-stream-pump;1"].createInstance(Ci.nsIInputStreamPump); + this._pump.init(this._inputStream, -1, -1, 0, 0, true); + this._pump.asyncRead(this, null); +} + +RemoteMedia.prototype = { + onStartRequest: function(request, context) { + }, + + onDataAvailable: function(request, context, stream, offset, count) { + this._scriptableStream.init(stream); + let data = this._scriptableStream.read(count); + if (!data) { + return; + } + + let msg = JSON.parse(data); + if (this._status === msg._s) { + return; + } + + this._status = msg._s; + + if (this._listener) { + // Check to see if we are getting the initial "connected" message + if (this._status == "connected" && "onRemoteMediaStart" in this._listener) { + this._listener.onRemoteMediaStart(this); + } + + if ("onRemoteMediaStatus" in this._listener) { + this._listener.onRemoteMediaStatus(this); + } + } + }, + + onStopRequest: function(request, context, result) { + if (this._listener && "onRemoteMediaStop" in this._listener) + this._listener.onRemoteMediaStop(this); + }, + + _sendMsg: function _sendMsg(data) { + if (!data) + return; + + // Add the protocol version + data["_v"] = PROTOCOL_VERSION; + + let raw = JSON.stringify(data); + this._outputStream.write(raw, raw.length); + }, + + shutdown: function shutdown() { + this._outputStream.close(); + this._inputStream.close(); + }, + + get active() { + return (this._socket && this._socket.isAlive()); + }, + + play: function play() { + // TODO: add position support + this._sendMsg({ type: "PLAY" }); + }, + + pause: function pause() { + this._sendMsg({ type: "STOP" }); + }, + + load: function load(data) { + this._sendMsg({ type: "LOAD", title: data.title, source: data.source, poster: data.poster }); + }, + + get status() { + return this._status; + } +} diff --git a/toolkit/modules/secondscreen/SimpleServiceDiscovery.jsm b/toolkit/modules/secondscreen/SimpleServiceDiscovery.jsm new file mode 100644 index 0000000000..cf9617ea1e --- /dev/null +++ b/toolkit/modules/secondscreen/SimpleServiceDiscovery.jsm @@ -0,0 +1,435 @@ +// -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*- +/* 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 = ["SimpleServiceDiscovery"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); + +var log = Cu.reportError; + +XPCOMUtils.defineLazyGetter(this, "converter", function () { + let conv = Cc["@mozilla.org/intl/scriptableunicodeconverter"].createInstance(Ci.nsIScriptableUnicodeConverter); + conv.charset = "utf8"; + return conv; +}); + +// Spec information: +// https://tools.ietf.org/html/draft-cai-ssdp-v1-03 +// http://www.dial-multiscreen.org/dial-protocol-specification +const SSDP_PORT = 1900; +const SSDP_ADDRESS = "239.255.255.250"; + +const SSDP_DISCOVER_PACKET = + "M-SEARCH * HTTP/1.1\r\n" + + "HOST: " + SSDP_ADDRESS + ":" + SSDP_PORT + "\r\n" + + "MAN: \"ssdp:discover\"\r\n" + + "MX: 2\r\n" + + "ST: %SEARCH_TARGET%\r\n\r\n"; + +const SSDP_DISCOVER_ATTEMPTS = 3; +const SSDP_DISCOVER_DELAY = 500; +const SSDP_DISCOVER_TIMEOUT_MULTIPLIER = 2; +const SSDP_TRANSMISSION_INTERVAL = 1000; + +const EVENT_SERVICE_FOUND = "ssdp-service-found"; +const EVENT_SERVICE_LOST = "ssdp-service-lost"; + +/* + * SimpleServiceDiscovery manages any discovered SSDP services. It uses a UDP + * broadcast to locate available services on the local network. + */ +var SimpleServiceDiscovery = { + get EVENT_SERVICE_FOUND() { return EVENT_SERVICE_FOUND; }, + get EVENT_SERVICE_LOST() { return EVENT_SERVICE_LOST; }, + + _devices: new Map(), + _services: new Map(), + _searchSocket: null, + _searchInterval: 0, + _searchTimestamp: 0, + _searchTimeout: Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer), + _searchRepeat: Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer), + _discoveryMethods: [], + + _forceTrailingSlash: function(aURL) { + // Cleanup the URL to make it consistent across devices + try { + aURL = Services.io.newURI(aURL, null, null).spec; + } catch (e) {} + return aURL; + }, + + // nsIUDPSocketListener implementation + onPacketReceived: function(aSocket, aMessage) { + // Listen for responses from specific devices. There could be more than one + // available. + let response = aMessage.data.split("\n"); + let service = {}; + response.forEach(function(row) { + let name = row.toUpperCase(); + if (name.startsWith("LOCATION")) { + service.location = row.substr(10).trim(); + } else if (name.startsWith("ST")) { + service.target = row.substr(4).trim(); + } + }.bind(this)); + + if (service.location && service.target) { + service.location = this._forceTrailingSlash(service.location); + + // When we find a valid response, package up the service information + // and pass it on. + try { + this._processService(service); + } catch (e) {} + } + }, + + onStopListening: function(aSocket, aStatus) { + // This is fired when the socket is closed expectedly or unexpectedly. + // nsITimer.cancel() is a no-op if the timer is not active. + this._searchTimeout.cancel(); + this._searchSocket = null; + }, + + // Start a search. Make it continuous by passing an interval (in milliseconds). + // This will stop a current search loop because the timer resets itself. + // Returns the existing search interval. + search: function search(aInterval) { + let existingSearchInterval = this._searchInterval; + if (aInterval > 0) { + this._searchInterval = aInterval || 0; + this._searchRepeat.initWithCallback(this._search.bind(this), this._searchInterval, Ci.nsITimer.TYPE_REPEATING_SLACK); + } + this._search(); + return existingSearchInterval; + }, + + // Stop the current continuous search + stopSearch: function stopSearch() { + this._searchRepeat.cancel(); + }, + + _usingLAN: function() { + let network = Cc["@mozilla.org/network/network-link-service;1"].getService(Ci.nsINetworkLinkService); + return (network.linkType == Ci.nsINetworkLinkService.LINK_TYPE_WIFI || + network.linkType == Ci.nsINetworkLinkService.LINK_TYPE_ETHERNET || + network.linkType == Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN); + }, + + _search: function _search() { + // If a search is already active, shut it down. + this._searchShutdown(); + + // We only search if on local network + if (!this._usingLAN()) { + return; + } + + // Update the timestamp so we can use it to clean out stale services the + // next time we search. + this._searchTimestamp = Date.now(); + + // Look for any fixed IP devices. Some routers might be configured to block + // UDP broadcasts, so this is a way to skip discovery. + this._searchFixedDevices(); + + // Look for any devices via registered external discovery mechanism. + this._startExternalDiscovery(); + + // Perform a UDP broadcast to search for SSDP devices + let socket = Cc["@mozilla.org/network/udp-socket;1"].createInstance(Ci.nsIUDPSocket); + try { + socket.init(SSDP_PORT, false, Services.scriptSecurityManager.getSystemPrincipal()); + socket.joinMulticast(SSDP_ADDRESS); + socket.asyncListen(this); + } catch (e) { + // We were unable to create the broadcast socket. Just return, but don't + // kill the interval timer. This might work next time. + log("failed to start socket: " + e); + return; + } + + // Make the timeout SSDP_DISCOVER_TIMEOUT_MULTIPLIER times as long as the time needed to send out the discovery packets. + const SSDP_DISCOVER_TIMEOUT = this._devices.size * SSDP_DISCOVER_ATTEMPTS * SSDP_TRANSMISSION_INTERVAL * SSDP_DISCOVER_TIMEOUT_MULTIPLIER; + this._searchSocket = socket; + this._searchTimeout.initWithCallback(this._searchShutdown.bind(this), SSDP_DISCOVER_TIMEOUT, Ci.nsITimer.TYPE_ONE_SHOT); + + let data = SSDP_DISCOVER_PACKET; + + // Send discovery packets out at 1 per SSDP_TRANSMISSION_INTERVAL and send each SSDP_DISCOVER_ATTEMPTS times + // to allow for packet loss on noisy networks. + let timeout = SSDP_DISCOVER_DELAY; + for (let attempts = 0; attempts < SSDP_DISCOVER_ATTEMPTS; attempts++) { + for (let [key, device] of this._devices) { + let target = device.target; + setTimeout(function() { + let msgData = data.replace("%SEARCH_TARGET%", target); + try { + let msgRaw = converter.convertToByteArray(msgData); + socket.send(SSDP_ADDRESS, SSDP_PORT, msgRaw, msgRaw.length); + } catch (e) { + log("failed to convert to byte array: " + e); + } + }, timeout); + timeout += SSDP_TRANSMISSION_INTERVAL; + } + } + }, + + _searchFixedDevices: function _searchFixedDevices() { + let fixedDevices = null; + try { + fixedDevices = Services.prefs.getCharPref("browser.casting.fixedDevices"); + } catch (e) {} + + if (!fixedDevices) { + return; + } + + fixedDevices = JSON.parse(fixedDevices); + for (let fixedDevice of fixedDevices) { + // Verify we have the right data + if (!("location" in fixedDevice) || !("target" in fixedDevice)) { + continue; + } + + fixedDevice.location = this._forceTrailingSlash(fixedDevice.location); + + let service = { + location: fixedDevice.location, + target: fixedDevice.target + }; + + // We don't assume the fixed target is ready. We still need to ping it. + try { + this._processService(service); + } catch (e) {} + } + }, + + // Called when the search timeout is hit. We use it to cleanup the socket and + // perform some post-processing on the services list. + _searchShutdown: function _searchShutdown() { + if (this._searchSocket) { + // This will call onStopListening. + this._searchSocket.close(); + + // Clean out any stale services + for (let [key, service] of this._services) { + if (service.lastPing != this._searchTimestamp) { + this.removeService(service.uuid); + } + } + } + + this._stopExternalDiscovery(); + }, + + getSupportedExtensions: function() { + let extensions = []; + this.services.forEach(function(service) { + extensions = extensions.concat(service.extensions); + }, this); + return extensions.filter(function(extension, pos) { + return extensions.indexOf(extension) == pos; + }); + }, + + getSupportedMimeTypes: function() { + let types = []; + this.services.forEach(function(service) { + types = types.concat(service.types); + }, this); + return types.filter(function(type, pos) { + return types.indexOf(type) == pos; + }); + }, + + registerDevice: function registerDevice(aDevice) { + // We must have "id", "target" and "factory" defined + if (!("id" in aDevice) || !("target" in aDevice) || !("factory" in aDevice)) { + // Fatal for registration + throw "Registration requires an id, a target and a location"; + } + + // Only add if we don't already know about this device + if (!this._devices.has(aDevice.id)) { + this._devices.set(aDevice.id, aDevice); + } else { + log("device was already registered: " + aDevice.id); + } + }, + + unregisterDevice: function unregisterDevice(aDevice) { + // We must have "id", "target" and "factory" defined + if (!("id" in aDevice) || !("target" in aDevice) || !("factory" in aDevice)) { + return; + } + + // Only remove if we know about this device + if (this._devices.has(aDevice.id)) { + this._devices.delete(aDevice.id); + } else { + log("device was not registered: " + aDevice.id); + } + }, + + findAppForService: function findAppForService(aService) { + if (!aService || !aService.deviceID) { + return null; + } + + // Find the registration for the device + if (this._devices.has(aService.deviceID)) { + return this._devices.get(aService.deviceID).factory(aService); + } + return null; + }, + + findServiceForID: function findServiceForID(aUUID) { + if (this._services.has(aUUID)) { + return this._services.get(aUUID); + } + return null; + }, + + // Returns an array copy of the active services + get services() { + let array = []; + for (let [key, service] of this._services) { + let target = this._devices.get(service.deviceID); + service.extensions = target.extensions; + service.types = target.types; + array.push(service); + } + return array; + }, + + // Returns false if the service does not match the device's filters + _filterService: function _filterService(aService) { + // Loop over all the devices, looking for one that matches the service + for (let [key, device] of this._devices) { + // First level of match is on the target itself + if (device.target != aService.target) { + continue; + } + + // If we have no filter, everything passes + if (!("filters" in device)) { + aService.deviceID = device.id; + return true; + } + + // If all the filters pass, we have a match + let failed = false; + let filters = device.filters; + for (let filter in filters) { + if (filter in aService && aService[filter] != filters[filter]) { + failed = true; + } + } + + // We found a match, so link the service to the device + if (!failed) { + aService.deviceID = device.id; + return true; + } + } + + // We didn't find any matches + return false; + }, + + _processService: function _processService(aService) { + // Use the REST api to request more information about this service + let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest); + xhr.open("GET", aService.location, true); + xhr.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + xhr.overrideMimeType("text/xml"); + + xhr.addEventListener("load", (function() { + if (xhr.status == 200) { + let doc = xhr.responseXML; + aService.appsURL = xhr.getResponseHeader("Application-URL"); + if (aService.appsURL && !aService.appsURL.endsWith("/")) + aService.appsURL += "/"; + aService.friendlyName = doc.querySelector("friendlyName").textContent; + aService.uuid = doc.querySelector("UDN").textContent; + aService.manufacturer = doc.querySelector("manufacturer").textContent; + aService.modelName = doc.querySelector("modelName").textContent; + + this.addService(aService); + } + }).bind(this), false); + + xhr.send(null); + }, + + // Add a service to the WeakMap, even if one already exists with this id. + // Returns true if this succeeded or false if it failed + _addService: function(service) { + // Filter out services that do not match the device filter + if (!this._filterService(service)) { + return false; + } + + let device = this._devices.get(service.target); + if (device && device.mirror) { + service.mirror = true; + } + this._services.set(service.uuid, service); + return true; + }, + + addService: function(service) { + // Only add and notify if we don't already know about this service + if (!this._services.has(service.uuid)) { + if (!this._addService(service)) { + return; + } + Services.obs.notifyObservers(null, EVENT_SERVICE_FOUND, service.uuid); + } + + // Make sure we remember this service is not stale + this._services.get(service.uuid).lastPing = this._searchTimestamp; + }, + + removeService: function(uuid) { + Services.obs.notifyObservers(null, EVENT_SERVICE_LOST, uuid); + this._services.delete(uuid); + }, + + updateService: function(service) { + if (!this._addService(service)) { + return; + } + + // Make sure we remember this service is not stale + this._services.get(service.uuid).lastPing = this._searchTimestamp; + }, + + addExternalDiscovery: function(discovery) { + this._discoveryMethods.push(discovery); + }, + + _startExternalDiscovery: function() { + for (let discovery of this._discoveryMethods) { + discovery.startDiscovery(); + } + }, + + _stopExternalDiscovery: function() { + for (let discovery of this._discoveryMethods) { + discovery.stopDiscovery(); + } + }, +} diff --git a/toolkit/modules/sessionstore/FormData.jsm b/toolkit/modules/sessionstore/FormData.jsm new file mode 100644 index 0000000000..f90ba5825e --- /dev/null +++ b/toolkit/modules/sessionstore/FormData.jsm @@ -0,0 +1,412 @@ +/* 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 = ["FormData"]; + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/XPathGenerator.jsm"); + +/** + * Returns whether the given URL very likely has input + * fields that contain serialized session store data. + */ +function isRestorationPage(url) { + return url == "about:sessionrestore" || url == "about:welcomeback"; +} + +/** + * Returns whether the given form |data| object contains nested restoration + * data for a page like about:sessionrestore or about:welcomeback. + */ +function hasRestorationData(data) { + if (isRestorationPage(data.url) && data.id) { + return typeof(data.id.sessionData) == "object"; + } + + return false; +} + +/** + * Returns the given document's current URI and strips + * off the URI's anchor part, if any. + */ +function getDocumentURI(doc) { + return doc.documentURI.replace(/#.*$/, ""); +} + +/** + * Returns whether the given value is a valid credit card number based on + * the Luhn algorithm. See https://en.wikipedia.org/wiki/Luhn_algorithm. + */ +function isValidCCNumber(value) { + // Remove dashes and whitespace. + let ccNumber = value.replace(/[-\s]+/g, ""); + + // Check for non-alphanumeric characters. + if (/[^0-9]/.test(ccNumber)) { + return false; + } + + // Check for invalid length. + let length = ccNumber.length; + if (length != 9 && length != 15 && length != 16) { + return false; + } + + let total = 0; + for (let i = 0; i < length; i++) { + let currentChar = ccNumber.charAt(length - i - 1); + let currentDigit = parseInt(currentChar, 10); + + if (i % 2) { + // Double every other value. + total += currentDigit * 2; + // If the doubled value has two digits, add the digits together. + if (currentDigit > 4) { + total -= 9; + } + } else { + total += currentDigit; + } + } + return total % 10 == 0; +} + +/** + * The public API exported by this module that allows to collect + * and restore form data for a document and its subframes. + */ +this.FormData = Object.freeze({ + collect: function (frame) { + return FormDataInternal.collect(frame); + }, + + restoreTree: function (root, data) { + FormDataInternal.restoreTree(root, data); + } +}); + +/** + * This module's internal API. + */ +var FormDataInternal = { + /** + * Collect form data for a given |frame| *not* including any subframes. + * + * The returned object may have an "id", "xpath", or "innerHTML" key or a + * combination of those three. Form data stored under "id" is for input + * fields with id attributes. Data stored under "xpath" is used for input + * fields that don't have a unique id and need to be queried using XPath. + * The "innerHTML" key is used for editable documents (designMode=on). + * + * Example: + * { + * id: {input1: "value1", input3: "value3"}, + * xpath: { + * "/xhtml:html/xhtml:body/xhtml:input[@name='input2']" : "value2", + * "/xhtml:html/xhtml:body/xhtml:input[@name='input4']" : "value4" + * } + * } + * + * @param doc + * DOMDocument instance to obtain form data for. + * @return object + * Form data encoded in an object. + */ + collect: function ({document: doc}) { + let formNodes = doc.evaluate( + XPathGenerator.restorableFormNodes, + doc, + XPathGenerator.resolveNS, + Ci.nsIDOMXPathResult.UNORDERED_NODE_ITERATOR_TYPE, null + ); + + let node; + let ret = {}; + + // Limit the number of XPath expressions for performance reasons. See + // bug 477564. + const MAX_TRAVERSED_XPATHS = 100; + let generatedCount = 0; + + while ((node = formNodes.iterateNext())) { + let hasDefaultValue = true; + let value; + + // Only generate a limited number of XPath expressions for perf reasons + // (cf. bug 477564) + if (!node.id && generatedCount > MAX_TRAVERSED_XPATHS) { + continue; + } + + // We do not want to collect credit card numbers. + if (node instanceof Ci.nsIDOMHTMLInputElement && + isValidCCNumber(node.value)) { + continue; + } + + if (node instanceof Ci.nsIDOMHTMLInputElement || + node instanceof Ci.nsIDOMHTMLTextAreaElement || + node instanceof Ci.nsIDOMXULTextBoxElement) { + switch (node.type) { + case "checkbox": + case "radio": + value = node.checked; + hasDefaultValue = value == node.defaultChecked; + break; + case "file": + value = { type: "file", fileList: node.mozGetFileNameArray() }; + hasDefaultValue = !value.fileList.length; + break; + default: // text, textarea + value = node.value; + hasDefaultValue = value == node.defaultValue; + break; + } + } else if (!node.multiple) { + // <select>s without the multiple attribute are hard to determine the + // default value, so assume we don't have the default. + hasDefaultValue = false; + value = { selectedIndex: node.selectedIndex, value: node.value }; + } else { + // <select>s with the multiple attribute are easier to determine the + // default value since each <option> has a defaultSelected property + let options = Array.map(node.options, opt => { + hasDefaultValue = hasDefaultValue && (opt.selected == opt.defaultSelected); + return opt.selected ? opt.value : -1; + }); + value = options.filter(ix => ix > -1); + } + + // In order to reduce XPath generation (which is slow), we only save data + // for form fields that have been changed. (cf. bug 537289) + if (hasDefaultValue) { + continue; + } + + if (node.id) { + ret.id = ret.id || {}; + ret.id[node.id] = value; + } else { + generatedCount++; + ret.xpath = ret.xpath || {}; + ret.xpath[XPathGenerator.generate(node)] = value; + } + } + + // designMode is undefined e.g. for XUL documents (as about:config) + if ((doc.designMode || "") == "on" && doc.body) { + ret.innerHTML = doc.body.innerHTML; + } + + // Return |null| if no form data has been found. + if (Object.keys(ret).length === 0) { + return null; + } + + // Store the frame's current URL with its form data so that we can compare + // it when restoring data to not inject form data into the wrong document. + ret.url = getDocumentURI(doc); + + // We want to avoid saving data for about:sessionrestore as a string. + // Since it's stored in the form as stringified JSON, stringifying further + // causes an explosion of escape characters. cf. bug 467409 + if (isRestorationPage(ret.url)) { + ret.id.sessionData = JSON.parse(ret.id.sessionData); + } + + return ret; + }, + + /** + * Restores form |data| for the given frame. The data is expected to be in + * the same format that FormData.collect() returns. + * + * @param frame (DOMWindow) + * The frame to restore form data to. + * @param data (object) + * An object holding form data. + */ + restore: function ({document: doc}, data) { + // Don't restore any data for the given frame if the URL + // stored in the form data doesn't match its current URL. + if (!data.url || data.url != getDocumentURI(doc)) { + return; + } + + // For about:{sessionrestore,welcomeback} we saved the field as JSON to + // avoid nested instances causing humongous sessionstore.js files. + // cf. bug 467409 + if (hasRestorationData(data)) { + data.id.sessionData = JSON.stringify(data.id.sessionData); + } + + if ("id" in data) { + let retrieveNode = id => doc.getElementById(id); + this.restoreManyInputValues(data.id, retrieveNode); + } + + if ("xpath" in data) { + let retrieveNode = xpath => XPathGenerator.resolve(doc, xpath); + this.restoreManyInputValues(data.xpath, retrieveNode); + } + + if ("innerHTML" in data) { + if (doc.body && doc.designMode == "on") { + doc.body.innerHTML = data.innerHTML; + this.fireEvent(doc.body, "input"); + } + } + }, + + /** + * Iterates the given form data, retrieving nodes for all the keys and + * restores their appropriate values. + * + * @param data (object) + * A subset of the form data as collected by FormData.collect(). This + * is either data stored under "id" or under "xpath". + * @param retrieve (function) + * The function used to retrieve the input field belonging to a key + * in the given |data| object. + */ + restoreManyInputValues: function (data, retrieve) { + for (let key of Object.keys(data)) { + let input = retrieve(key); + if (input) { + this.restoreSingleInputValue(input, data[key]); + } + } + }, + + /** + * Restores a given form value to a given DOMNode and takes care of firing + * the appropriate DOM event should the input's value change. + * + * @param aNode + * DOMNode to set form value on. + * @param aValue + * Value to set form element to. + */ + restoreSingleInputValue: function (aNode, aValue) { + let eventType; + + if (typeof aValue == "string" && aNode.type != "file") { + // Don't dispatch an input event if there is no change. + if (aNode.value == aValue) { + return; + } + + aNode.value = aValue; + eventType = "input"; + } else if (typeof aValue == "boolean") { + // Don't dispatch a change event for no change. + if (aNode.checked == aValue) { + return; + } + + aNode.checked = aValue; + eventType = "change"; + } else if (aValue && aValue.selectedIndex >= 0 && aValue.value) { + // Don't dispatch a change event for no change + if (aNode.options[aNode.selectedIndex].value == aValue.value) { + return; + } + + // find first option with matching aValue if possible + for (let i = 0; i < aNode.options.length; i++) { + if (aNode.options[i].value == aValue.value) { + aNode.selectedIndex = i; + eventType = "change"; + break; + } + } + } else if (aValue && aValue.fileList && aValue.type == "file" && + aNode.type == "file") { + try { + // FIXME (bug 1122855): This won't work in content processes. + aNode.mozSetFileNameArray(aValue.fileList, aValue.fileList.length); + } catch (e) { + Cu.reportError("mozSetFileNameArray: " + e); + } + eventType = "input"; + } else if (Array.isArray(aValue) && aNode.options) { + Array.forEach(aNode.options, function(opt, index) { + // don't worry about malformed options with same values + opt.selected = aValue.indexOf(opt.value) > -1; + + // Only fire the event here if this wasn't selected by default + if (!opt.defaultSelected) { + eventType = "change"; + } + }); + } + + // Fire events for this node if applicable + if (eventType) { + this.fireEvent(aNode, eventType); + } + }, + + /** + * Dispatches an event of type |type| to the given |node|. + * + * @param node (DOMNode) + * @param type (string) + */ + fireEvent: function (node, type) { + let doc = node.ownerDocument; + let event = doc.createEvent("UIEvents"); + event.initUIEvent(type, true, true, doc.defaultView, 0); + node.dispatchEvent(event); + }, + + /** + * Restores form data for the current frame hierarchy starting at |root| + * using the given form |data|. + * + * If the given |root| frame's hierarchy doesn't match that of the given + * |data| object we will silently discard data for unreachable frames. For + * security reasons we will never restore form data to the wrong frames as + * we bail out silently if the stored URL doesn't match the frame's current + * URL. + * + * @param root (DOMWindow) + * @param data (object) + * { + * formdata: {id: {input1: "value1"}}, + * children: [ + * {formdata: {id: {input2: "value2"}}}, + * null, + * {formdata: {xpath: { ... }}, children: [ ... ]} + * ] + * } + */ + restoreTree: function (root, data) { + // Don't restore any data for the root frame and its subframes if there + // is a URL stored in the form data and it doesn't match its current URL. + if (data.url && data.url != getDocumentURI(root.document)) { + return; + } + + if (data.url) { + this.restore(root, data); + } + + if (!data.hasOwnProperty("children")) { + return; + } + + let frames = root.frames; + for (let index of Object.keys(data.children)) { + if (index < frames.length) { + this.restoreTree(frames[index], data.children[index]); + } + } + } +}; diff --git a/toolkit/modules/sessionstore/ScrollPosition.jsm b/toolkit/modules/sessionstore/ScrollPosition.jsm new file mode 100644 index 0000000000..5267f332a0 --- /dev/null +++ b/toolkit/modules/sessionstore/ScrollPosition.jsm @@ -0,0 +1,103 @@ +/* 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 = ["ScrollPosition"]; + +const Ci = Components.interfaces; + +/** + * It provides methods to collect scroll positions from single frames and to + * restore scroll positions for frame trees. + * + * This is a child process module. + */ +this.ScrollPosition = Object.freeze({ + collect(frame) { + return ScrollPositionInternal.collect(frame); + }, + + restoreTree(root, data) { + ScrollPositionInternal.restoreTree(root, data); + } +}); + +/** + * This module's internal API. + */ +var ScrollPositionInternal = { + /** + * Collects scroll position data for any given |frame| in the frame hierarchy. + * + * @param frame (DOMWindow) + * + * @return {scroll: "x,y"} e.g. {scroll: "100,200"} + * Returns null when there is no scroll data we want to store for the + * given |frame|. + */ + collect: function (frame) { + let ifreq = frame.QueryInterface(Ci.nsIInterfaceRequestor); + let utils = ifreq.getInterface(Ci.nsIDOMWindowUtils); + let scrollX = {}, scrollY = {}; + utils.getScrollXY(false /* no layout flush */, scrollX, scrollY); + + if (scrollX.value || scrollY.value) { + return {scroll: scrollX.value + "," + scrollY.value}; + } + + return null; + }, + + /** + * Restores scroll position data for any given |frame| in the frame hierarchy. + * + * @param frame (DOMWindow) + * @param value (object, see collect()) + */ + restore: function (frame, value) { + let match; + + if (value && (match = /(\d+),(\d+)/.exec(value))) { + frame.scrollTo(match[1], match[2]); + } + }, + + /** + * Restores scroll position data for the current frame hierarchy starting at + * |root| using the given scroll position |data|. + * + * If the given |root| frame's hierarchy doesn't match that of the given + * |data| object we will silently discard data for unreachable frames. We + * may as well assign scroll positions to the wrong frames if some were + * reordered or removed. + * + * @param root (DOMWindow) + * @param data (object) + * { + * scroll: "100,200", + * children: [ + * {scroll: "100,200"}, + * null, + * {scroll: "200,300", children: [ ... ]} + * ] + * } + */ + restoreTree: function (root, data) { + if (data.hasOwnProperty("scroll")) { + this.restore(root, data.scroll); + } + + if (!data.hasOwnProperty("children")) { + return; + } + + let frames = root.frames; + data.children.forEach((child, index) => { + if (child && index < frames.length) { + this.restoreTree(frames[index], child); + } + }); + } +}; diff --git a/toolkit/modules/sessionstore/Utils.jsm b/toolkit/modules/sessionstore/Utils.jsm new file mode 100644 index 0000000000..863bca6f51 --- /dev/null +++ b/toolkit/modules/sessionstore/Utils.jsm @@ -0,0 +1,107 @@ +/* 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 = ["Utils"]; + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyServiceGetter(this, "serializationHelper", + "@mozilla.org/network/serialization-helper;1", + "nsISerializationHelper"); + +function debug(msg) { + Services.console.logStringMessage("Utils: " + msg); +} + +this.Utils = Object.freeze({ + makeURI: function (url) { + return Services.io.newURI(url, null, null); + }, + + makeInputStream: function (aString) { + let stream = Cc["@mozilla.org/io/string-input-stream;1"]. + createInstance(Ci.nsISupportsCString); + stream.data = aString; + return stream; // XPConnect will QI this to nsIInputStream for us. + }, + + /** + * Returns true if the |url| passed in is part of the given root |domain|. + * For example, if |url| is "www.mozilla.org", and we pass in |domain| as + * "mozilla.org", this will return true. It would return false the other way + * around. + */ + hasRootDomain: function (url, domain) { + let host; + + try { + host = this.makeURI(url).host; + } catch (e) { + // The given URL probably doesn't have a host. + return false; + } + + let index = host.indexOf(domain); + if (index == -1) + return false; + + if (host == domain) + return true; + + let prevChar = host[index - 1]; + return (index == (host.length - domain.length)) && + (prevChar == "." || prevChar == "/"); + }, + + shallowCopy: function (obj) { + let retval = {}; + + for (let key of Object.keys(obj)) { + retval[key] = obj[key]; + } + + return retval; + }, + + /** + * Serialize principal data. + * + * @param {nsIPrincipal} principal The principal to serialize. + * @return {String} The base64 encoded principal data. + */ + serializePrincipal(principal) { + if (!principal) + return null; + + return serializationHelper.serializeToString(principal); + }, + + /** + * Deserialize a base64 encoded principal (serialized with + * Utils::serializePrincipal). + * + * @param {String} principal_b64 A base64 encoded serialized principal. + * @return {nsIPrincipal} A deserialized principal. + */ + deserializePrincipal(principal_b64) { + if (!principal_b64) + return null; + + try { + let principal = serializationHelper.deserializeObject(principal_b64); + principal.QueryInterface(Ci.nsIPrincipal); + return principal; + } catch (e) { + debug(`Failed to deserialize principal_b64 '${principal_b64}' ${e}`); + } + return null; + } +}); diff --git a/toolkit/modules/sessionstore/XPathGenerator.jsm b/toolkit/modules/sessionstore/XPathGenerator.jsm new file mode 100644 index 0000000000..33f397cdf3 --- /dev/null +++ b/toolkit/modules/sessionstore/XPathGenerator.jsm @@ -0,0 +1,119 @@ +/* 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 = ["XPathGenerator"]; + +this.XPathGenerator = { + // these two hashes should be kept in sync + namespaceURIs: { + "xhtml": "http://www.w3.org/1999/xhtml", + "xul": "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + }, + namespacePrefixes: { + "http://www.w3.org/1999/xhtml": "xhtml", + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul": "xul" + }, + + /** + * Generates an approximate XPath query to an (X)HTML node + */ + generate: function sss_xph_generate(aNode) { + // have we reached the document node already? + if (!aNode.parentNode) + return ""; + + // Access localName, namespaceURI just once per node since it's expensive. + let nNamespaceURI = aNode.namespaceURI; + let nLocalName = aNode.localName; + + let prefix = this.namespacePrefixes[nNamespaceURI] || null; + let tag = (prefix ? prefix + ":" : "") + this.escapeName(nLocalName); + + // stop once we've found a tag with an ID + if (aNode.id) + return "//" + tag + "[@id=" + this.quoteArgument(aNode.id) + "]"; + + // count the number of previous sibling nodes of the same tag + // (and possible also the same name) + let count = 0; + let nName = aNode.name || null; + for (let n = aNode; (n = n.previousSibling); ) + if (n.localName == nLocalName && n.namespaceURI == nNamespaceURI && + (!nName || n.name == nName)) + count++; + + // recurse until hitting either the document node or an ID'd node + return this.generate(aNode.parentNode) + "/" + tag + + (nName ? "[@name=" + this.quoteArgument(nName) + "]" : "") + + (count ? "[" + (count + 1) + "]" : ""); + }, + + /** + * Resolves an XPath query generated by XPathGenerator.generate + */ + resolve: function sss_xph_resolve(aDocument, aQuery) { + let xptype = Components.interfaces.nsIDOMXPathResult.FIRST_ORDERED_NODE_TYPE; + return aDocument.evaluate(aQuery, aDocument, this.resolveNS, xptype, null).singleNodeValue; + }, + + /** + * Namespace resolver for the above XPath resolver + */ + resolveNS: function sss_xph_resolveNS(aPrefix) { + return XPathGenerator.namespaceURIs[aPrefix] || null; + }, + + /** + * @returns valid XPath for the given node (usually just the local name itself) + */ + escapeName: function sss_xph_escapeName(aName) { + // we can't just use the node's local name, if it contains + // special characters (cf. bug 485482) + return /^\w+$/.test(aName) ? aName : + "*[local-name()=" + this.quoteArgument(aName) + "]"; + }, + + /** + * @returns a properly quoted string to insert into an XPath query + */ + quoteArgument: function sss_xph_quoteArgument(aArg) { + if (!/'/.test(aArg)) + return "'" + aArg + "'"; + if (!/"/.test(aArg)) + return '"' + aArg + '"'; + return "concat('" + aArg.replace(/'+/g, "',\"$&\",'") + "')"; + }, + + /** + * @returns an XPath query to all savable form field nodes + */ + get restorableFormNodes() { + // for a comprehensive list of all available <INPUT> types see + // https://dxr.mozilla.org/mozilla-central/search?q=kInputTypeTable&redirect=false + let ignoreInputs = new Map([ + ["type", ["password", "hidden", "button", "image", "submit", "reset"]], + ["autocomplete", ["off"]] + ]); + // XXXzeniko work-around until lower-case has been implemented (bug 398389) + let toLowerCase = '"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"'; + let ignores = []; + for (let [attrName, attrValues] of ignoreInputs) { + for (let attrValue of attrValues) + ignores.push(`translate(@${attrName}, ${toLowerCase})='${attrValue}'`); + } + let ignore = `not(${ignores.join(" or ")})`; + + let formNodesXPath = `//textarea[${ignore}]|//xhtml:textarea[${ignore}]|` + + `//select[${ignore}]|//xhtml:select[${ignore}]|` + + `//input[${ignore}]|//xhtml:input[${ignore}]`; + + // Special case for about:config's search field. + formNodesXPath += '|/xul:window[@id="config"]//xul:textbox[@id="textbox"]'; + + delete this.restorableFormNodes; + return (this.restorableFormNodes = formNodesXPath); + } +}; diff --git a/toolkit/modules/subprocess/.eslintrc.js b/toolkit/modules/subprocess/.eslintrc.js new file mode 100644 index 0000000000..fd3de6ad27 --- /dev/null +++ b/toolkit/modules/subprocess/.eslintrc.js @@ -0,0 +1,28 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": "../../components/extensions/.eslintrc.js", + + "env": { + "worker": true, + }, + + "globals": { + "ChromeWorker": false, + "Components": false, + "LIBC": true, + "Library": true, + "OS": false, + "Services": false, + "SubprocessConstants": true, + "ctypes": false, + "debug": true, + "dump": false, + "libc": true, + "unix": true, + }, + + "rules": { + "no-console": "off", + }, +}; diff --git a/toolkit/modules/subprocess/Subprocess.jsm b/toolkit/modules/subprocess/Subprocess.jsm new file mode 100644 index 0000000000..6d0d27d777 --- /dev/null +++ b/toolkit/modules/subprocess/Subprocess.jsm @@ -0,0 +1,163 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +/* + * These modules are loosely based on the subprocess.jsm module created + * by Jan Gerber and Patrick Brunschwig, though the implementation + * differs drastically. + */ + +"use strict"; + +let EXPORTED_SYMBOLS = ["Subprocess"]; + +/* exported Subprocess */ + +var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/subprocess/subprocess_common.jsm"); + +if (AppConstants.platform == "win") { + XPCOMUtils.defineLazyModuleGetter(this, "SubprocessImpl", + "resource://gre/modules/subprocess/subprocess_win.jsm"); +} else { + XPCOMUtils.defineLazyModuleGetter(this, "SubprocessImpl", + "resource://gre/modules/subprocess/subprocess_unix.jsm"); +} + +/** + * Allows for creation of and communication with OS-level sub-processes. + * @namespace + */ +var Subprocess = { + /** + * Launches a process, and returns a handle to it. + * + * @param {object} options + * An object describing the process to launch. + * + * @param {string} options.command + * The full path of the execuable to launch. Relative paths are not + * accepted, and `$PATH` is not searched. + * + * If a path search is necessary, the {@link Subprocess.pathSearch} method may + * be used to map a bare executable name to a full path. + * + * @param {string[]} [options.arguments] + * A list of strings to pass as arguments to the process. + * + * @param {object} [options.environment] + * An object containing a key and value for each environment variable + * to pass to the process. Only the object's own, enumerable properties + * are added to the environment. + * + * @param {boolean} [options.environmentAppend] + * If true, append the environment variables passed in `environment` to + * the existing set of environment variables. Otherwise, the values in + * 'environment' constitute the entire set of environment variables + * passed to the new process. + * + * @param {string} [options.stderr] + * Defines how the process's stderr output is handled. One of: + * + * - `"ignore"`: (default) The process's standard error is not redirected. + * - `"stdout"`: The process's stderr is merged with its stdout. + * - `"pipe"`: The process's stderr is redirected to a pipe, which can be read + * from via its `stderr` property. + * + * @param {string} [options.workdir] + * The working directory in which to launch the new process. + * + * @returns {Promise<Process>} + * + * @rejects {Error} + * May be rejected with an Error object if the process can not be + * launched. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_BAD_EXECUTABLE: The given command could not + * be found, or the file that it references is not executable. + * + * Note that if the process is successfully launched, but exits with + * a non-zero exit code, the promise will still resolve successfully. + */ + call(options) { + options = Object.assign({}, options); + + options.stderr = options.stderr || "ignore"; + options.workdir = options.workdir || null; + + let environment = {}; + if (!options.environment || options.environmentAppend) { + environment = this.getEnvironment(); + } + + if (options.environment) { + Object.assign(environment, options.environment); + } + + options.environment = Object.keys(environment) + .map(key => `${key}=${environment[key]}`); + + options.arguments = Array.from(options.arguments || []); + + return Promise.resolve(SubprocessImpl.isExecutableFile(options.command)).then(isExecutable => { + if (!isExecutable) { + let error = new Error(`File at path "${options.command}" does not exist, or is not executable`); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + } + + options.arguments.unshift(options.command); + + return SubprocessImpl.call(options); + }); + }, + + /** + * Returns an object with a key-value pair for every variable in the process's + * current environment. + * + * @returns {object} + */ + getEnvironment() { + let environment = Object.create(null); + for (let [k, v] of SubprocessImpl.getEnvironment()) { + environment[k] = v; + } + return environment; + }, + + /** + * Searches for the given executable file in the system executable + * file paths as specified by the PATH environment variable. + * + * On Windows, if the unadorned filename cannot be found, the + * extensions in the semicolon-separated list in the PATHSEP + * environment variable are successively appended to the original + * name and searched for in turn. + * + * @param {string} command + * The name of the executable to find. + * @param {object} [environment] + * An object containing a key for each environment variable to be used + * in the search. If not provided, full the current process environment + * is used. + * @returns {Promise<string>} + */ + pathSearch(command, environment = this.getEnvironment()) { + // Promise.resolve lets us get around returning one of the Promise.jsm + // pseudo-promises returned by Task.jsm. + let path = SubprocessImpl.pathSearch(command, environment); + return Promise.resolve(path); + }, +}; + +Object.assign(Subprocess, SubprocessConstants); +Object.freeze(Subprocess); diff --git a/toolkit/modules/subprocess/docs/index.rst b/toolkit/modules/subprocess/docs/index.rst new file mode 100644 index 0000000000..cb2d439a49 --- /dev/null +++ b/toolkit/modules/subprocess/docs/index.rst @@ -0,0 +1,227 @@ +.. _Subprocess: + +================= +Supbrocess Module +================= + +The Subprocess module allows a caller to spawn a native host executable, and +communicate with it asynchronously over its standard input and output pipes. + +Processes are launched asynchronously ``Subprocess.call`` method, based +on the properties of a single options object. The method returns a promise +which resolves, once the process has successfully launched, to a ``Process`` +object, which can be used to communicate with and control the process. + +A simple Hello World invocation, which writes a message to a process, reads it +back, logs it, and waits for the process to exit looks something like: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/cat", + }); + + proc.stdin.write("Hello World!"); + + let result = await proc.stdout.readString(); + console.log(result); + + proc.stdin.close(); + let {exitCode} = await proc.wait(); + +Input and Output Redirection +============================ + +Communication with the child process happens entirely via one-way pipes tied +to its standard input, standard output, and standard error file descriptors. +While standard input and output are always redirected to pipes, standard error +is inherited from the parent process by default. Standard error can, however, +optionally be either redirected to its own pipe or merged into the standard +output pipe. + +The module is designed primarily for use with processes following a strict +IO protocol, with predictable message sizes. Its read operations, therefore, +either complete after reading the exact amount of data specified, or do not +complete at all. For cases where this is not desirable, ``read()`` and +``readString`` may be called without any length argument, and will return a +chunk of data of an arbitrary size. + + +Process and Pipe Lifecycles +=========================== + +Once the process exits, any buffered data from its output pipes may still be +read until the pipe is explicitly closed. Unless the pipe is explicitly +closed, however, any pending buffered data *must* be read from the pipe, or +the resources associated with the pipe will not be freed. + +Beyond this, no explicit cleanup is required for either processes or their +pipes. So long as the caller ensures that the process exits, and there is no +pending input to be read on its ``stdout`` or ``stderr`` pipes, all resources +will be freed automatically. + +The preferred way to ensure that a process exits is to close its input pipe +and wait for it to exit gracefully. Processes which haven't exited gracefully +by shutdown time, however, must be forcibly terminated: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/usr/bin/subprocess.py", + }); + + // Kill the process if it hasn't gracefully exited by shutdown time. + let blocker = () => proc.kill(); + + AsyncShutdown.profileBeforeChange.addBlocker( + "Subprocess: Killing hung process", + blocker); + + proc.wait().then(() => { + // Remove the shutdown blocker once we've exited. + AsyncShutdown.profileBeforeChange.removeBlocker(blocker); + + // Close standard output, in case there's any buffered data we haven't read. + proc.stdout.close(); + }); + + // Send a message to the process, and close stdin, so the process knows to + // exit. + proc.stdin.write(message); + proc.stdin.close(); + +In the simpler case of a short-running process which takes no input, and exits +immediately after producing output, it's generally enough to simply read its +output stream until EOF: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: await Subprocess.pathSearch("ifconfig"), + }); + + // Read all of the process output. + let result = ""; + let string; + while ((string = await proc.stdout.readString())) { + result += string; + } + console.log(result); + + // The output pipe is closed and no buffered data remains to be read. + // This means the process has exited, and no further cleanup is necessary. + + +Bidirectional IO +================ + +When performing bidirectional IO, special care needs to be taken to avoid +deadlocks. While all IO operations in the Subprocess API are asynchronous, +careless ordering of operations can still lead to a state where both processes +are blocked on a read or write operation at the same time. For example, + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/cat", + }); + + let size = 1024 * 1024; + await proc.stdin.write(new ArrayBuffer(size)); + + let result = await proc.stdout.read(size); + +The code attempts to write 1MB of data to an input pipe, and then read it back +from the output pipe. Because the data is big enough to fill both the input +and output pipe buffers, though, and because the code waits for the write +operation to complete before attempting any reads, the ``cat`` process will +block trying to write to its output indefinitely, and never finish reading the +data from its standard input. + +In order to avoid the deadlock, we need to avoid blocking on the write +operation: + +.. code-block:: javascript + + let size = 1024 * 1024; + proc.stdin.write(new ArrayBuffer(size)); + + let result = await proc.stdout.read(size); + +There is no silver bullet to avoiding deadlocks in this type of situation, +though. Any input operations that depend on output operations, or vice versa, +have the possibility of triggering deadlocks, and need to be thought out +carefully. + +Arguments +========= + +Arguments may be passed to the process in the form an array of strings. +Arguments are never split, or subjected to any sort of shell expansion, so the +target process will receive the exact arguments array as passed to +``Subprocess.call``. Argument 0 will always be the full path to the +executable, as passed via the ``command`` argument: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/sh", + arguments: ["-c", "echo -n $0"], + }); + + let output = await proc.stdout.readString(); + assert(output === "/bin/sh"); + + +Process Environment +=================== + +By default, the process is launched with the same environment variables and +working directory as the parent process, but either can be changed if +necessary. The working directory may be changed simply by passing a +``workdir`` option: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/pwd", + workdir: "/tmp", + }); + + let output = await proc.stdout.readString(); + assert(output === "/tmp\n"); + +The process's environment variables can be changed using the ``environment`` +and ``environmentAppend`` options. By default, passing an ``environment`` +object replaces the process's entire environment with the properties in that +object: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/pwd", + environment: {FOO: "BAR"}, + }); + + let output = await proc.stdout.readString(); + assert(output === "FOO=BAR\n"); + +In order to add variables to, or change variables from, the current set of +environment variables, the ``environmentAppend`` object must be passed in +addition: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/pwd", + environment: {FOO: "BAR"}, + environmentAppend: true, + }); + + let output = ""; + while ((string = await proc.stdout.readString())) { + output += string; + } + + assert(output.includes("FOO=BAR\n")); + diff --git a/toolkit/modules/subprocess/moz.build b/toolkit/modules/subprocess/moz.build new file mode 100644 index 0000000000..e7a1f526a4 --- /dev/null +++ b/toolkit/modules/subprocess/moz.build @@ -0,0 +1,32 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +EXTRA_JS_MODULES += [ + 'Subprocess.jsm', +] + +EXTRA_JS_MODULES.subprocess += [ + 'subprocess_common.jsm', + 'subprocess_shared.js', + 'subprocess_worker_common.js', +] + +if CONFIG['OS_TARGET'] == 'WINNT': + EXTRA_JS_MODULES.subprocess += [ + 'subprocess_shared_win.js', + 'subprocess_win.jsm', + 'subprocess_worker_win.js', + ] +else: + EXTRA_JS_MODULES.subprocess += [ + 'subprocess_shared_unix.js', + 'subprocess_unix.jsm', + 'subprocess_worker_unix.js', + ] + +XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini'] + +SPHINX_TREES['toolkit_modules/subprocess'] = ['docs'] diff --git a/toolkit/modules/subprocess/subprocess_common.jsm b/toolkit/modules/subprocess/subprocess_common.jsm new file mode 100644 index 0000000000..a899fcc49e --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_common.jsm @@ -0,0 +1,703 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +/* eslint-disable mozilla/balanced-listeners */ + +/* exported BaseProcess, PromiseWorker */ + +var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.importGlobalProperties(["TextDecoder"]); + +XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "setTimeout", + "resource://gre/modules/Timer.jsm"); + +Services.scriptloader.loadSubScript("resource://gre/modules/subprocess/subprocess_shared.js", this); + +var EXPORTED_SYMBOLS = ["BaseProcess", "PromiseWorker", "SubprocessConstants"]; + +const BUFFER_SIZE = 4096; + +let nextResponseId = 0; + +/** + * Wraps a ChromeWorker so that messages sent to it return a promise which + * resolves when the message has been received and the operation it triggers is + * complete. + */ +class PromiseWorker extends ChromeWorker { + constructor(url) { + super(url); + + this.listeners = new Map(); + this.pendingResponses = new Map(); + + this.addListener("close", this.onClose.bind(this)); + this.addListener("failure", this.onFailure.bind(this)); + this.addListener("success", this.onSuccess.bind(this)); + this.addListener("debug", this.onDebug.bind(this)); + + this.addEventListener("message", this.onmessage); + + this.shutdown = this.shutdown.bind(this); + AsyncShutdown.webWorkersShutdown.addBlocker( + "Subprocess.jsm: Shut down IO worker", + this.shutdown); + } + + onClose() { + AsyncShutdown.webWorkersShutdown.removeBlocker(this.shutdown); + } + + shutdown() { + return this.call("shutdown", []); + } + + /** + * Adds a listener for the given message from the worker. Any message received + * from the worker with a `data.msg` property matching the given `msg` + * parameter are passed to the given listener. + * + * @param {string} msg + * The message to listen for. + * @param {function(Event)} listener + * The listener to call when matching messages are received. + */ + addListener(msg, listener) { + if (!this.listeners.has(msg)) { + this.listeners.set(msg, new Set()); + } + this.listeners.get(msg).add(listener); + } + + /** + * Removes the given message listener. + * + * @param {string} msg + * The message to stop listening for. + * @param {function(Event)} listener + * The listener to remove. + */ + removeListener(msg, listener) { + let listeners = this.listeners.get(msg); + if (listeners) { + listeners.delete(listener); + + if (!listeners.size) { + this.listeners.delete(msg); + } + } + } + + onmessage(event) { + let {msg} = event.data; + let listeners = this.listeners.get(msg) || new Set(); + + for (let listener of listeners) { + try { + listener(event.data); + } catch (e) { + Cu.reportError(e); + } + } + } + + /** + * Called when a message sent to the worker has failed, and rejects its + * corresponding promise. + * + * @private + */ + onFailure({msgId, error}) { + this.pendingResponses.get(msgId).reject(error); + this.pendingResponses.delete(msgId); + } + + /** + * Called when a message sent to the worker has succeeded, and resolves its + * corresponding promise. + * + * @private + */ + onSuccess({msgId, data}) { + this.pendingResponses.get(msgId).resolve(data); + this.pendingResponses.delete(msgId); + } + + onDebug({message}) { + dump(`Worker debug: ${message}\n`); + } + + /** + * Calls the given method in the worker, and returns a promise which resolves + * or rejects when the method has completed. + * + * @param {string} method + * The name of the method to call. + * @param {Array} args + * The arguments to pass to the method. + * @param {Array} [transferList] + * A list of objects to transfer to the worker, rather than cloning. + * @returns {Promise} + */ + call(method, args, transferList = []) { + let msgId = nextResponseId++; + + return new Promise((resolve, reject) => { + this.pendingResponses.set(msgId, {resolve, reject}); + + let message = { + msg: method, + msgId, + args, + }; + + this.postMessage(message, transferList); + }); + } +} + +/** + * Represents an input or output pipe connected to a subprocess. + * + * @property {integer} fd + * The file descriptor number of the pipe on the child process's side. + * @readonly + */ +class Pipe { + /** + * @param {Process} process + * The child process that this pipe is connected to. + * @param {integer} fd + * The file descriptor number of the pipe on the child process's side. + * @param {integer} id + * The internal ID of the pipe, which ties it to the corresponding Pipe + * object on the Worker side. + */ + constructor(process, fd, id) { + this.id = id; + this.fd = fd; + this.processId = process.id; + this.worker = process.worker; + + /** + * @property {boolean} closed + * True if the file descriptor has been closed, and can no longer + * be read from or written to. Pending IO operations may still + * complete, but new operations may not be initiated. + * @readonly + */ + this.closed = false; + } + + /** + * Closes the end of the pipe which belongs to this process. + * + * @param {boolean} force + * If true, the pipe is closed immediately, regardless of any pending + * IO operations. If false, the pipe is closed after any existing + * pending IO operations have completed. + * @returns {Promise<object>} + * Resolves to an object with no properties once the pipe has been + * closed. + */ + close(force = false) { + this.closed = true; + return this.worker.call("close", [this.id, force]); + } +} + +/** + * Represents an output-only pipe, to which data may be written. + */ +class OutputPipe extends Pipe { + constructor(...args) { + super(...args); + + this.encoder = new TextEncoder(); + } + + /** + * Writes the given data to the stream. + * + * When given an array buffer or typed array, ownership of the buffer is + * transferred to the IO worker, and it may no longer be used from this + * thread. + * + * @param {ArrayBuffer|TypedArray|string} buffer + * Data to write to the stream. + * @returns {Promise<object>} + * Resolves to an object with a `bytesWritten` property, containing + * the number of bytes successfully written, once the operation has + * completed. + * + * @rejects {object} + * May be rejected with an Error object, or an object with similar + * properties. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_END_OF_FILE: The pipe was closed before + * all of the data in `buffer` could be written to it. + */ + write(buffer) { + if (typeof buffer === "string") { + buffer = this.encoder.encode(buffer); + } + + if (Cu.getClassName(buffer, true) !== "ArrayBuffer") { + if (buffer.byteLength === buffer.buffer.byteLength) { + buffer = buffer.buffer; + } else { + buffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + } + } + + let args = [this.id, buffer]; + + return this.worker.call("write", args, [buffer]); + } +} + +/** + * Represents an input-only pipe, from which data may be read. + */ +class InputPipe extends Pipe { + constructor(...args) { + super(...args); + + this.buffers = []; + + /** + * @property {integer} dataAvailable + * The number of readable bytes currently stored in the input + * buffer. + * @readonly + */ + this.dataAvailable = 0; + + this.decoder = new TextDecoder(); + + this.pendingReads = []; + + this._pendingBufferRead = null; + + this.fillBuffer(); + } + + /** + * @property {integer} bufferSize + * The current size of the input buffer. This varies depending on + * the size of pending read operations. + * @readonly + */ + get bufferSize() { + if (this.pendingReads.length) { + return Math.max(this.pendingReads[0].length, BUFFER_SIZE); + } + return BUFFER_SIZE; + } + + /** + * Attempts to fill the input buffer. + * + * @private + */ + fillBuffer() { + let dataWanted = this.bufferSize - this.dataAvailable; + + if (!this._pendingBufferRead && dataWanted > 0) { + this._pendingBufferRead = this._read(dataWanted); + + this._pendingBufferRead.then((result) => { + this._pendingBufferRead = null; + + if (result) { + this.onInput(result.buffer); + + this.fillBuffer(); + } + }); + } + } + + _read(size) { + let args = [this.id, size]; + + return this.worker.call("read", args).catch(e => { + this.closed = true; + + for (let {length, resolve, reject} of this.pendingReads.splice(0)) { + if (length === null && e.errorCode === SubprocessConstants.ERROR_END_OF_FILE) { + resolve(new ArrayBuffer(0)); + } else { + reject(e); + } + } + }); + } + + /** + * Adds the given data to the end of the input buffer. + * + * @param {ArrayBuffer} buffer + * An input buffer to append to the current buffered input. + * @private + */ + onInput(buffer) { + this.buffers.push(buffer); + this.dataAvailable += buffer.byteLength; + this.checkPendingReads(); + } + + /** + * Checks the topmost pending read operations and fulfills as many as can be + * filled from the current input buffer. + * + * @private + */ + checkPendingReads() { + this.fillBuffer(); + + let reads = this.pendingReads; + while (reads.length && this.dataAvailable && + reads[0].length <= this.dataAvailable) { + let pending = this.pendingReads.shift(); + + let length = pending.length || this.dataAvailable; + + let result; + let byteLength = this.buffers[0].byteLength; + if (byteLength == length) { + result = this.buffers.shift(); + } else if (byteLength > length) { + let buffer = this.buffers[0]; + + this.buffers[0] = buffer.slice(length); + result = ArrayBuffer.transfer(buffer, length); + } else { + result = ArrayBuffer.transfer(this.buffers.shift(), length); + let u8result = new Uint8Array(result); + + while (byteLength < length) { + let buffer = this.buffers[0]; + let u8buffer = new Uint8Array(buffer); + + let remaining = length - byteLength; + + if (buffer.byteLength <= remaining) { + this.buffers.shift(); + + u8result.set(u8buffer, byteLength); + } else { + this.buffers[0] = buffer.slice(remaining); + + u8result.set(u8buffer.subarray(0, remaining), byteLength); + } + + byteLength += Math.min(buffer.byteLength, remaining); + } + } + + this.dataAvailable -= result.byteLength; + pending.resolve(result); + } + } + + /** + * Reads exactly `length` bytes of binary data from the input stream, or, if + * length is not provided, reads the first chunk of data to become available. + * In the latter case, returns an empty array buffer on end of file. + * + * The read operation will not complete until enough data is available to + * fulfill the request. If the pipe closes without enough available data to + * fulfill the read, the operation fails, and any remaining buffered data is + * lost. + * + * @param {integer} [length] + * The number of bytes to read. + * @returns {Promise<ArrayBuffer>} + * + * @rejects {object} + * May be rejected with an Error object, or an object with similar + * properties. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_END_OF_FILE: The pipe was closed before + * enough input could be read to satisfy the request. + */ + read(length = null) { + if (length !== null && !(Number.isInteger(length) && length >= 0)) { + throw new RangeError("Length must be a non-negative integer"); + } + + if (length == 0) { + return Promise.resolve(new ArrayBuffer(0)); + } + + return new Promise((resolve, reject) => { + this.pendingReads.push({length, resolve, reject}); + this.checkPendingReads(); + }); + } + + /** + * Reads exactly `length` bytes from the input stream, and parses them as + * UTF-8 JSON data. + * + * @param {integer} length + * The number of bytes to read. + * @returns {Promise<object>} + * + * @rejects {object} + * May be rejected with an Error object, or an object with similar + * properties. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_END_OF_FILE: The pipe was closed before + * enough input could be read to satisfy the request. + * - Subprocess.ERROR_INVALID_JSON: The data read from the pipe + * could not be parsed as a valid JSON string. + */ + readJSON(length) { + if (!Number.isInteger(length) || length <= 0) { + throw new RangeError("Length must be a positive integer"); + } + + return this.readString(length).then(string => { + try { + return JSON.parse(string); + } catch (e) { + e.errorCode = SubprocessConstants.ERROR_INVALID_JSON; + throw e; + } + }); + } + + /** + * Reads a chunk of UTF-8 data from the input stream, and converts it to a + * JavaScript string. + * + * If `length` is provided, reads exactly `length` bytes. Otherwise, reads the + * first chunk of data to become available, and returns an empty string on end + * of file. In the latter case, the chunk is decoded in streaming mode, and + * any incomplete UTF-8 sequences at the end of a chunk are returned at the + * start of a subsequent read operation. + * + * @param {integer} [length] + * The number of bytes to read. + * @param {object} [options] + * An options object as expected by TextDecoder.decode. + * @returns {Promise<string>} + * + * @rejects {object} + * May be rejected with an Error object, or an object with similar + * properties. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_END_OF_FILE: The pipe was closed before + * enough input could be read to satisfy the request. + */ + readString(length = null, options = {stream: length === null}) { + if (length !== null && !(Number.isInteger(length) && length >= 0)) { + throw new RangeError("Length must be a non-negative integer"); + } + + return this.read(length).then(buffer => { + return this.decoder.decode(buffer, options); + }); + } + + /** + * Reads 4 bytes from the input stream, and parses them as an unsigned + * integer, in native byte order. + * + * @returns {Promise<integer>} + * + * @rejects {object} + * May be rejected with an Error object, or an object with similar + * properties. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_END_OF_FILE: The pipe was closed before + * enough input could be read to satisfy the request. + */ + readUint32() { + return this.read(4).then(buffer => { + return new Uint32Array(buffer)[0]; + }); + } +} + +/** + * @class Process + * @extends BaseProcess + */ + +/** + * Represents a currently-running process, and allows interaction with it. + */ +class BaseProcess { + /** + * @param {PromiseWorker} worker + * The worker instance which owns the process. + * @param {integer} processId + * The internal ID of the Process object, which ties it to the + * corresponding process on the Worker side. + * @param {integer[]} fds + * An array of internal Pipe IDs, one for each standard file descriptor + * in the child process. + * @param {integer} pid + * The operating system process ID of the process. + */ + constructor(worker, processId, fds, pid) { + this.id = processId; + this.worker = worker; + + /** + * @property {integer} pid + * The process ID of the process, assigned by the operating system. + * @readonly + */ + this.pid = pid; + + this.exitCode = null; + + this.exitPromise = new Promise(resolve => { + this.worker.call("wait", [this.id]).then(({exitCode}) => { + resolve(Object.freeze({exitCode})); + this.exitCode = exitCode; + }); + }); + + if (fds[0] !== undefined) { + /** + * @property {OutputPipe} stdin + * A Pipe object which allows writing to the process's standard + * input. + * @readonly + */ + this.stdin = new OutputPipe(this, 0, fds[0]); + } + if (fds[1] !== undefined) { + /** + * @property {InputPipe} stdout + * A Pipe object which allows reading from the process's standard + * output. + * @readonly + */ + this.stdout = new InputPipe(this, 1, fds[1]); + } + if (fds[2] !== undefined) { + /** + * @property {InputPipe} [stderr] + * An optional Pipe object which allows reading from the + * process's standard error output. + * @readonly + */ + this.stderr = new InputPipe(this, 2, fds[2]); + } + } + + /** + * Spawns a process, and resolves to a BaseProcess instance on success. + * + * @param {object} options + * An options object as passed to `Subprocess.call`. + * + * @returns {Promise<BaseProcess>} + */ + static create(options) { + let worker = this.getWorker(); + + return worker.call("spawn", [options]).then(({processId, fds, pid}) => { + return new this(worker, processId, fds, pid); + }); + } + + static get WORKER_URL() { + throw new Error("Not implemented"); + } + + static get WorkerClass() { + return PromiseWorker; + } + + /** + * Gets the current subprocess worker, or spawns a new one if it does not + * currently exist. + * + * @returns {PromiseWorker} + */ + static getWorker() { + if (!this._worker) { + this._worker = new this.WorkerClass(this.WORKER_URL); + } + return this._worker; + } + + /** + * Kills the process. + * + * @param {integer} [timeout=300] + * A timeout, in milliseconds, after which the process will be forcibly + * killed. On platforms which support it, the process will be sent + * a `SIGTERM` signal immediately, so that it has a chance to terminate + * gracefully, and a `SIGKILL` signal if it hasn't exited within + * `timeout` milliseconds. On other platforms (namely Windows), the + * process will be forcibly terminated immediately. + * + * @returns {Promise<object>} + * Resolves to an object with an `exitCode` property when the process + * has exited. + */ + kill(timeout = 300) { + // If the process has already exited, don't bother sending a signal. + if (this.exitCode != null) { + return this.wait(); + } + + let force = timeout <= 0; + this.worker.call("kill", [this.id, force]); + + if (!force) { + setTimeout(() => { + if (this.exitCode == null) { + this.worker.call("kill", [this.id, true]); + } + }, timeout); + } + + return this.wait(); + } + + /** + * Returns a promise which resolves to the process's exit code, once it has + * exited. + * + * @returns {Promise<object>} + * Resolves to an object with an `exitCode` property, containing the + * process's exit code, once the process has exited. + * + * On Unix-like systems, a negative exit code indicates that the + * process was killed by a signal whose signal number is the absolute + * value of the error code. On Windows, an exit code of -9 indicates + * that the process was killed via the {@linkcode BaseProcess#kill kill()} + * method. + */ + wait() { + return this.exitPromise; + } +} diff --git a/toolkit/modules/subprocess/subprocess_shared.js b/toolkit/modules/subprocess/subprocess_shared.js new file mode 100644 index 0000000000..2661096c83 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_shared.js @@ -0,0 +1,98 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +/* exported Library, SubprocessConstants */ + +if (!ArrayBuffer.transfer) { + /** + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer/transfer + * + * @param {ArrayBuffer} buffer + * @param {integer} [size = buffer.byteLength] + * @returns {ArrayBuffer} + */ + ArrayBuffer.transfer = function(buffer, size = buffer.byteLength) { + let u8out = new Uint8Array(size); + let u8buffer = new Uint8Array(buffer, 0, Math.min(size, buffer.byteLength)); + + u8out.set(u8buffer); + + return u8out.buffer; + }; +} + +var libraries = {}; + +class Library { + constructor(name, names, definitions) { + if (name in libraries) { + return libraries[name]; + } + + for (let name of names) { + try { + if (!this.library) { + this.library = ctypes.open(name); + } + } catch (e) { + // Ignore errors until we've tried all the options. + } + } + if (!this.library) { + throw new Error("Could not load libc"); + } + + libraries[name] = this; + + for (let symbol of Object.keys(definitions)) { + this.declare(symbol, ...definitions[symbol]); + } + } + + declare(name, ...args) { + Object.defineProperty(this, name, { + configurable: true, + get() { + Object.defineProperty(this, name, { + configurable: true, + value: this.library.declare(name, ...args), + }); + + return this[name]; + }, + }); + } +} + +/** + * Holds constants which apply to various Subprocess operations. + * @namespace + * @lends Subprocess + */ +const SubprocessConstants = { + /** + * @property {integer} ERROR_END_OF_FILE + * The operation failed because the end of the file was reached. + * @constant + */ + ERROR_END_OF_FILE: 0xff7a0001, + /** + * @property {integer} ERROR_INVALID_JSON + * The operation failed because an invalid JSON was encountered. + * @constant + */ + ERROR_INVALID_JSON: 0xff7a0002, + /** + * @property {integer} ERROR_BAD_EXECUTABLE + * The operation failed because the given file did not exist, or + * could not be executed. + * @constant + */ + ERROR_BAD_EXECUTABLE: 0xff7a0003, +}; + +Object.freeze(SubprocessConstants); diff --git a/toolkit/modules/subprocess/subprocess_shared_unix.js b/toolkit/modules/subprocess/subprocess_shared_unix.js new file mode 100644 index 0000000000..534c4be2c3 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_shared_unix.js @@ -0,0 +1,157 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +/* exported libc */ + +const LIBC = OS.Constants.libc; + +const LIBC_CHOICES = ["libc.so", "libSystem.B.dylib", "a.out"]; + +const unix = { + pid_t: ctypes.int32_t, + + pollfd: new ctypes.StructType("pollfd", [ + {"fd": ctypes.int}, + {"events": ctypes.short}, + {"revents": ctypes.short}, + ]), + + posix_spawn_file_actions_t: ctypes.uint8_t.array( + LIBC.OSFILE_SIZEOF_POSIX_SPAWN_FILE_ACTIONS_T), + + WEXITSTATUS(status) { + return (status >> 8) & 0xff; + }, + + WTERMSIG(status) { + return status & 0x7f; + }, +}; + +var libc = new Library("libc", LIBC_CHOICES, { + environ: [ctypes.char.ptr.ptr], + + // Darwin-only. + _NSGetEnviron: [ + ctypes.default_abi, + ctypes.char.ptr.ptr.ptr, + ], + + chdir: [ + ctypes.default_abi, + ctypes.int, + ctypes.char.ptr, /* path */ + ], + + close: [ + ctypes.default_abi, + ctypes.int, + ctypes.int, /* fildes */ + ], + + fcntl: [ + ctypes.default_abi, + ctypes.int, + ctypes.int, /* fildes */ + ctypes.int, /* cmd */ + ctypes.int, /* ... */ + ], + + getcwd: [ + ctypes.default_abi, + ctypes.char.ptr, + ctypes.char.ptr, /* buf */ + ctypes.size_t, /* size */ + ], + + kill: [ + ctypes.default_abi, + ctypes.int, + unix.pid_t, /* pid */ + ctypes.int, /* signal */ + ], + + pipe: [ + ctypes.default_abi, + ctypes.int, + ctypes.int.array(2), /* pipefd */ + ], + + poll: [ + ctypes.default_abi, + ctypes.int, + unix.pollfd.array(), /* fds */ + ctypes.unsigned_int, /* nfds */ + ctypes.int, /* timeout */ + ], + + posix_spawn: [ + ctypes.default_abi, + ctypes.int, + unix.pid_t.ptr, /* pid */ + ctypes.char.ptr, /* path */ + unix.posix_spawn_file_actions_t.ptr, /* file_actions */ + ctypes.voidptr_t, /* attrp */ + ctypes.char.ptr.ptr, /* argv */ + ctypes.char.ptr.ptr, /* envp */ + ], + + posix_spawn_file_actions_addclose: [ + ctypes.default_abi, + ctypes.int, + unix.posix_spawn_file_actions_t.ptr, /* file_actions */ + ctypes.int, /* fildes */ + ], + + posix_spawn_file_actions_adddup2: [ + ctypes.default_abi, + ctypes.int, + unix.posix_spawn_file_actions_t.ptr, /* file_actions */ + ctypes.int, /* fildes */ + ctypes.int, /* newfildes */ + ], + + posix_spawn_file_actions_destroy: [ + ctypes.default_abi, + ctypes.int, + unix.posix_spawn_file_actions_t.ptr, /* file_actions */ + ], + + posix_spawn_file_actions_init: [ + ctypes.default_abi, + ctypes.int, + unix.posix_spawn_file_actions_t.ptr, /* file_actions */ + ], + + read: [ + ctypes.default_abi, + ctypes.ssize_t, + ctypes.int, /* fildes */ + ctypes.char.ptr, /* buf */ + ctypes.size_t, /* nbyte */ + ], + + waitpid: [ + ctypes.default_abi, + unix.pid_t, + unix.pid_t, /* pid */ + ctypes.int.ptr, /* status */ + ctypes.int, /* options */ + ], + + write: [ + ctypes.default_abi, + ctypes.ssize_t, + ctypes.int, /* fildes */ + ctypes.char.ptr, /* buf */ + ctypes.size_t, /* nbyte */ + ], +}); + +unix.Fd = function(fd) { + return ctypes.CDataFinalizer(ctypes.int(fd), libc.close); +}; diff --git a/toolkit/modules/subprocess/subprocess_shared_win.js b/toolkit/modules/subprocess/subprocess_shared_win.js new file mode 100644 index 0000000000..6267b8cc73 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_shared_win.js @@ -0,0 +1,522 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +/* exported LIBC, Win, createPipe, libc */ + +const LIBC = OS.Constants.libc; + +const Win = OS.Constants.Win; + +const LIBC_CHOICES = ["kernel32.dll"]; + +var win32 = { + // On Windows 64, winapi_abi is an alias for default_abi. + WINAPI: ctypes.winapi_abi, + + VOID: ctypes.void_t, + + BYTE: ctypes.uint8_t, + WORD: ctypes.uint16_t, + DWORD: ctypes.uint32_t, + LONG: ctypes.long, + LARGE_INTEGER: ctypes.int64_t, + ULONGLONG: ctypes.uint64_t, + + UINT: ctypes.unsigned_int, + UCHAR: ctypes.unsigned_char, + + BOOL: ctypes.bool, + + HANDLE: ctypes.voidptr_t, + PVOID: ctypes.voidptr_t, + LPVOID: ctypes.voidptr_t, + + CHAR: ctypes.char, + WCHAR: ctypes.jschar, + + ULONG_PTR: ctypes.uintptr_t, + + SIZE_T: ctypes.size_t, + PSIZE_T: ctypes.size_t.ptr, +}; + +Object.assign(win32, { + DWORD_PTR: win32.ULONG_PTR, + + LPSTR: win32.CHAR.ptr, + LPWSTR: win32.WCHAR.ptr, + + LPBYTE: win32.BYTE.ptr, + LPDWORD: win32.DWORD.ptr, + LPHANDLE: win32.HANDLE.ptr, + + // This is an opaque type. + PROC_THREAD_ATTRIBUTE_LIST: ctypes.char.array(), + LPPROC_THREAD_ATTRIBUTE_LIST: ctypes.char.ptr, +}); + +Object.assign(win32, { + LPCSTR: win32.LPSTR, + LPCWSTR: win32.LPWSTR, + LPCVOID: win32.LPVOID, +}); + +Object.assign(win32, { + CREATE_SUSPENDED: 0x00000004, + CREATE_NEW_CONSOLE: 0x00000010, + CREATE_UNICODE_ENVIRONMENT: 0x00000400, + CREATE_NO_WINDOW: 0x08000000, + CREATE_BREAKAWAY_FROM_JOB: 0x01000000, + EXTENDED_STARTUPINFO_PRESENT: 0x00080000, + + STARTF_USESTDHANDLES: 0x0100, + + DUPLICATE_CLOSE_SOURCE: 0x01, + DUPLICATE_SAME_ACCESS: 0x02, + + ERROR_HANDLE_EOF: 38, + ERROR_BROKEN_PIPE: 109, + ERROR_INSUFFICIENT_BUFFER: 122, + + FILE_FLAG_OVERLAPPED: 0x40000000, + + PIPE_TYPE_BYTE: 0x00, + + PIPE_ACCESS_INBOUND: 0x01, + PIPE_ACCESS_OUTBOUND: 0x02, + PIPE_ACCESS_DUPLEX: 0x03, + + PIPE_WAIT: 0x00, + PIPE_NOWAIT: 0x01, + + STILL_ACTIVE: 259, + + PROC_THREAD_ATTRIBUTE_HANDLE_LIST: 0x00020002, + + JobObjectBasicLimitInformation: 2, + JobObjectExtendedLimitInformation: 9, + + JOB_OBJECT_LIMIT_BREAKAWAY_OK: 0x00000800, + + // These constants are 32-bit unsigned integers, but Windows defines + // them as negative integers cast to an unsigned type. + STD_INPUT_HANDLE: -10 + 0x100000000, + STD_OUTPUT_HANDLE: -11 + 0x100000000, + STD_ERROR_HANDLE: -12 + 0x100000000, + + WAIT_TIMEOUT: 0x00000102, + WAIT_FAILED: 0xffffffff, +}); + +Object.assign(win32, { + JOBOBJECT_BASIC_LIMIT_INFORMATION: new ctypes.StructType("JOBOBJECT_BASIC_LIMIT_INFORMATION", [ + {"PerProcessUserTimeLimit": win32.LARGE_INTEGER}, + {"PerJobUserTimeLimit": win32.LARGE_INTEGER}, + {"LimitFlags": win32.DWORD}, + {"MinimumWorkingSetSize": win32.SIZE_T}, + {"MaximumWorkingSetSize": win32.SIZE_T}, + {"ActiveProcessLimit": win32.DWORD}, + {"Affinity": win32.ULONG_PTR}, + {"PriorityClass": win32.DWORD}, + {"SchedulingClass": win32.DWORD}, + ]), + + IO_COUNTERS: new ctypes.StructType("IO_COUNTERS", [ + {"ReadOperationCount": win32.ULONGLONG}, + {"WriteOperationCount": win32.ULONGLONG}, + {"OtherOperationCount": win32.ULONGLONG}, + {"ReadTransferCount": win32.ULONGLONG}, + {"WriteTransferCount": win32.ULONGLONG}, + {"OtherTransferCount": win32.ULONGLONG}, + ]), +}); + +Object.assign(win32, { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION: new ctypes.StructType("JOBOBJECT_EXTENDED_LIMIT_INFORMATION", [ + {"BasicLimitInformation": win32.JOBOBJECT_BASIC_LIMIT_INFORMATION}, + {"IoInfo": win32.IO_COUNTERS}, + {"ProcessMemoryLimit": win32.SIZE_T}, + {"JobMemoryLimit": win32.SIZE_T}, + {"PeakProcessMemoryUsed": win32.SIZE_T}, + {"PeakJobMemoryUsed": win32.SIZE_T}, + ]), + + OVERLAPPED: new ctypes.StructType("OVERLAPPED", [ + {"Internal": win32.ULONG_PTR}, + {"InternalHigh": win32.ULONG_PTR}, + {"Offset": win32.DWORD}, + {"OffsetHigh": win32.DWORD}, + {"hEvent": win32.HANDLE}, + ]), + + PROCESS_INFORMATION: new ctypes.StructType("PROCESS_INFORMATION", [ + {"hProcess": win32.HANDLE}, + {"hThread": win32.HANDLE}, + {"dwProcessId": win32.DWORD}, + {"dwThreadId": win32.DWORD}, + ]), + + SECURITY_ATTRIBUTES: new ctypes.StructType("SECURITY_ATTRIBUTES", [ + {"nLength": win32.DWORD}, + {"lpSecurityDescriptor": win32.LPVOID}, + {"bInheritHandle": win32.BOOL}, + ]), + + STARTUPINFOW: new ctypes.StructType("STARTUPINFOW", [ + {"cb": win32.DWORD}, + {"lpReserved": win32.LPWSTR}, + {"lpDesktop": win32.LPWSTR}, + {"lpTitle": win32.LPWSTR}, + {"dwX": win32.DWORD}, + {"dwY": win32.DWORD}, + {"dwXSize": win32.DWORD}, + {"dwYSize": win32.DWORD}, + {"dwXCountChars": win32.DWORD}, + {"dwYCountChars": win32.DWORD}, + {"dwFillAttribute": win32.DWORD}, + {"dwFlags": win32.DWORD}, + {"wShowWindow": win32.WORD}, + {"cbReserved2": win32.WORD}, + {"lpReserved2": win32.LPBYTE}, + {"hStdInput": win32.HANDLE}, + {"hStdOutput": win32.HANDLE}, + {"hStdError": win32.HANDLE}, + ]), +}); + +Object.assign(win32, { + STARTUPINFOEXW: new ctypes.StructType("STARTUPINFOEXW", [ + {"StartupInfo": win32.STARTUPINFOW}, + {"lpAttributeList": win32.LPPROC_THREAD_ATTRIBUTE_LIST}, + ]), +}); + + +var libc = new Library("libc", LIBC_CHOICES, { + AssignProcessToJobObject: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hJob */ + win32.HANDLE, /* hProcess */ + ], + + CloseHandle: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hObject */ + ], + + CreateEventW: [ + win32.WINAPI, + win32.HANDLE, + win32.SECURITY_ATTRIBUTES.ptr, /* opt lpEventAttributes */ + win32.BOOL, /* bManualReset */ + win32.BOOL, /* bInitialState */ + win32.LPWSTR, /* lpName */ + ], + + CreateFileW: [ + win32.WINAPI, + win32.HANDLE, + win32.LPWSTR, /* lpFileName */ + win32.DWORD, /* dwDesiredAccess */ + win32.DWORD, /* dwShareMode */ + win32.SECURITY_ATTRIBUTES.ptr, /* opt lpSecurityAttributes */ + win32.DWORD, /* dwCreationDisposition */ + win32.DWORD, /* dwFlagsAndAttributes */ + win32.HANDLE, /* opt hTemplateFile */ + ], + + CreateJobObjectW: [ + win32.WINAPI, + win32.HANDLE, + win32.SECURITY_ATTRIBUTES.ptr, /* opt lpJobAttributes */ + win32.LPWSTR, /* lpName */ + ], + + CreateNamedPipeW: [ + win32.WINAPI, + win32.HANDLE, + win32.LPWSTR, /* lpName */ + win32.DWORD, /* dwOpenMode */ + win32.DWORD, /* dwPipeMode */ + win32.DWORD, /* nMaxInstances */ + win32.DWORD, /* nOutBufferSize */ + win32.DWORD, /* nInBufferSize */ + win32.DWORD, /* nDefaultTimeOut */ + win32.SECURITY_ATTRIBUTES.ptr, /* opt lpSecurityAttributes */ + ], + + CreatePipe: [ + win32.WINAPI, + win32.BOOL, + win32.LPHANDLE, /* out hReadPipe */ + win32.LPHANDLE, /* out hWritePipe */ + win32.SECURITY_ATTRIBUTES.ptr, /* opt lpPipeAttributes */ + win32.DWORD, /* nSize */ + ], + + CreateProcessW: [ + win32.WINAPI, + win32.BOOL, + win32.LPCWSTR, /* lpApplicationName */ + win32.LPWSTR, /* lpCommandLine */ + win32.SECURITY_ATTRIBUTES.ptr, /* lpProcessAttributes */ + win32.SECURITY_ATTRIBUTES.ptr, /* lpThreadAttributes */ + win32.BOOL, /* bInheritHandle */ + win32.DWORD, /* dwCreationFlags */ + win32.LPVOID, /* opt lpEnvironment */ + win32.LPCWSTR, /* opt lpCurrentDirectory */ + win32.STARTUPINFOW.ptr, /* lpStartupInfo */ + win32.PROCESS_INFORMATION.ptr, /* out lpProcessInformation */ + ], + + CreateSemaphoreW: [ + win32.WINAPI, + win32.HANDLE, + win32.SECURITY_ATTRIBUTES.ptr, /* opt lpSemaphoreAttributes */ + win32.LONG, /* lInitialCount */ + win32.LONG, /* lMaximumCount */ + win32.LPCWSTR, /* opt lpName */ + ], + + DeleteProcThreadAttributeList: [ + win32.WINAPI, + win32.VOID, + win32.LPPROC_THREAD_ATTRIBUTE_LIST, /* in/out lpAttributeList */ + ], + + DuplicateHandle: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hSourceProcessHandle */ + win32.HANDLE, /* hSourceHandle */ + win32.HANDLE, /* hTargetProcessHandle */ + win32.LPHANDLE, /* out lpTargetHandle */ + win32.DWORD, /* dwDesiredAccess */ + win32.BOOL, /* bInheritHandle */ + win32.DWORD, /* dwOptions */ + ], + + FreeEnvironmentStringsW: [ + win32.WINAPI, + win32.BOOL, + win32.LPCWSTR, /* lpszEnvironmentBlock */ + ], + + GetCurrentProcess: [ + win32.WINAPI, + win32.HANDLE, + ], + + GetCurrentProcessId: [ + win32.WINAPI, + win32.DWORD, + ], + + GetEnvironmentStringsW: [ + win32.WINAPI, + win32.LPCWSTR, + ], + + GetExitCodeProcess: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hProcess */ + win32.LPDWORD, /* lpExitCode */ + ], + + GetOverlappedResult: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hFile */ + win32.OVERLAPPED.ptr, /* lpOverlapped */ + win32.LPDWORD, /* lpNumberOfBytesTransferred */ + win32.BOOL, /* bWait */ + ], + + GetStdHandle: [ + win32.WINAPI, + win32.HANDLE, + win32.DWORD, /* nStdHandle */ + ], + + InitializeProcThreadAttributeList: [ + win32.WINAPI, + win32.BOOL, + win32.LPPROC_THREAD_ATTRIBUTE_LIST, /* out opt lpAttributeList */ + win32.DWORD, /* dwAttributeCount */ + win32.DWORD, /* dwFlags */ + win32.PSIZE_T, /* in/out lpSize */ + ], + + ReadFile: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hFile */ + win32.LPVOID, /* out lpBuffer */ + win32.DWORD, /* nNumberOfBytesToRead */ + win32.LPDWORD, /* opt out lpNumberOfBytesRead */ + win32.OVERLAPPED.ptr, /* opt in/out lpOverlapped */ + ], + + ReleaseSemaphore: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hSemaphore */ + win32.LONG, /* lReleaseCount */ + win32.LONG.ptr, /* opt out lpPreviousCount */ + ], + + ResumeThread: [ + win32.WINAPI, + win32.DWORD, + win32.HANDLE, /* hThread */ + ], + + SetInformationJobObject: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hJob */ + ctypes.int, /* JobObjectInfoClass */ + win32.LPVOID, /* lpJobObjectInfo */ + win32.DWORD, /* cbJobObjectInfoLengt */ + ], + + TerminateJobObject: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hJob */ + win32.UINT, /* uExitCode */ + ], + + TerminateProcess: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hProcess */ + win32.UINT, /* uExitCode */ + ], + + UpdateProcThreadAttribute: [ + win32.WINAPI, + win32.BOOL, + win32.LPPROC_THREAD_ATTRIBUTE_LIST, /* in/out lpAttributeList */ + win32.DWORD, /* dwFlags */ + win32.DWORD_PTR, /* Attribute */ + win32.PVOID, /* lpValue */ + win32.SIZE_T, /* cbSize */ + win32.PVOID, /* out opt lpPreviousValue */ + win32.PSIZE_T, /* opt lpReturnSize */ + ], + + WaitForMultipleObjects: [ + win32.WINAPI, + win32.DWORD, + win32.DWORD, /* nCount */ + win32.HANDLE.ptr, /* hHandles */ + win32.BOOL, /* bWaitAll */ + win32.DWORD, /* dwMilliseconds */ + ], + + WaitForSingleObject: [ + win32.WINAPI, + win32.DWORD, + win32.HANDLE, /* hHandle */ + win32.BOOL, /* bWaitAll */ + win32.DWORD, /* dwMilliseconds */ + ], + + WriteFile: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hFile */ + win32.LPCVOID, /* lpBuffer */ + win32.DWORD, /* nNumberOfBytesToRead */ + win32.LPDWORD, /* opt out lpNumberOfBytesWritten */ + win32.OVERLAPPED.ptr, /* opt in/out lpOverlapped */ + ], +}); + + +let nextNamedPipeId = 0; + +win32.Handle = function(handle) { + return ctypes.CDataFinalizer(win32.HANDLE(handle), libc.CloseHandle); +}; + +win32.createPipe = function(secAttr, readFlags = 0, writeFlags = 0, size = 0) { + readFlags |= win32.PIPE_ACCESS_INBOUND; + writeFlags |= Win.FILE_ATTRIBUTE_NORMAL; + + if (size == 0) { + size = 4096; + } + + let pid = libc.GetCurrentProcessId(); + let pipeName = String.raw`\\.\Pipe\SubProcessPipe.${pid}.${nextNamedPipeId++}`; + + let readHandle = libc.CreateNamedPipeW( + pipeName, readFlags, + win32.PIPE_TYPE_BYTE | win32.PIPE_WAIT, + 1, /* number of connections */ + size, /* output buffer size */ + size, /* input buffer size */ + 0, /* timeout */ + secAttr.address()); + + let isInvalid = handle => String(handle) == String(win32.HANDLE(Win.INVALID_HANDLE_VALUE)); + + if (isInvalid(readHandle)) { + return []; + } + + let writeHandle = libc.CreateFileW( + pipeName, Win.GENERIC_WRITE, 0, secAttr.address(), + Win.OPEN_EXISTING, writeFlags, null); + + if (isInvalid(writeHandle)) { + libc.CloseHandle(readHandle); + return []; + } + + return [win32.Handle(readHandle), + win32.Handle(writeHandle)]; +}; + +win32.createThreadAttributeList = function(handles) { + try { + void libc.InitializeProcThreadAttributeList; + void libc.DeleteProcThreadAttributeList; + void libc.UpdateProcThreadAttribute; + } catch (e) { + // This is only supported in Windows Vista and later. + return null; + } + + let size = win32.SIZE_T(); + if (!libc.InitializeProcThreadAttributeList(null, 1, 0, size.address()) && + ctypes.winLastError != win32.ERROR_INSUFFICIENT_BUFFER) { + return null; + } + + let attrList = win32.PROC_THREAD_ATTRIBUTE_LIST(size.value); + + if (!libc.InitializeProcThreadAttributeList(attrList, 1, 0, size.address())) { + return null; + } + + let ok = libc.UpdateProcThreadAttribute( + attrList, 0, win32.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + handles, handles.constructor.size, null, null); + + if (!ok) { + libc.DeleteProcThreadAttributeList(attrList); + return null; + } + + return attrList; +}; diff --git a/toolkit/modules/subprocess/subprocess_unix.jsm b/toolkit/modules/subprocess/subprocess_unix.jsm new file mode 100644 index 0000000000..47ce667d26 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_unix.jsm @@ -0,0 +1,166 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +/* eslint-disable mozilla/balanced-listeners */ + +/* exported SubprocessImpl */ + +/* globals BaseProcess, PromiseWorker */ + +var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +var EXPORTED_SYMBOLS = ["SubprocessImpl"]; + +Cu.import("resource://gre/modules/ctypes.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/subprocess/subprocess_common.jsm"); + +Services.scriptloader.loadSubScript("resource://gre/modules/subprocess/subprocess_shared.js", this); +Services.scriptloader.loadSubScript("resource://gre/modules/subprocess/subprocess_shared_unix.js", this); + +class UnixPromiseWorker extends PromiseWorker { + constructor(...args) { + super(...args); + + let fds = ctypes.int.array(2)(); + let res = libc.pipe(fds); + if (res == -1) { + throw new Error("Unable to create pipe"); + } + + this.signalFd = fds[1]; + + libc.fcntl(fds[0], LIBC.F_SETFL, LIBC.O_NONBLOCK); + libc.fcntl(fds[0], LIBC.F_SETFD, LIBC.FD_CLOEXEC); + libc.fcntl(fds[1], LIBC.F_SETFD, LIBC.FD_CLOEXEC); + + this.call("init", [{signalFd: fds[0]}]); + } + + closePipe() { + if (this.signalFd) { + libc.close(this.signalFd); + this.signalFd = null; + } + } + + onClose() { + this.closePipe(); + super.onClose(); + } + + signalWorker() { + libc.write(this.signalFd, new ArrayBuffer(1), 1); + } + + postMessage(...args) { + this.signalWorker(); + return super.postMessage(...args); + } +} + + +class Process extends BaseProcess { + static get WORKER_URL() { + return "resource://gre/modules/subprocess/subprocess_worker_unix.js"; + } + + static get WorkerClass() { + return UnixPromiseWorker; + } +} + +var SubprocessUnix = { + Process, + + call(options) { + return Process.create(options); + }, + + * getEnvironment() { + let environ; + if (OS.Constants.Sys.Name == "Darwin") { + environ = libc._NSGetEnviron().contents; + } else { + environ = libc.environ; + } + + for (let envp = environ; !envp.contents.isNull(); envp = envp.increment()) { + let str = envp.contents.readString(); + + let idx = str.indexOf("="); + if (idx >= 0) { + yield [str.slice(0, idx), + str.slice(idx + 1)]; + } + } + }, + + isExecutableFile: Task.async(function* isExecutable(path) { + if (!OS.Path.split(path).absolute) { + return false; + } + + try { + let info = yield OS.File.stat(path); + + // FIXME: We really want access(path, X_OK) here, but OS.File does not + // support it. + return !info.isDir && (info.unixMode & 0o111); + } catch (e) { + return false; + } + }), + + /** + * Searches for the given executable file in the system executable + * file paths as specified by the PATH environment variable. + * + * On Windows, if the unadorned filename cannot be found, the + * extensions in the semicolon-separated list in the PATHEXT + * environment variable are successively appended to the original + * name and searched for in turn. + * + * @param {string} bin + * The name of the executable to find. + * @param {object} environment + * An object containing a key for each environment variable to be used + * in the search. + * @returns {Promise<string>} + */ + pathSearch: Task.async(function* (bin, environment) { + let split = OS.Path.split(bin); + if (split.absolute) { + if (yield this.isExecutableFile(bin)) { + return bin; + } + let error = new Error(`File at path "${bin}" does not exist, or is not executable`); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + } + + let dirs = []; + if (environment.PATH) { + dirs = environment.PATH.split(":"); + } + + for (let dir of dirs) { + let path = OS.Path.join(dir, bin); + + if (yield this.isExecutableFile(path)) { + return path; + } + } + let error = new Error(`Executable not found: ${bin}`); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + }), +}; + +var SubprocessImpl = SubprocessUnix; diff --git a/toolkit/modules/subprocess/subprocess_win.jsm b/toolkit/modules/subprocess/subprocess_win.jsm new file mode 100644 index 0000000000..aac625f725 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_win.jsm @@ -0,0 +1,168 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +/* eslint-disable mozilla/balanced-listeners */ + +/* exported SubprocessImpl */ + +/* globals BaseProcess, PromiseWorker */ + +var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +var EXPORTED_SYMBOLS = ["SubprocessImpl"]; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/ctypes.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/subprocess/subprocess_common.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "env", "@mozilla.org/process/environment;1", "nsIEnvironment"); + +Services.scriptloader.loadSubScript("resource://gre/modules/subprocess/subprocess_shared.js", this); +Services.scriptloader.loadSubScript("resource://gre/modules/subprocess/subprocess_shared_win.js", this); + +class WinPromiseWorker extends PromiseWorker { + constructor(...args) { + super(...args); + + this.signalEvent = libc.CreateSemaphoreW(null, 0, 32, null); + + this.call("init", [{ + breakAwayFromJob: !AppConstants.isPlatformAndVersionAtLeast("win", "6.2"), + comspec: env.get("COMSPEC"), + signalEvent: String(ctypes.cast(this.signalEvent, ctypes.uintptr_t).value), + }]); + } + + signalWorker() { + libc.ReleaseSemaphore(this.signalEvent, 1, null); + } + + postMessage(...args) { + this.signalWorker(); + return super.postMessage(...args); + } +} + +class Process extends BaseProcess { + static get WORKER_URL() { + return "resource://gre/modules/subprocess/subprocess_worker_win.js"; + } + + static get WorkerClass() { + return WinPromiseWorker; + } +} + +var SubprocessWin = { + Process, + + call(options) { + return Process.create(options); + }, + + * getEnvironment() { + let env = libc.GetEnvironmentStringsW(); + try { + for (let p = env, q = env; ; p = p.increment()) { + if (p.contents == "\0") { + if (String(p) == String(q)) { + break; + } + + let str = q.readString(); + q = p.increment(); + + let idx = str.indexOf("="); + if (idx == 0) { + idx = str.indexOf("=", 1); + } + + if (idx >= 0) { + yield [str.slice(0, idx), str.slice(idx + 1)]; + } + } + } + } finally { + libc.FreeEnvironmentStringsW(env); + } + }, + + isExecutableFile: Task.async(function* (path) { + if (!OS.Path.split(path).absolute) { + return false; + } + + try { + let info = yield OS.File.stat(path); + return !(info.isDir || info.isSymlink); + } catch (e) { + return false; + } + }), + + /** + * Searches for the given executable file in the system executable + * file paths as specified by the PATH environment variable. + * + * On Windows, if the unadorned filename cannot be found, the + * extensions in the semicolon-separated list in the PATHEXT + * environment variable are successively appended to the original + * name and searched for in turn. + * + * @param {string} bin + * The name of the executable to find. + * @param {object} environment + * An object containing a key for each environment variable to be used + * in the search. + * @returns {Promise<string>} + */ + pathSearch: Task.async(function* (bin, environment) { + let split = OS.Path.split(bin); + if (split.absolute) { + if (yield this.isExecutableFile(bin)) { + return bin; + } + let error = new Error(`File at path "${bin}" does not exist, or is not a normal file`); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + } + + let dirs = []; + let exts = []; + if (environment.PATH) { + dirs = environment.PATH.split(";"); + } + if (environment.PATHEXT) { + exts = environment.PATHEXT.split(";"); + } + + for (let dir of dirs) { + let path = OS.Path.join(dir, bin); + + if (yield this.isExecutableFile(path)) { + return path; + } + + for (let ext of exts) { + let file = path + ext; + + if (yield this.isExecutableFile(file)) { + return file; + } + } + } + let error = new Error(`Executable not found: ${bin}`); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + }), +}; + +var SubprocessImpl = SubprocessWin; diff --git a/toolkit/modules/subprocess/subprocess_worker_common.js b/toolkit/modules/subprocess/subprocess_worker_common.js new file mode 100644 index 0000000000..2b681e7378 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_worker_common.js @@ -0,0 +1,229 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +/* exported BasePipe, BaseProcess, debug */ +/* globals Process, io */ + +function debug(message) { + self.postMessage({msg: "debug", message}); +} + +class BasePipe { + constructor() { + this.closing = false; + this.closed = false; + + this.closedPromise = new Promise(resolve => { + this.resolveClosed = resolve; + }); + + this.pending = []; + } + + shiftPending() { + let result = this.pending.shift(); + + if (this.closing && this.pending.length == 0) { + this.close(); + } + + return result; + } +} + +let nextProcessId = 0; + +class BaseProcess { + constructor(options) { + this.id = nextProcessId++; + + this.exitCode = null; + + this.exitPromise = new Promise(resolve => { + this.resolveExit = resolve; + }); + this.exitPromise.then(() => { + // The input file descriptors will be closed after poll + // reports that their input buffers are empty. If we close + // them now, we may lose output. + this.pipes[0].close(true); + }); + + this.pid = null; + this.pipes = []; + + this.stringArrays = []; + + this.spawn(options); + } + + /** + * Waits for the process to exit and all of its pending IO operations to + * complete. + * + * @returns {Promise<void>} + */ + awaitFinished() { + return Promise.all([ + this.exitPromise, + ...this.pipes.map(pipe => pipe.closedPromise), + ]); + } + + /** + * Creates a null-terminated array of pointers to null-terminated C-strings, + * and returns it. + * + * @param {string[]} strings + * The strings to convert into a C string array. + * + * @returns {ctypes.char.ptr.array} + */ + stringArray(strings) { + let result = ctypes.char.ptr.array(strings.length + 1)(); + + let cstrings = strings.map(str => ctypes.char.array()(str)); + for (let [i, cstring] of cstrings.entries()) { + result[i] = cstring; + } + + // Char arrays used in char arg and environment vectors must be + // explicitly kept alive in a JS object, or they will be reaped + // by the GC if it runs before our process is started. + this.stringArrays.push(cstrings); + + return result; + } +} + +let requests = { + init(details) { + io.init(details); + + return {data: {}}; + }, + + shutdown() { + io.shutdown(); + + return {data: {}}; + }, + + close(pipeId, force = false) { + let pipe = io.getPipe(pipeId); + + return pipe.close(force).then(() => ({data: {}})); + }, + + spawn(options) { + let process = new Process(options); + let processId = process.id; + + io.addProcess(process); + + let fds = process.pipes.map(pipe => pipe.id); + + return {data: {processId, fds, pid: process.pid}}; + }, + + kill(processId, force = false) { + let process = io.getProcess(processId); + + process.kill(force ? 9 : 15); + + return {data: {}}; + }, + + wait(processId) { + let process = io.getProcess(processId); + + process.wait(); + + process.awaitFinished().then(() => { + io.cleanupProcess(process); + }); + + return process.exitPromise.then(exitCode => { + return {data: {exitCode}}; + }); + }, + + read(pipeId, count) { + let pipe = io.getPipe(pipeId); + + return pipe.read(count).then(buffer => { + return {data: {buffer}}; + }); + }, + + write(pipeId, buffer) { + let pipe = io.getPipe(pipeId); + + return pipe.write(buffer).then(bytesWritten => { + return {data: {bytesWritten}}; + }); + }, + + getOpenFiles() { + return {data: new Set(io.pipes.keys())}; + }, + + getProcesses() { + let data = new Map(Array.from(io.processes.values()) + .filter(proc => proc.exitCode == null) + .map(proc => [proc.id, proc.pid])); + return {data}; + }, + + waitForNoProcesses() { + return Promise.all(Array.from(io.processes.values(), + proc => proc.awaitFinished())); + }, +}; + +onmessage = event => { + io.messageCount--; + + let {msg, msgId, args} = event.data; + + new Promise(resolve => { + resolve(requests[msg](...args)); + }).then(result => { + let response = { + msg: "success", + msgId, + data: result.data, + }; + + self.postMessage(response, result.transfer || []); + }).catch(error => { + if (error instanceof Error) { + error = { + message: error.message, + fileName: error.fileName, + lineNumber: error.lineNumber, + column: error.column, + stack: error.stack, + errorCode: error.errorCode, + }; + } + + self.postMessage({ + msg: "failure", + msgId, + error, + }); + }).catch(error => { + console.error(error); + + self.postMessage({ + msg: "failure", + msgId, + error: {}, + }); + }); +}; diff --git a/toolkit/modules/subprocess/subprocess_worker_unix.js b/toolkit/modules/subprocess/subprocess_worker_unix.js new file mode 100644 index 0000000000..839402deb1 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_worker_unix.js @@ -0,0 +1,609 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +/* exported Process */ +/* globals BaseProcess, BasePipe */ + +importScripts("resource://gre/modules/subprocess/subprocess_shared.js", + "resource://gre/modules/subprocess/subprocess_shared_unix.js", + "resource://gre/modules/subprocess/subprocess_worker_common.js"); + +const POLL_TIMEOUT = 5000; + +let io; + +let nextPipeId = 0; + +class Pipe extends BasePipe { + constructor(process, fd) { + super(); + + this.process = process; + this.fd = fd; + this.id = nextPipeId++; + } + + get pollEvents() { + throw new Error("Not implemented"); + } + + /** + * Closes the file descriptor. + * + * @param {boolean} [force=false] + * If true, the file descriptor is closed immediately. If false, the + * file descriptor is closed after all current pending IO operations + * have completed. + * + * @returns {Promise<void>} + * Resolves when the file descriptor has been closed. + */ + close(force = false) { + if (!force && this.pending.length) { + this.closing = true; + return this.closedPromise; + } + + for (let {reject} of this.pending) { + let error = new Error("File closed"); + error.errorCode = SubprocessConstants.ERROR_END_OF_FILE; + reject(error); + } + this.pending.length = 0; + + if (!this.closed) { + this.fd.dispose(); + + this.closed = true; + this.resolveClosed(); + + io.pipes.delete(this.id); + io.updatePollFds(); + } + return this.closedPromise; + } + + /** + * Called when an error occurred while polling our file descriptor. + */ + onError() { + this.close(true); + this.process.wait(); + } +} + +class InputPipe extends Pipe { + /** + * A bit mask of poll() events which we currently wish to be notified of on + * this file descriptor. + */ + get pollEvents() { + if (this.pending.length) { + return LIBC.POLLIN; + } + return 0; + } + + /** + * Asynchronously reads at most `length` bytes of binary data from the file + * descriptor into an ArrayBuffer of the same size. Returns a promise which + * resolves when the operation is complete. + * + * @param {integer} length + * The number of bytes to read. + * + * @returns {Promise<ArrayBuffer>} + */ + read(length) { + if (this.closing || this.closed) { + throw new Error("Attempt to read from closed pipe"); + } + + return new Promise((resolve, reject) => { + this.pending.push({resolve, reject, length}); + io.updatePollFds(); + }); + } + + /** + * Synchronously reads at most `count` bytes of binary data into an + * ArrayBuffer, and returns that buffer. If no data can be read without + * blocking, returns null instead. + * + * @param {integer} count + * The number of bytes to read. + * + * @returns {ArrayBuffer|null} + */ + readBuffer(count) { + let buffer = new ArrayBuffer(count); + + let read = +libc.read(this.fd, buffer, buffer.byteLength); + if (read < 0 && ctypes.errno != LIBC.EAGAIN) { + this.onError(); + } + + if (read <= 0) { + return null; + } + + if (read < buffer.byteLength) { + return ArrayBuffer.transfer(buffer, read); + } + + return buffer; + } + + /** + * Called when one of the IO operations matching the `pollEvents` mask may be + * performed without blocking. + * + * @returns {boolean} + * True if any data was successfully read. + */ + onReady() { + let result = false; + let reads = this.pending; + while (reads.length) { + let {resolve, length} = reads[0]; + + let buffer = this.readBuffer(length); + if (buffer) { + result = true; + this.shiftPending(); + resolve(buffer); + } else { + break; + } + } + + if (reads.length == 0) { + io.updatePollFds(); + } + return result; + } +} + +class OutputPipe extends Pipe { + /** + * A bit mask of poll() events which we currently wish to be notified of on + * this file discriptor. + */ + get pollEvents() { + if (this.pending.length) { + return LIBC.POLLOUT; + } + return 0; + } + + /** + * Asynchronously writes the given buffer to our file descriptor, and returns + * a promise which resolves when the operation is complete. + * + * @param {ArrayBuffer} buffer + * The buffer to write. + * + * @returns {Promise<integer>} + * Resolves to the number of bytes written when the operation is + * complete. + */ + write(buffer) { + if (this.closing || this.closed) { + throw new Error("Attempt to write to closed pipe"); + } + + return new Promise((resolve, reject) => { + this.pending.push({resolve, reject, buffer, length: buffer.byteLength}); + io.updatePollFds(); + }); + } + + /** + * Attempts to synchronously write the given buffer to our file descriptor. + * Writes only as many bytes as can be written without blocking, and returns + * the number of byes successfully written. + * + * Closes the file descriptor if an IO error occurs. + * + * @param {ArrayBuffer} buffer + * The buffer to write. + * + * @returns {integer} + * The number of bytes successfully written. + */ + writeBuffer(buffer) { + let bytesWritten = libc.write(this.fd, buffer, buffer.byteLength); + + if (bytesWritten < 0 && ctypes.errno != LIBC.EAGAIN) { + this.onError(); + } + + return bytesWritten; + } + + /** + * Called when one of the IO operations matching the `pollEvents` mask may be + * performed without blocking. + */ + onReady() { + let writes = this.pending; + while (writes.length) { + let {buffer, resolve, length} = writes[0]; + + let written = this.writeBuffer(buffer); + + if (written == buffer.byteLength) { + resolve(length); + this.shiftPending(); + } else if (written > 0) { + writes[0].buffer = buffer.slice(written); + } else { + break; + } + } + + if (writes.length == 0) { + io.updatePollFds(); + } + } +} + +class Signal { + constructor(fd) { + this.fd = fd; + } + + cleanup() { + libc.close(this.fd); + this.fd = null; + } + + get pollEvents() { + return LIBC.POLLIN; + } + + /** + * Called when an error occurred while polling our file descriptor. + */ + onError() { + io.shutdown(); + } + + /** + * Called when one of the IO operations matching the `pollEvents` mask may be + * performed without blocking. + */ + onReady() { + let buffer = new ArrayBuffer(16); + let count = +libc.read(this.fd, buffer, buffer.byteLength); + if (count > 0) { + io.messageCount += count; + } + } +} + +class Process extends BaseProcess { + /** + * Each Process object opens an additional pipe from the target object, which + * will be automatically closed when the process exits, but otherwise + * carries no data. + * + * This property contains a bit mask of poll() events which we wish to be + * notified of on this descriptor. We're not expecting any input from this + * pipe, but we need to poll for input until the process exits in order to be + * notified when the pipe closes. + */ + get pollEvents() { + if (this.exitCode === null) { + return LIBC.POLLIN; + } + return 0; + } + + /** + * Kills the process with the given signal. + * + * @param {integer} signal + */ + kill(signal) { + libc.kill(this.pid, signal); + this.wait(); + } + + /** + * Initializes the IO pipes for use as standard input, output, and error + * descriptors in the spawned process. + * + * @param {object} options + * The Subprocess options object for this process. + * @returns {unix.Fd[]} + * The array of file descriptors belonging to the spawned process. + */ + initPipes(options) { + let stderr = options.stderr; + + let our_pipes = []; + let their_pipes = new Map(); + + let pipe = input => { + let fds = ctypes.int.array(2)(); + + let res = libc.pipe(fds); + if (res == -1) { + throw new Error("Unable to create pipe"); + } + + fds = Array.from(fds, unix.Fd); + + if (input) { + fds.reverse(); + } + + if (input) { + our_pipes.push(new InputPipe(this, fds[1])); + } else { + our_pipes.push(new OutputPipe(this, fds[1])); + } + + libc.fcntl(fds[0], LIBC.F_SETFD, LIBC.FD_CLOEXEC); + libc.fcntl(fds[1], LIBC.F_SETFD, LIBC.FD_CLOEXEC); + libc.fcntl(fds[1], LIBC.F_SETFL, LIBC.O_NONBLOCK); + + return fds[0]; + }; + + their_pipes.set(0, pipe(false)); + their_pipes.set(1, pipe(true)); + + if (stderr == "pipe") { + their_pipes.set(2, pipe(true)); + } else if (stderr == "stdout") { + their_pipes.set(2, their_pipes.get(1)); + } + + // Create an additional pipe that we can use to monitor for process exit. + their_pipes.set(3, pipe(true)); + this.fd = our_pipes.pop().fd; + + this.pipes = our_pipes; + + return their_pipes; + } + + spawn(options) { + let {command, arguments: args} = options; + + let argv = this.stringArray(args); + let envp = this.stringArray(options.environment); + + let actions = unix.posix_spawn_file_actions_t(); + let actionsp = actions.address(); + + let fds = this.initPipes(options); + + let cwd; + try { + if (options.workdir) { + cwd = ctypes.char.array(LIBC.PATH_MAX)(); + libc.getcwd(cwd, cwd.length); + + if (libc.chdir(options.workdir) < 0) { + throw new Error(`Unable to change working directory to ${options.workdir}`); + } + } + + libc.posix_spawn_file_actions_init(actionsp); + for (let [i, fd] of fds.entries()) { + libc.posix_spawn_file_actions_adddup2(actionsp, fd, i); + } + + let pid = unix.pid_t(); + let rv = libc.posix_spawn(pid.address(), command, actionsp, null, argv, envp); + + if (rv != 0) { + for (let pipe of this.pipes) { + pipe.close(); + } + throw new Error(`Failed to execute command "${command}"`); + } + + this.pid = pid.value; + } finally { + libc.posix_spawn_file_actions_destroy(actionsp); + + this.stringArrays.length = 0; + + if (cwd) { + libc.chdir(cwd); + } + for (let fd of new Set(fds.values())) { + fd.dispose(); + } + } + } + + /** + * Called when input is available on our sentinel file descriptor. + * + * @see pollEvents + */ + onReady() { + // We're not actually expecting any input on this pipe. If we get any, we + // can't poll the pipe any further without reading it. + if (this.wait() == undefined) { + this.kill(9); + } + } + + /** + * Called when an error occurred while polling our sentinel file descriptor. + * + * @see pollEvents + */ + onError() { + this.wait(); + } + + /** + * Attempts to wait for the process's exit status, without blocking. If + * successful, resolves the `exitPromise` to the process's exit value. + * + * @returns {integer|null} + * The process's exit status, if it has already exited. + */ + wait() { + if (this.exitCode !== null) { + return this.exitCode; + } + + let status = ctypes.int(); + + let res = libc.waitpid(this.pid, status.address(), LIBC.WNOHANG); + if (res == this.pid) { + let sig = unix.WTERMSIG(status.value); + if (sig) { + this.exitCode = -sig; + } else { + this.exitCode = unix.WEXITSTATUS(status.value); + } + + this.fd.dispose(); + io.updatePollFds(); + this.resolveExit(this.exitCode); + return this.exitCode; + } + } +} + +io = { + pollFds: null, + pollHandlers: null, + + pipes: new Map(), + + processes: new Map(), + + messageCount: 0, + + running: true, + + init(details) { + this.signal = new Signal(details.signalFd); + this.updatePollFds(); + + setTimeout(this.loop.bind(this), 0); + }, + + shutdown() { + if (this.running) { + this.running = false; + + this.signal.cleanup(); + this.signal = null; + + self.postMessage({msg: "close"}); + self.close(); + } + }, + + getPipe(pipeId) { + let pipe = this.pipes.get(pipeId); + + if (!pipe) { + let error = new Error("File closed"); + error.errorCode = SubprocessConstants.ERROR_END_OF_FILE; + throw error; + } + return pipe; + }, + + getProcess(processId) { + let process = this.processes.get(processId); + + if (!process) { + throw new Error(`Invalid process ID: ${processId}`); + } + return process; + }, + + updatePollFds() { + let handlers = [this.signal, + ...this.pipes.values(), + ...this.processes.values()]; + + handlers = handlers.filter(handler => handler.pollEvents); + + let pollfds = unix.pollfd.array(handlers.length)(); + + for (let [i, handler] of handlers.entries()) { + let pollfd = pollfds[i]; + + pollfd.fd = handler.fd; + pollfd.events = handler.pollEvents; + pollfd.revents = 0; + } + + this.pollFds = pollfds; + this.pollHandlers = handlers; + }, + + loop() { + this.poll(); + if (this.running) { + setTimeout(this.loop.bind(this), 0); + } + }, + + poll() { + let handlers = this.pollHandlers; + let pollfds = this.pollFds; + + let timeout = this.messageCount > 0 ? 0 : POLL_TIMEOUT; + let count = libc.poll(pollfds, pollfds.length, timeout); + + for (let i = 0; count && i < pollfds.length; i++) { + let pollfd = pollfds[i]; + if (pollfd.revents) { + count--; + + let handler = handlers[i]; + try { + let success = false; + if (pollfd.revents & handler.pollEvents) { + success = handler.onReady(); + } + // Only call the error handler in this iteration if we didn't also + // have a success. This is necessary because Linux systems set POLLHUP + // on a pipe when it's closed but there's still buffered data to be + // read, and Darwin sets POLLIN and POLLHUP on a closed pipe, even + // when there's no data to be read. + if (!success && (pollfd.revents & (LIBC.POLLERR | LIBC.POLLHUP | LIBC.POLLNVAL))) { + handler.onError(); + } + } catch (e) { + console.error(e); + debug(`Worker error: ${e} :: ${e.stack}`); + handler.onError(); + } + + pollfd.revents = 0; + } + } + }, + + addProcess(process) { + this.processes.set(process.id, process); + + for (let pipe of process.pipes) { + this.pipes.set(pipe.id, pipe); + } + }, + + cleanupProcess(process) { + this.processes.delete(process.id); + }, +}; diff --git a/toolkit/modules/subprocess/subprocess_worker_win.js b/toolkit/modules/subprocess/subprocess_worker_win.js new file mode 100644 index 0000000000..eec5232544 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_worker_win.js @@ -0,0 +1,708 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +/* exported Process */ +/* globals BaseProcess, BasePipe, win32 */ + +importScripts("resource://gre/modules/subprocess/subprocess_shared.js", + "resource://gre/modules/subprocess/subprocess_shared_win.js", + "resource://gre/modules/subprocess/subprocess_worker_common.js"); + +const POLL_TIMEOUT = 5000; + +// The exit code that we send when we forcibly terminate a process. +const TERMINATE_EXIT_CODE = 0x7f; + +let io; + +let nextPipeId = 0; + +class Pipe extends BasePipe { + constructor(process, origHandle) { + super(); + + let handle = win32.HANDLE(); + + let curProc = libc.GetCurrentProcess(); + libc.DuplicateHandle(curProc, origHandle, curProc, handle.address(), + 0, false /* inheritable */, win32.DUPLICATE_SAME_ACCESS); + + origHandle.dispose(); + + this.id = nextPipeId++; + this.process = process; + + this.handle = win32.Handle(handle); + + let event = libc.CreateEventW(null, false, false, null); + + this.overlapped = win32.OVERLAPPED(); + this.overlapped.hEvent = event; + + this._event = win32.Handle(event); + + this.buffer = null; + } + + get event() { + if (this.pending.length) { + return this._event; + } + return null; + } + + maybeClose() {} + + /** + * Closes the file handle. + * + * @param {boolean} [force=false] + * If true, the file handle is closed immediately. If false, the + * file handle is closed after all current pending IO operations + * have completed. + * + * @returns {Promise<void>} + * Resolves when the file handle has been closed. + */ + close(force = false) { + if (!force && this.pending.length) { + this.closing = true; + return this.closedPromise; + } + + for (let {reject} of this.pending) { + let error = new Error("File closed"); + error.errorCode = SubprocessConstants.ERROR_END_OF_FILE; + reject(error); + } + this.pending.length = 0; + + this.buffer = null; + + if (!this.closed) { + this.handle.dispose(); + this._event.dispose(); + + io.pipes.delete(this.id); + + this.handle = null; + this.closed = true; + this.resolveClosed(); + + io.updatePollEvents(); + } + return this.closedPromise; + } + + /** + * Called when an error occurred while attempting an IO operation on our file + * handle. + */ + onError() { + this.close(true); + } +} + +class InputPipe extends Pipe { + /** + * Queues the next chunk of data to be read from the pipe if, and only if, + * there is no IO operation currently pending. + */ + readNext() { + if (this.buffer === null) { + this.readBuffer(this.pending[0].length); + } + } + + /** + * Closes the pipe if there is a pending read operation with no more + * buffered data to be read. + */ + maybeClose() { + if (this.buffer) { + let read = win32.DWORD(); + + let ok = libc.GetOverlappedResult( + this.handle, this.overlapped.address(), + read.address(), false); + + if (!ok) { + this.onError(); + } + } + } + + /** + * Asynchronously reads at most `length` bytes of binary data from the file + * descriptor into an ArrayBuffer of the same size. Returns a promise which + * resolves when the operation is complete. + * + * @param {integer} length + * The number of bytes to read. + * + * @returns {Promise<ArrayBuffer>} + */ + read(length) { + if (this.closing || this.closed) { + throw new Error("Attempt to read from closed pipe"); + } + + return new Promise((resolve, reject) => { + this.pending.push({resolve, reject, length}); + this.readNext(); + }); + } + + /** + * Initializes an overlapped IO read operation to read exactly `count` bytes + * into a new ArrayBuffer, which is stored in the `buffer` property until the + * operation completes. + * + * @param {integer} count + * The number of bytes to read. + */ + readBuffer(count) { + this.buffer = new ArrayBuffer(count); + + let ok = libc.ReadFile(this.handle, this.buffer, count, + null, this.overlapped.address()); + + if (!ok && (!this.process.handle || libc.winLastError)) { + this.onError(); + } else { + io.updatePollEvents(); + } + } + + /** + * Called when our pending overlapped IO operation has completed, whether + * successfully or in failure. + */ + onReady() { + let read = win32.DWORD(); + + let ok = libc.GetOverlappedResult( + this.handle, this.overlapped.address(), + read.address(), false); + + read = read.value; + + if (!ok) { + this.onError(); + } else if (read > 0) { + let buffer = this.buffer; + this.buffer = null; + + let {resolve} = this.shiftPending(); + + if (read == buffer.byteLength) { + resolve(buffer); + } else { + resolve(ArrayBuffer.transfer(buffer, read)); + } + + if (this.pending.length) { + this.readNext(); + } else { + io.updatePollEvents(); + } + } + } +} + +class OutputPipe extends Pipe { + /** + * Queues the next chunk of data to be written to the pipe if, and only if, + * there is no IO operation currently pending. + */ + writeNext() { + if (this.buffer === null) { + this.writeBuffer(this.pending[0].buffer); + } + } + + /** + * Asynchronously writes the given buffer to our file descriptor, and returns + * a promise which resolves when the operation is complete. + * + * @param {ArrayBuffer} buffer + * The buffer to write. + * + * @returns {Promise<integer>} + * Resolves to the number of bytes written when the operation is + * complete. + */ + write(buffer) { + if (this.closing || this.closed) { + throw new Error("Attempt to write to closed pipe"); + } + + return new Promise((resolve, reject) => { + this.pending.push({resolve, reject, buffer}); + this.writeNext(); + }); + } + + /** + * Initializes an overapped IO read operation to write the data in `buffer` to + * our file descriptor. + * + * @param {ArrayBuffer} buffer + * The buffer to write. + */ + writeBuffer(buffer) { + this.buffer = buffer; + + let ok = libc.WriteFile(this.handle, buffer, buffer.byteLength, + null, this.overlapped.address()); + + if (!ok && libc.winLastError) { + this.onError(); + } else { + io.updatePollEvents(); + } + } + + /** + * Called when our pending overlapped IO operation has completed, whether + * successfully or in failure. + */ + onReady() { + let written = win32.DWORD(); + + let ok = libc.GetOverlappedResult( + this.handle, this.overlapped.address(), + written.address(), false); + + written = written.value; + + if (!ok || written != this.buffer.byteLength) { + this.onError(); + } else if (written > 0) { + let {resolve} = this.shiftPending(); + + this.buffer = null; + resolve(written); + + if (this.pending.length) { + this.writeNext(); + } else { + io.updatePollEvents(); + } + } + } +} + +class Signal { + constructor(event) { + this.event = event; + } + + cleanup() { + libc.CloseHandle(this.event); + this.event = null; + } + + onError() { + io.shutdown(); + } + + onReady() { + io.messageCount += 1; + } +} + +class Process extends BaseProcess { + constructor(...args) { + super(...args); + + this.killed = false; + } + + /** + * Returns our process handle for use as an event in a WaitForMultipleObjects + * call. + */ + get event() { + return this.handle; + } + + /** + * Forcibly terminates the process. + */ + kill() { + this.killed = true; + libc.TerminateJobObject(this.jobHandle, TERMINATE_EXIT_CODE); + } + + /** + * Initializes the IO pipes for use as standard input, output, and error + * descriptors in the spawned process. + * + * @returns {win32.Handle[]} + * The array of file handles belonging to the spawned process. + */ + initPipes({stderr}) { + let our_pipes = []; + let their_pipes = []; + + let secAttr = new win32.SECURITY_ATTRIBUTES(); + secAttr.nLength = win32.SECURITY_ATTRIBUTES.size; + secAttr.bInheritHandle = true; + + let pipe = input => { + if (input) { + let handles = win32.createPipe(secAttr, win32.FILE_FLAG_OVERLAPPED); + our_pipes.push(new InputPipe(this, handles[0])); + return handles[1]; + } + let handles = win32.createPipe(secAttr, 0, win32.FILE_FLAG_OVERLAPPED); + our_pipes.push(new OutputPipe(this, handles[1])); + return handles[0]; + }; + + their_pipes[0] = pipe(false); + their_pipes[1] = pipe(true); + + if (stderr == "pipe") { + their_pipes[2] = pipe(true); + } else { + let srcHandle; + if (stderr == "stdout") { + srcHandle = their_pipes[1]; + } else { + srcHandle = libc.GetStdHandle(win32.STD_ERROR_HANDLE); + } + + let handle = win32.HANDLE(); + + let curProc = libc.GetCurrentProcess(); + let ok = libc.DuplicateHandle(curProc, srcHandle, curProc, handle.address(), + 0, true /* inheritable */, + win32.DUPLICATE_SAME_ACCESS); + + their_pipes[2] = ok && win32.Handle(handle); + } + + if (!their_pipes.every(handle => handle)) { + throw new Error("Failed to create pipe"); + } + + this.pipes = our_pipes; + + return their_pipes; + } + + /** + * Creates a null-separated, null-terminated string list. + * + * @param {Array<string>} strings + * @returns {win32.WCHAR.array} + */ + stringList(strings) { + // Remove empty strings, which would terminate the list early. + strings = strings.filter(string => string); + + let string = strings.join("\0") + "\0\0"; + + return win32.WCHAR.array()(string); + } + + /** + * Quotes a string for use as a single command argument, using Windows quoting + * conventions. + * + * @see https://msdn.microsoft.com/en-us/library/17w5ykft(v=vs.85).aspx + * + * @param {string} str + * The argument string to quote. + * @returns {string} + */ + quoteString(str) { + if (!/[\s"]/.test(str)) { + return str; + } + + let escaped = str.replace(/(\\*)("|$)/g, (m0, m1, m2) => { + if (m2) { + m2 = `\\${m2}`; + } + return `${m1}${m1}${m2}`; + }); + + return `"${escaped}"`; + } + + spawn(options) { + let {command, arguments: args} = options; + + if (/\\cmd\.exe$/i.test(command) && args.length == 3 && /^(\/S)?\/C$/i.test(args[1])) { + // cmd.exe is insane and requires special treatment. + args = [this.quoteString(args[0]), "/S/C", `"${args[2]}"`]; + } else { + args = args.map(arg => this.quoteString(arg)); + } + + if (/\.bat$/i.test(command)) { + command = io.comspec; + args = ["cmd.exe", "/s/c", `"${args.join(" ")}"`]; + } + + let envp = this.stringList(options.environment); + + let handles = this.initPipes(options); + + let processFlags = win32.CREATE_NO_WINDOW + | win32.CREATE_SUSPENDED + | win32.CREATE_UNICODE_ENVIRONMENT; + + if (io.breakAwayFromJob) { + processFlags |= win32.CREATE_BREAKAWAY_FROM_JOB; + } + + let startupInfoEx = new win32.STARTUPINFOEXW(); + let startupInfo = startupInfoEx.StartupInfo; + + startupInfo.cb = win32.STARTUPINFOW.size; + startupInfo.dwFlags = win32.STARTF_USESTDHANDLES; + + startupInfo.hStdInput = handles[0]; + startupInfo.hStdOutput = handles[1]; + startupInfo.hStdError = handles[2]; + + // Note: This needs to be kept alive until we destroy the attribute list. + let handleArray = win32.HANDLE.array()(handles); + + let threadAttrs = win32.createThreadAttributeList(handleArray); + if (threadAttrs) { + // If have thread attributes to pass, pass the size of the full extended + // startup info struct. + processFlags |= win32.EXTENDED_STARTUPINFO_PRESENT; + startupInfo.cb = win32.STARTUPINFOEXW.size; + + startupInfoEx.lpAttributeList = threadAttrs; + } + + let procInfo = new win32.PROCESS_INFORMATION(); + + let errorMessage = "Failed to create process"; + let ok = libc.CreateProcessW( + command, args.join(" "), + null, /* Security attributes */ + null, /* Thread security attributes */ + true, /* Inherits handles */ + processFlags, envp, options.workdir, + startupInfo.address(), + procInfo.address()); + + for (let handle of new Set(handles)) { + handle.dispose(); + } + + if (threadAttrs) { + libc.DeleteProcThreadAttributeList(threadAttrs); + } + + if (ok) { + this.jobHandle = win32.Handle(libc.CreateJobObjectW(null, null)); + + let info = win32.JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + info.BasicLimitInformation.LimitFlags = win32.JOB_OBJECT_LIMIT_BREAKAWAY_OK; + + ok = libc.SetInformationJobObject(this.jobHandle, win32.JobObjectExtendedLimitInformation, + ctypes.cast(info.address(), ctypes.voidptr_t), + info.constructor.size); + errorMessage = `Failed to set job limits: 0x${(ctypes.winLastError || 0).toString(16)}`; + } + + if (ok) { + ok = libc.AssignProcessToJobObject(this.jobHandle, procInfo.hProcess); + if (!ok) { + errorMessage = `Failed to attach process to job object: 0x${(ctypes.winLastError || 0).toString(16)}`; + libc.TerminateProcess(procInfo.hProcess, TERMINATE_EXIT_CODE); + } + } + + if (!ok) { + for (let pipe of this.pipes) { + pipe.close(); + } + throw new Error(errorMessage); + } + + this.handle = win32.Handle(procInfo.hProcess); + this.pid = procInfo.dwProcessId; + + libc.ResumeThread(procInfo.hThread); + libc.CloseHandle(procInfo.hThread); + } + + /** + * Called when our process handle is signaled as active, meaning the process + * has exited. + */ + onReady() { + this.wait(); + } + + /** + * Attempts to wait for the process's exit status, without blocking. If + * successful, resolves the `exitPromise` to the process's exit value. + * + * @returns {integer|null} + * The process's exit status, if it has already exited. + */ + wait() { + if (this.exitCode !== null) { + return this.exitCode; + } + + let status = win32.DWORD(); + + let ok = libc.GetExitCodeProcess(this.handle, status.address()); + if (ok && status.value != win32.STILL_ACTIVE) { + let exitCode = status.value; + if (this.killed && exitCode == TERMINATE_EXIT_CODE) { + // If we forcibly terminated the process, return the force kill exit + // code that we return on other platforms. + exitCode = -9; + } + + this.resolveExit(exitCode); + this.exitCode = exitCode; + + this.handle.dispose(); + this.handle = null; + + libc.TerminateJobObject(this.jobHandle, TERMINATE_EXIT_CODE); + this.jobHandle.dispose(); + this.jobHandle = null; + + for (let pipe of this.pipes) { + pipe.maybeClose(); + } + + io.updatePollEvents(); + + return exitCode; + } + } +} + +io = { + events: null, + eventHandlers: null, + + pipes: new Map(), + + processes: new Map(), + + messageCount: 0, + + running: true, + + init(details) { + this.comspec = details.comspec; + + let signalEvent = ctypes.cast(ctypes.uintptr_t(details.signalEvent), + win32.HANDLE); + this.signal = new Signal(signalEvent); + this.updatePollEvents(); + + this.breakAwayFromJob = details.breakAwayFromJob; + + setTimeout(this.loop.bind(this), 0); + }, + + shutdown() { + if (this.running) { + this.running = false; + + this.signal.cleanup(); + this.signal = null; + + self.postMessage({msg: "close"}); + self.close(); + } + }, + + getPipe(pipeId) { + let pipe = this.pipes.get(pipeId); + + if (!pipe) { + let error = new Error("File closed"); + error.errorCode = SubprocessConstants.ERROR_END_OF_FILE; + throw error; + } + return pipe; + }, + + getProcess(processId) { + let process = this.processes.get(processId); + + if (!process) { + throw new Error(`Invalid process ID: ${processId}`); + } + return process; + }, + + updatePollEvents() { + let handlers = [this.signal, + ...this.pipes.values(), + ...this.processes.values()]; + + handlers = handlers.filter(handler => handler.event); + + this.eventHandlers = handlers; + + let handles = handlers.map(handler => handler.event); + this.events = win32.HANDLE.array()(handles); + }, + + loop() { + this.poll(); + if (this.running) { + setTimeout(this.loop.bind(this), 0); + } + }, + + + poll() { + let timeout = this.messageCount > 0 ? 0 : POLL_TIMEOUT; + for (;; timeout = 0) { + let events = this.events; + let handlers = this.eventHandlers; + + let result = libc.WaitForMultipleObjects(events.length, events, + false, timeout); + + if (result < handlers.length) { + try { + handlers[result].onReady(); + } catch (e) { + console.error(e); + debug(`Worker error: ${e} :: ${e.stack}`); + handlers[result].onError(); + } + } else { + break; + } + } + }, + + addProcess(process) { + this.processes.set(process.id, process); + + for (let pipe of process.pipes) { + this.pipes.set(pipe.id, pipe); + } + }, + + cleanupProcess(process) { + this.processes.delete(process.id); + }, +}; diff --git a/toolkit/modules/subprocess/test/xpcshell/.eslintrc.js b/toolkit/modules/subprocess/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..fc63a79b75 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/.eslintrc.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": "../../../../../testing/xpcshell/xpcshell.eslintrc.js", +}; diff --git a/toolkit/modules/subprocess/test/xpcshell/data_test_script.py b/toolkit/modules/subprocess/test/xpcshell/data_test_script.py new file mode 100644 index 0000000000..035d8ac569 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/data_test_script.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python2 +from __future__ import print_function + +import os +import signal +import struct +import sys + + +def output(line): + sys.stdout.write(struct.pack('@I', len(line))) + sys.stdout.write(line) + sys.stdout.flush() + + +def echo_loop(): + while True: + line = sys.stdin.readline() + if not line: + break + + output(line) + + +if sys.platform == "win32": + import msvcrt + msvcrt.setmode(sys.stderr.fileno(), os.O_BINARY) + + +cmd = sys.argv[1] +if cmd == 'echo': + echo_loop() +elif cmd == 'exit': + sys.exit(int(sys.argv[2])) +elif cmd == 'env': + for var in sys.argv[2:]: + output(os.environ.get(var, '')) +elif cmd == 'pwd': + output(os.path.abspath(os.curdir)) +elif cmd == 'print_args': + for arg in sys.argv[2:]: + output(arg) +elif cmd == 'ignore_sigterm': + signal.signal(signal.SIGTERM, signal.SIG_IGN) + + output('Ready') + while True: + try: + signal.pause() + except AttributeError: + import time + time.sleep(3600) +elif cmd == 'print': + sys.stdout.write(sys.argv[2]) + sys.stderr.write(sys.argv[3]) diff --git a/toolkit/modules/subprocess/test/xpcshell/data_text_file.txt b/toolkit/modules/subprocess/test/xpcshell/data_text_file.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/data_text_file.txt diff --git a/toolkit/modules/subprocess/test/xpcshell/head.js b/toolkit/modules/subprocess/test/xpcshell/head.js new file mode 100644 index 0000000000..b3175d08a0 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/head.js @@ -0,0 +1,14 @@ +"use strict"; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Subprocess", + "resource://gre/modules/Subprocess.jsm"); diff --git a/toolkit/modules/subprocess/test/xpcshell/test_subprocess.js b/toolkit/modules/subprocess/test/xpcshell/test_subprocess.js new file mode 100644 index 0000000000..1b8e02820b --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/test_subprocess.js @@ -0,0 +1,769 @@ +"use strict"; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); + + +const env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + +const MAX_ROUND_TRIP_TIME_MS = AppConstants.DEBUG || AppConstants.ASAN ? 18 : 9; +const MAX_RETRIES = 5; + +let PYTHON; +let PYTHON_BIN; +let PYTHON_DIR; + +const TEST_SCRIPT = do_get_file("data_test_script.py").path; + +let read = pipe => { + return pipe.readUint32().then(count => { + return pipe.readString(count); + }); +}; + + +let readAll = Task.async(function* (pipe) { + let result = []; + let string; + while ((string = yield pipe.readString())) { + result.push(string); + } + + return result.join(""); +}); + + +add_task(function* setup() { + PYTHON = yield Subprocess.pathSearch(env.get("PYTHON")); + + PYTHON_BIN = OS.Path.basename(PYTHON); + PYTHON_DIR = OS.Path.dirname(PYTHON); +}); + + +add_task(function* test_subprocess_io() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + Assert.throws(() => { proc.stdout.read(-1); }, + /non-negative integer/); + Assert.throws(() => { proc.stdout.read(1.1); }, + /non-negative integer/); + + Assert.throws(() => { proc.stdout.read(Infinity); }, + /non-negative integer/); + Assert.throws(() => { proc.stdout.read(NaN); }, + /non-negative integer/); + + Assert.throws(() => { proc.stdout.readString(-1); }, + /non-negative integer/); + Assert.throws(() => { proc.stdout.readString(1.1); }, + /non-negative integer/); + + Assert.throws(() => { proc.stdout.readJSON(-1); }, + /positive integer/); + Assert.throws(() => { proc.stdout.readJSON(0); }, + /positive integer/); + Assert.throws(() => { proc.stdout.readJSON(1.1); }, + /positive integer/); + + + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + + let outputPromise = read(proc.stdout); + + yield new Promise(resolve => setTimeout(resolve, 100)); + + let [output] = yield Promise.all([ + outputPromise, + proc.stdin.write(LINE1), + ]); + + equal(output, LINE1, "Got expected output"); + + + // Make sure it succeeds whether the write comes before or after the + // read. + let inputPromise = proc.stdin.write(LINE2); + + yield new Promise(resolve => setTimeout(resolve, 100)); + + [output] = yield Promise.all([ + read(proc.stdout), + inputPromise, + ]); + + equal(output, LINE2, "Got expected output"); + + + let JSON_BLOB = {foo: {bar: "baz"}}; + + inputPromise = proc.stdin.write(JSON.stringify(JSON_BLOB) + "\n"); + + output = yield proc.stdout.readUint32().then(count => { + return proc.stdout.readJSON(count); + }); + + Assert.deepEqual(output, JSON_BLOB, "Got expected JSON output"); + + + yield proc.stdin.close(); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_large_io() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + const LINE = "I'm a leaf on the wind.\n"; + const BUFFER_SIZE = 4096; + + // Create a message that's ~3/4 the input buffer size. + let msg = Array(BUFFER_SIZE * .75 / 16 | 0).fill("0123456789abcdef").join("") + "\n"; + + // This sequence of writes and reads crosses several buffer size + // boundaries, and causes some branches of the read buffer code to be + // exercised which are not exercised by other tests. + proc.stdin.write(msg); + proc.stdin.write(msg); + proc.stdin.write(LINE); + + let output = yield read(proc.stdout); + equal(output, msg, "Got the expected output"); + + output = yield read(proc.stdout); + equal(output, msg, "Got the expected output"); + + output = yield read(proc.stdout); + equal(output, LINE, "Got the expected output"); + + proc.stdin.close(); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_huge() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + // This should be large enough to fill most pipe input/output buffers. + const MESSAGE_SIZE = 1024 * 16; + + let msg = Array(MESSAGE_SIZE).fill("0123456789abcdef").join("") + "\n"; + + proc.stdin.write(msg); + + let output = yield read(proc.stdout); + equal(output, msg, "Got the expected output"); + + proc.stdin.close(); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_round_trip_perf() { + let roundTripTime = Infinity; + for (let i = 0; i < MAX_RETRIES && roundTripTime > MAX_ROUND_TRIP_TIME_MS; i++) { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + + const LINE = "I'm a leaf on the wind.\n"; + + let now = Date.now(); + const COUNT = 1000; + for (let j = 0; j < COUNT; j++) { + let [output] = yield Promise.all([ + read(proc.stdout), + proc.stdin.write(LINE), + ]); + + // We don't want to log this for every iteration, but we still need + // to fail if it goes wrong. + if (output !== LINE) { + equal(output, LINE, "Got expected output"); + } + } + + roundTripTime = (Date.now() - now) / COUNT; + + yield proc.stdin.close(); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); + } + + ok(roundTripTime <= MAX_ROUND_TRIP_TIME_MS, + `Expected round trip time (${roundTripTime}ms) to be less than ${MAX_ROUND_TRIP_TIME_MS}ms`); +}); + + +add_task(function* test_subprocess_stderr_default() { + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print", LINE1, LINE2], + }); + + equal(proc.stderr, undefined, "There should be no stderr pipe by default"); + + let stdout = yield readAll(proc.stdout); + + equal(stdout, LINE1, "Got the expected stdout output"); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_stderr_pipe() { + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print", LINE1, LINE2], + stderr: "pipe", + }); + + let [stdout, stderr] = yield Promise.all([ + readAll(proc.stdout), + readAll(proc.stderr), + ]); + + equal(stdout, LINE1, "Got the expected stdout output"); + equal(stderr, LINE2, "Got the expected stderr output"); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_stderr_merged() { + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print", LINE1, LINE2], + stderr: "stdout", + }); + + equal(proc.stderr, undefined, "There should be no stderr pipe by default"); + + let stdout = yield readAll(proc.stdout); + + equal(stdout, LINE1 + LINE2, "Got the expected merged stdout output"); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_read_after_exit() { + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print", LINE1, LINE2], + stderr: "pipe", + }); + + + let {exitCode} = yield proc.wait(); + equal(exitCode, 0, "Process exited with expected code"); + + + let [stdout, stderr] = yield Promise.all([ + readAll(proc.stdout), + readAll(proc.stderr), + ]); + + equal(stdout, LINE1, "Got the expected stdout output"); + equal(stderr, LINE2, "Got the expected stderr output"); +}); + + +add_task(function* test_subprocess_lazy_close_output() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let writePromises = [ + proc.stdin.write(LINE1), + proc.stdin.write(LINE2), + ]; + let closedPromise = proc.stdin.close(); + + + let output1 = yield read(proc.stdout); + let output2 = yield read(proc.stdout); + + yield Promise.all([...writePromises, closedPromise]); + + equal(output1, LINE1, "Got expected output"); + equal(output2, LINE2, "Got expected output"); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_lazy_close_input() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + let readPromise = proc.stdout.readUint32(); + let closedPromise = proc.stdout.close(); + + + const LINE = "I'm a leaf on the wind.\n"; + + proc.stdin.write(LINE); + proc.stdin.close(); + + let len = yield readPromise; + equal(len, LINE.length); + + yield closedPromise; + + + // Don't test for a successful exit here. The process may exit with a + // write error if we close the pipe after it's written the message + // size but before it's written the message. + yield proc.wait(); +}); + + +add_task(function* test_subprocess_force_close() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + let readPromise = proc.stdout.readUint32(); + let closedPromise = proc.stdout.close(true); + + yield Assert.rejects( + readPromise, + function(e) { + equal(e.errorCode, Subprocess.ERROR_END_OF_FILE, + "Got the expected error code"); + return /File closed/.test(e.message); + }, + "Promise should be rejected when file is closed"); + + yield closedPromise; + yield proc.stdin.close(); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_eof() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + let readPromise = proc.stdout.readUint32(); + + yield proc.stdin.close(); + + yield Assert.rejects( + readPromise, + function(e) { + equal(e.errorCode, Subprocess.ERROR_END_OF_FILE, + "Got the expected error code"); + return /File closed/.test(e.message); + }, + "Promise should be rejected on EOF"); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_invalid_json() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + const LINE = "I'm a leaf on the wind.\n"; + + proc.stdin.write(LINE); + proc.stdin.close(); + + let count = yield proc.stdout.readUint32(); + let readPromise = proc.stdout.readJSON(count); + + yield Assert.rejects( + readPromise, + function(e) { + equal(e.errorCode, Subprocess.ERROR_INVALID_JSON, + "Got the expected error code"); + return /SyntaxError/.test(e); + }, + "Promise should be rejected on EOF"); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +if (AppConstants.isPlatformAndVersionAtLeast("win", "6")) { + add_task(function* test_subprocess_inherited_descriptors() { + let {ctypes, libc, win32} = Cu.import("resource://gre/modules/subprocess/subprocess_win.jsm"); + + let secAttr = new win32.SECURITY_ATTRIBUTES(); + secAttr.nLength = win32.SECURITY_ATTRIBUTES.size; + secAttr.bInheritHandle = true; + + let handles = win32.createPipe(secAttr, 0); + + + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + + // Close the output end of the pipe. + // Ours should be the only copy, so reads should fail after this. + handles[1].dispose(); + + let buffer = new ArrayBuffer(1); + let succeeded = libc.ReadFile(handles[0], buffer, buffer.byteLength, + null, null); + + ok(!succeeded, "ReadFile should fail on broken pipe"); + equal(ctypes.winLastError, win32.ERROR_BROKEN_PIPE, "Read should fail with ERROR_BROKEN_PIPE"); + + + proc.stdin.close(); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); + }); +} + + +add_task(function* test_subprocess_wait() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "exit", "42"], + }); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 42, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_pathSearch() { + let promise = Subprocess.call({ + command: PYTHON_BIN, + arguments: ["-u", TEST_SCRIPT, "exit", "13"], + environment: { + PATH: PYTHON_DIR, + }, + }); + + yield Assert.rejects( + promise, + function(error) { + return error.errorCode == Subprocess.ERROR_BAD_EXECUTABLE; + }, + "Subprocess.call should fail for a bad executable"); +}); + + +add_task(function* test_subprocess_workdir() { + let procDir = yield OS.File.getCurrentDirectory(); + let tmpDirFile = Components.classes["@mozilla.org/file/local;1"] + .createInstance(Components.interfaces.nsILocalFile); + tmpDirFile.initWithPath(OS.Constants.Path.tmpDir); + tmpDirFile.normalize(); + let tmpDir = tmpDirFile.path; + + notEqual(procDir, tmpDir, + "Current process directory must not be the current temp directory"); + + function* pwd(options) { + let proc = yield Subprocess.call(Object.assign({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "pwd"], + }, options)); + + let pwdOutput = read(proc.stdout); + + let {exitCode} = yield proc.wait(); + equal(exitCode, 0, "Got expected exit code"); + + return pwdOutput; + } + + let dir = yield pwd({}); + equal(dir, procDir, "Process should normally launch in current process directory"); + + dir = yield pwd({workdir: tmpDir}); + equal(dir, tmpDir, "Process should launch in the directory specified in `workdir`"); + + dir = yield OS.File.getCurrentDirectory(); + equal(dir, procDir, "`workdir` should not change the working directory of the current process"); +}); + + +add_task(function* test_subprocess_term() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + // Windows does not support killing processes gracefully, so they will + // always exit with -9 there. + let retVal = AppConstants.platform == "win" ? -9 : -15; + + // Kill gracefully with the default timeout of 300ms. + let {exitCode} = yield proc.kill(); + + equal(exitCode, retVal, "Got expected exit code"); + + ({exitCode} = yield proc.wait()); + + equal(exitCode, retVal, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_kill() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + // Force kill with no gracefull termination timeout. + let {exitCode} = yield proc.kill(0); + + equal(exitCode, -9, "Got expected exit code"); + + ({exitCode} = yield proc.wait()); + + equal(exitCode, -9, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_kill_timeout() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "ignore_sigterm"], + }); + + // Wait for the process to set up its signal handler and tell us it's + // ready. + let msg = yield read(proc.stdout); + equal(msg, "Ready", "Process is ready"); + + // Kill gracefully with the default timeout of 300ms. + // Expect a force kill after 300ms, since the process traps SIGTERM. + const TIMEOUT = 300; + let startTime = Date.now(); + + let {exitCode} = yield proc.kill(TIMEOUT); + + // Graceful termination is not supported on Windows, so don't bother + // testing the timeout there. + if (AppConstants.platform != "win") { + let diff = Date.now() - startTime; + ok(diff >= TIMEOUT, `Process was killed after ${diff}ms (expected ~${TIMEOUT}ms)`); + } + + equal(exitCode, -9, "Got expected exit code"); + + ({exitCode} = yield proc.wait()); + + equal(exitCode, -9, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_arguments() { + let args = [ + String.raw`C:\Program Files\Company\Program.exe`, + String.raw`\\NETWORK SHARE\Foo Directory${"\\"}`, + String.raw`foo bar baz`, + String.raw`"foo bar baz"`, + String.raw`foo " bar`, + String.raw`Thing \" with "" "\" \\\" \\\\" quotes\\" \\`, + ]; + + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print_args", ...args], + }); + + for (let [i, arg] of args.entries()) { + let val = yield read(proc.stdout); + equal(val, arg, `Got correct value for args[${i}]`); + } + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +// Windows XP can't handle launching Python with a partial environment. +if (!AppConstants.isPlatformAndVersionAtMost("win", "5.2")) { + add_task(function* test_subprocess_environment() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "env", "PATH", "FOO"], + environment: { + FOO: "BAR", + }, + }); + + let path = yield read(proc.stdout); + let foo = yield read(proc.stdout); + + equal(path, "", "Got expected $PATH value"); + equal(foo, "BAR", "Got expected $FOO value"); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); + }); +} + + +add_task(function* test_subprocess_environmentAppend() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "env", "PATH", "FOO"], + environmentAppend: true, + environment: { + FOO: "BAR", + }, + }); + + let path = yield read(proc.stdout); + let foo = yield read(proc.stdout); + + equal(path, env.get("PATH"), "Got expected $PATH value"); + equal(foo, "BAR", "Got expected $FOO value"); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); + + proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "env", "PATH", "FOO"], + environmentAppend: true, + }); + + path = yield read(proc.stdout); + foo = yield read(proc.stdout); + + equal(path, env.get("PATH"), "Got expected $PATH value"); + equal(foo, "", "Got expected $FOO value"); + + ({exitCode} = yield proc.wait()); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_bad_executable() { + // Test with a non-executable file. + + let textFile = do_get_file("data_text_file.txt").path; + + let promise = Subprocess.call({ + command: textFile, + arguments: [], + }); + + yield Assert.rejects( + promise, + function(error) { + if (AppConstants.platform == "win") { + return /Failed to create process/.test(error.message); + } + return error.errorCode == Subprocess.ERROR_BAD_EXECUTABLE; + }, + "Subprocess.call should fail for a bad executable"); + + // Test with a nonexistent file. + promise = Subprocess.call({ + command: textFile + ".doesNotExist", + arguments: [], + }); + + yield Assert.rejects( + promise, + function(error) { + return error.errorCode == Subprocess.ERROR_BAD_EXECUTABLE; + }, + "Subprocess.call should fail for a bad executable"); +}); + + +add_task(function* test_cleanup() { + let {SubprocessImpl} = Cu.import("resource://gre/modules/Subprocess.jsm"); + + let worker = SubprocessImpl.Process.getWorker(); + + let openFiles = yield worker.call("getOpenFiles", []); + let processes = yield worker.call("getProcesses", []); + + equal(openFiles.size, 0, "No remaining open files"); + equal(processes.size, 0, "No remaining processes"); +}); diff --git a/toolkit/modules/subprocess/test/xpcshell/test_subprocess_getEnvironment.js b/toolkit/modules/subprocess/test/xpcshell/test_subprocess_getEnvironment.js new file mode 100644 index 0000000000..4606aec04e --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/test_subprocess_getEnvironment.js @@ -0,0 +1,17 @@ +"use strict"; + +let env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + +add_task(function* test_getEnvironment() { + env.set("FOO", "BAR"); + + let environment = Subprocess.getEnvironment(); + + equal(environment.FOO, "BAR"); + equal(environment.PATH, env.get("PATH")); + + env.set("FOO", null); + + environment = Subprocess.getEnvironment(); + equal(environment.FOO || "", ""); +}); diff --git a/toolkit/modules/subprocess/test/xpcshell/test_subprocess_pathSearch.js b/toolkit/modules/subprocess/test/xpcshell/test_subprocess_pathSearch.js new file mode 100644 index 0000000000..5eb4cd412d --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/test_subprocess_pathSearch.js @@ -0,0 +1,73 @@ +"use strict"; + +let envService = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + +const PYTHON = envService.get("PYTHON"); + +const PYTHON_BIN = OS.Path.basename(PYTHON); +const PYTHON_DIR = OS.Path.dirname(PYTHON); + +const DOES_NOT_EXIST = OS.Path.join(OS.Constants.Path.tmpDir, + "ThisPathDoesNotExist"); + +const PATH_SEP = AppConstants.platform == "win" ? ";" : ":"; + + +add_task(function* test_pathSearchAbsolute() { + let env = {}; + + let path = yield Subprocess.pathSearch(PYTHON, env); + equal(path, PYTHON, "Full path resolves even with no PATH."); + + env.PATH = ""; + path = yield Subprocess.pathSearch(PYTHON, env); + equal(path, PYTHON, "Full path resolves even with empty PATH."); + + yield Assert.rejects( + Subprocess.pathSearch(DOES_NOT_EXIST, env), + function(e) { + equal(e.errorCode, Subprocess.ERROR_BAD_EXECUTABLE, + "Got the expected error code"); + return /File at path .* does not exist, or is not (executable|a normal file)/.test(e.message); + }, + "Absolute path should throw for a nonexistent execuable"); +}); + + +add_task(function* test_pathSearchRelative() { + let env = {}; + + yield Assert.rejects( + Subprocess.pathSearch(PYTHON_BIN, env), + function(e) { + equal(e.errorCode, Subprocess.ERROR_BAD_EXECUTABLE, + "Got the expected error code"); + return /Executable not found:/.test(e.message); + }, + "Relative path should not be found when PATH is missing"); + + env.PATH = [DOES_NOT_EXIST, PYTHON_DIR].join(PATH_SEP); + + let path = yield Subprocess.pathSearch(PYTHON_BIN, env); + equal(path, PYTHON, "Correct executable should be found in the path"); +}); + + +add_task({ + skip_if: () => AppConstants.platform != "win", +}, function* test_pathSearch_PATHEXT() { + ok(PYTHON_BIN.endsWith(".exe"), "Python executable must end with .exe"); + + const python_bin = PYTHON_BIN.slice(0, -4); + + let env = { + PATH: PYTHON_DIR, + PATHEXT: [".com", ".exe", ".foobar"].join(";"), + }; + + let path = yield Subprocess.pathSearch(python_bin, env); + equal(path, PYTHON, "Correct executable should be found in the path, with guessed extension"); +}); +// IMPORTANT: Do not add any tests beyond this point without removing +// the `skip_if` condition from the previous task, or it will prevent +// all succeeding tasks from running when it does not match. diff --git a/toolkit/modules/subprocess/test/xpcshell/xpcshell.ini b/toolkit/modules/subprocess/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..7b7d49a73c --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/xpcshell.ini @@ -0,0 +1,14 @@ +[DEFAULT] +head = head.js +tail = +firefox-appdir = browser +skip-if = os == 'android' +subprocess = true +support-files = + data_text_file.txt + data_test_script.py + +[test_subprocess.js] +skip-if = os == 'win' # Path issues due to venv changes on the test machines +[test_subprocess_getEnvironment.js] +[test_subprocess_pathSearch.js] diff --git a/toolkit/modules/tests/MockDocument.jsm b/toolkit/modules/tests/MockDocument.jsm new file mode 100644 index 0000000000..3cae9bb910 --- /dev/null +++ b/toolkit/modules/tests/MockDocument.jsm @@ -0,0 +1,50 @@ +/** + * Provides infrastructure for tests that would require mock document. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["MockDocument"] + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.importGlobalProperties(["URL"]); + +const MockDocument = { + /** + * Create a document for the given URL containing the given HTML with the ownerDocument of all <form>s having a mocked location. + */ + createTestDocument(aDocumentURL, aContent = "<form>", aType = "text/html") { + let parser = Cc["@mozilla.org/xmlextras/domparser;1"]. + createInstance(Ci.nsIDOMParser); + parser.init(); + let parsedDoc = parser.parseFromString(aContent, aType); + + for (let element of parsedDoc.forms) { + this.mockOwnerDocumentProperty(element, parsedDoc, aDocumentURL); + } + return parsedDoc; + }, + + mockOwnerDocumentProperty(aElement, aDoc, aURL) { + // Mock the document.location object so we can unit test without a frame. We use a proxy + // instead of just assigning to the property since it's not configurable or writable. + let document = new Proxy(aDoc, { + get(target, property, receiver) { + // document.location is normally null when a document is outside of a "browsing context". + // See https://html.spec.whatwg.org/#the-location-interface + if (property == "location") { + return new URL(aURL); + } + return target[property]; + }, + }); + + // Assign element.ownerDocument to the proxy so document.location works. + Object.defineProperty(aElement, "ownerDocument", { + value: document, + }); + }, + +}; + diff --git a/toolkit/modules/tests/PromiseTestUtils.jsm b/toolkit/modules/tests/PromiseTestUtils.jsm new file mode 100644 index 0000000000..d60b785a58 --- /dev/null +++ b/toolkit/modules/tests/PromiseTestUtils.jsm @@ -0,0 +1,241 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Detects and reports unhandled rejections during test runs. Test harnesses + * will fail tests in this case, unless the test whitelists itself. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "PromiseTestUtils", +]; + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/Services.jsm", this); + +// Keep "JSMPromise" separate so "Promise" still refers to DOM Promises. +let JSMPromise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise; + +// For now, we need test harnesses to provide a reference to Assert.jsm. +let Assert = null; + +this.PromiseTestUtils = { + /** + * Array of objects containing the details of the Promise rejections that are + * currently left uncaught. This includes DOM Promise and Promise.jsm. When + * rejections in DOM Promises are consumed, they are removed from this list. + * + * The objects contain at least the following properties: + * { + * message: The error message associated with the rejection, if any. + * date: Date object indicating when the rejection was observed. + * id: For DOM Promise only, the Promise ID from PromiseDebugging. This is + * only used for tracking and should not be checked by the callers. + * stack: nsIStackFrame, SavedFrame, or string indicating the stack at the + * time the rejection was triggered. May also be null if the + * rejection was triggered while a script was on the stack. + * } + */ + _rejections: [], + + /** + * When an uncaught rejection is detected, it is ignored if one of the + * functions in this array returns true when called with the rejection details + * as its only argument. When a function matches an expected rejection, it is + * then removed from the array. + */ + _rejectionIgnoreFns: [], + + /** + * Called only by the test infrastructure, registers the rejection observers. + * + * This should be called only once, and a matching "uninit" call must be made + * or the tests will crash on shutdown. + */ + init() { + if (this._initialized) { + Cu.reportError("This object was already initialized."); + return; + } + + PromiseDebugging.addUncaughtRejectionObserver(this); + + // Promise.jsm rejections are only reported to this observer when requested, + // so we don't have to store a key to remove them when consumed. + JSMPromise.Debugging.addUncaughtErrorObserver( + rejection => this._rejections.push(rejection)); + + this._initialized = true; + }, + _initialized: false, + + /** + * Called only by the test infrastructure, unregisters the observers. + */ + uninit() { + if (!this._initialized) { + return; + } + + PromiseDebugging.removeUncaughtRejectionObserver(this); + JSMPromise.Debugging.clearUncaughtErrorObservers(); + + this._initialized = false; + }, + + /** + * Called only by the test infrastructure, spins the event loop until the + * messages for pending DOM Promise rejections have been processed. + */ + ensureDOMPromiseRejectionsProcessed() { + let observed = false; + let observer = { + onLeftUncaught: promise => { + if (PromiseDebugging.getState(promise).reason === + this._ensureDOMPromiseRejectionsProcessedReason) { + observed = true; + } + }, + onConsumed() {}, + }; + + PromiseDebugging.addUncaughtRejectionObserver(observer); + Promise.reject(this._ensureDOMPromiseRejectionsProcessedReason); + while (!observed) { + Services.tm.mainThread.processNextEvent(true); + } + PromiseDebugging.removeUncaughtRejectionObserver(observer); + }, + _ensureDOMPromiseRejectionsProcessedReason: {}, + + /** + * Called only by the tests for PromiseDebugging.addUncaughtRejectionObserver + * and for JSMPromise.Debugging, disables the observers in this module. + */ + disableUncaughtRejectionObserverForSelfTest() { + this.uninit(); + }, + + /** + * Called by tests that have been whitelisted, disables the observers in this + * module. For new tests where uncaught rejections are expected, you should + * use the more granular expectUncaughtRejection function instead. + */ + thisTestLeaksUncaughtRejectionsAndShouldBeFixed() { + this.uninit(); + }, + + /** + * Sets or updates the Assert object instance to be used for error reporting. + */ + set Assert(assert) { + Assert = assert; + }, + + // UncaughtRejectionObserver + onLeftUncaught(promise) { + let message = "(Unable to convert rejection reason to string.)"; + try { + let reason = PromiseDebugging.getState(promise).reason; + if (reason === this._ensureDOMPromiseRejectionsProcessedReason) { + // Ignore the special promise for ensureDOMPromiseRejectionsProcessed. + return; + } + message = reason.message || ("" + reason); + } catch (ex) {} + + // It's important that we don't store any reference to the provided Promise + // object or its value after this function returns in order to avoid leaks. + this._rejections.push({ + id: PromiseDebugging.getPromiseID(promise), + message, + date: new Date(), + stack: PromiseDebugging.getRejectionStack(promise), + }); + }, + + // UncaughtRejectionObserver + onConsumed(promise) { + // We don't expect that many unhandled rejections will appear at the same + // time, so the algorithm doesn't need to be optimized for that case. + let id = PromiseDebugging.getPromiseID(promise); + let index = this._rejections.findIndex(rejection => rejection.id == id); + // If we get a consumption notification for a rejection that was left + // uncaught before this module was initialized, we can safely ignore it. + if (index != -1) { + this._rejections.splice(index, 1); + } + }, + + /** + * Informs the test suite that the test code will generate a Promise rejection + * that will still be unhandled when the test file terminates. + * + * This method must be called once for each instance of Promise that is + * expected to be uncaught, even if the rejection reason is the same for each + * instance. + * + * If the expected rejection does not occur, the test will fail. + * + * @param regExpOrCheckFn + * This can either be a regular expression that should match the error + * message of the rejection, or a check function that is invoked with + * the rejection details object as its first argument. + */ + expectUncaughtRejection(regExpOrCheckFn) { + let checkFn = !("test" in regExpOrCheckFn) ? regExpOrCheckFn : + rejection => regExpOrCheckFn.test(rejection.message); + this._rejectionIgnoreFns.push(checkFn); + }, + + /** + * Fails the test if there are any uncaught rejections at this time that have + * not been whitelisted using expectUncaughtRejection. + * + * Depending on the configuration of the test suite, this function might only + * report the details of the first uncaught rejection that was generated. + * + * This is called by the test suite at the end of each test function. + */ + assertNoUncaughtRejections() { + // Ask Promise.jsm to report all uncaught rejections to the observer now. + JSMPromise.Debugging.flushUncaughtErrors(); + + // If there is any uncaught rejection left at this point, the test fails. + while (this._rejections.length > 0) { + let rejection = this._rejections.shift(); + + // If one of the ignore functions matches, ignore the rejection, then + // remove the function so that each function only matches one rejection. + let index = this._rejectionIgnoreFns.findIndex(f => f(rejection)); + if (index != -1) { + this._rejectionIgnoreFns.splice(index, 1); + continue; + } + + // Report the error. This operation can throw an exception, depending on + // the configuration of the test suite that handles the assertion. + Assert.ok(false, + `A promise chain failed to handle a rejection:` + + ` ${rejection.message} - rejection date: ${rejection.date}`+ + ` - stack: ${rejection.stack}`); + } + }, + + /** + * Fails the test if any rejection indicated by expectUncaughtRejection has + * not yet been reported at this time. + * + * This is called by the test suite at the end of each test file. + */ + assertNoMoreExpectedRejections() { + // Only log this condition is there is a failure. + if (this._rejectionIgnoreFns.length > 0) { + Assert.equal(this._rejectionIgnoreFns.length, 0, + "Unable to find a rejection expected by expectUncaughtRejection."); + } + }, +}; diff --git a/toolkit/modules/tests/browser/.eslintrc.js b/toolkit/modules/tests/browser/.eslintrc.js new file mode 100644 index 0000000000..c764b133dc --- /dev/null +++ b/toolkit/modules/tests/browser/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../testing/mochitest/browser.eslintrc.js" + ] +}; diff --git a/toolkit/modules/tests/browser/WebRequest_dynamic.sjs b/toolkit/modules/tests/browser/WebRequest_dynamic.sjs new file mode 100644 index 0000000000..7b34a377d3 --- /dev/null +++ b/toolkit/modules/tests/browser/WebRequest_dynamic.sjs @@ -0,0 +1,13 @@ +function handleRequest(aRequest, aResponse) { + aResponse.setStatusLine(aRequest.httpVersion, 200); + if (aRequest.hasHeader('Cookie')) { + let value = aRequest.getHeader("Cookie"); + if (value == "blinky=1") { + aResponse.setHeader("Set-Cookie", "dinky=1"); + } + aResponse.write("cookie-present"); + } else { + aResponse.setHeader("Set-Cookie", "foopy=1"); + aResponse.write("cookie-not-present"); + } +} diff --git a/toolkit/modules/tests/browser/WebRequest_redirection.sjs b/toolkit/modules/tests/browser/WebRequest_redirection.sjs new file mode 100644 index 0000000000..370ecd213f --- /dev/null +++ b/toolkit/modules/tests/browser/WebRequest_redirection.sjs @@ -0,0 +1,4 @@ +function handleRequest(aRequest, aResponse) { + aResponse.setStatusLine(aRequest.httpVersion, 302); + aResponse.setHeader("Location", "./dummy_page.html"); +} diff --git a/toolkit/modules/tests/browser/browser.ini b/toolkit/modules/tests/browser/browser.ini new file mode 100644 index 0000000000..e82feaa427 --- /dev/null +++ b/toolkit/modules/tests/browser/browser.ini @@ -0,0 +1,41 @@ +[DEFAULT] +support-files = + dummy_page.html + metadata_*.html + testremotepagemanager.html + file_WebNavigation_page1.html + file_WebNavigation_page2.html + file_WebNavigation_page3.html + file_WebRequest_page1.html + file_WebRequest_page2.html + file_image_good.png + file_image_bad.png + file_image_redirect.png + file_style_good.css + file_style_bad.css + file_style_redirect.css + file_script_good.js + file_script_bad.js + file_script_redirect.js + file_script_xhr.js + WebRequest_dynamic.sjs + WebRequest_redirection.sjs + +[browser_AsyncPrefs.js] +[browser_Battery.js] +[browser_Deprecated.js] +[browser_Finder.js] +[browser_Finder_hidden_textarea.js] +[browser_FinderHighlighter.js] +skip-if = debug || os = "linux" +support-files = file_FinderSample.html +[browser_Geometry.js] +[browser_InlineSpellChecker.js] +[browser_WebNavigation.js] +[browser_WebRequest.js] +[browser_WebRequest_cookies.js] +[browser_WebRequest_filtering.js] +[browser_PageMetadata.js] +[browser_PromiseMessage.js] +[browser_RemotePageManager.js] +[browser_Troubleshoot.js] diff --git a/toolkit/modules/tests/browser/browser_AsyncPrefs.js b/toolkit/modules/tests/browser/browser_AsyncPrefs.js new file mode 100644 index 0000000000..1d20a37898 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_AsyncPrefs.js @@ -0,0 +1,97 @@ +"use strict"; + +const kWhiteListedBool = "testing.allowed-prefs.some-bool-pref"; +const kWhiteListedChar = "testing.allowed-prefs.some-char-pref"; +const kWhiteListedInt = "testing.allowed-prefs.some-int-pref"; + +function resetPrefs() { + for (let pref of [kWhiteListedBool, kWhiteListedChar, kWhiteListedBool]) { + Services.prefs.clearUserPref(pref); + } +} + +registerCleanupFunction(resetPrefs); + +Services.prefs.getDefaultBranch("testing.allowed-prefs.").setBoolPref("some-bool-pref", false); +Services.prefs.getDefaultBranch("testing.allowed-prefs.").setCharPref("some-char-pref", ""); +Services.prefs.getDefaultBranch("testing.allowed-prefs.").setIntPref("some-int-pref", 0); + +function* runTest() { + let {AsyncPrefs} = Cu.import("resource://gre/modules/AsyncPrefs.jsm", {}); + const kInChildProcess = Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; + + // Need to define these again because when run in a content task we have no scope access. + const kNotWhiteListed = "some.pref.thats.not.whitelisted"; + const kWhiteListedBool = "testing.allowed-prefs.some-bool-pref"; + const kWhiteListedChar = "testing.allowed-prefs.some-char-pref"; + const kWhiteListedInt = "testing.allowed-prefs.some-int-pref"; + + const procDesc = kInChildProcess ? "child process" : "parent process"; + + const valueResultMap = [ + [true, "Bool"], + [false, "Bool"], + [10, "Int"], + [-1, "Int"], + ["", "Char"], + ["stuff", "Char"], + [[], false], + [{}, false], + [BrowserUtils.makeURI("http://mozilla.org/"), false], + ]; + + const prefMap = [ + ["Bool", kWhiteListedBool], + ["Char", kWhiteListedChar], + ["Int", kWhiteListedInt], + ]; + + function doesFail(pref, value) { + let msg = `Should not succeed setting ${pref} to ${value} in ${procDesc}`; + return AsyncPrefs.set(pref, value).then(() => ok(false, msg), error => ok(true, msg + "; " + error)); + } + + function doesWork(pref, value) { + let msg = `Should be able to set ${pref} to ${value} in ${procDesc}`; + return AsyncPrefs.set(pref, value).then(() => ok(true, msg), error => ok(false, msg + "; " + error)); + } + + function doReset(pref) { + let msg = `Should be able to reset ${pref} in ${procDesc}`; + return AsyncPrefs.reset(pref).then(() => ok(true, msg), () => ok(false, msg)); + } + + for (let [val, ] of valueResultMap) { + yield doesFail(kNotWhiteListed, val); + is(Services.prefs.prefHasUserValue(kNotWhiteListed), false, "Pref shouldn't get changed"); + } + + let resetMsg = `Should not succeed resetting ${kNotWhiteListed} in ${procDesc}`; + AsyncPrefs.reset(kNotWhiteListed).then(() => ok(false, resetMsg), error => ok(true, resetMsg + "; " + error)); + + for (let [type, pref] of prefMap) { + for (let [val, result] of valueResultMap) { + if (result == type) { + yield doesWork(pref, val); + is(Services.prefs["get" + type + "Pref"](pref), val, "Pref should have been updated"); + yield doReset(pref); + } else { + yield doesFail(pref, val); + is(Services.prefs.prefHasUserValue(pref), false, `Pref ${pref} shouldn't get changed`); + } + } + } +} + +add_task(function* runInParent() { + yield runTest(); + resetPrefs(); +}); + +if (gMultiProcessBrowser) { + add_task(function* runInChild() { + ok(gBrowser.selectedBrowser.isRemoteBrowser, "Should actually run this in child process"); + yield ContentTask.spawn(gBrowser.selectedBrowser, null, runTest); + resetPrefs(); + }); +} diff --git a/toolkit/modules/tests/browser/browser_Battery.js b/toolkit/modules/tests/browser/browser_Battery.js new file mode 100644 index 0000000000..2d3ba5da19 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_Battery.js @@ -0,0 +1,51 @@ +/* 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"; +var imported = Components.utils.import("resource://gre/modules/Battery.jsm", this); +Cu.import("resource://gre/modules/Services.jsm", this); + +function test() { + waitForExplicitFinish(); + + is(imported.Debugging.fake, false, "Battery spoofing is initially false") + + GetBattery().then(function (battery) { + for (let k of ["charging", "chargingTime", "dischargingTime", "level"]) { + let backup = battery[k]; + try { + battery[k] = "__magic__"; + } catch (e) { + // We are testing that we cannot set battery to new values + // when "use strict" is enabled, this throws a TypeError + if (e.name != "TypeError") + throw e; + } + is(battery[k], backup, "Setting battery " + k + " preference without spoofing enabled should fail"); + } + + imported.Debugging.fake = true; + + // reload again to get the fake one + GetBattery().then(function (battery) { + battery.charging = true; + battery.chargingTime = 100; + battery.level = 0.5; + ok(battery.charging, "Test for charging setter"); + is(battery.chargingTime, 100, "Test for chargingTime setter"); + is(battery.level, 0.5, "Test for level setter"); + + battery.charging = false; + battery.dischargingTime = 50; + battery.level = 0.7; + ok(!battery.charging, "Test for charging setter"); + is(battery.dischargingTime, 50, "Test for dischargingTime setter"); + is(battery.level, 0.7, "Test for level setter"); + + // Resetting the value to make the test run successful + // for multiple runs in same browser session. + imported.Debugging.fake = false; + finish(); + }); + }); +} diff --git a/toolkit/modules/tests/browser/browser_Deprecated.js b/toolkit/modules/tests/browser/browser_Deprecated.js new file mode 100644 index 0000000000..3217bdd229 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_Deprecated.js @@ -0,0 +1,157 @@ +/* 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/. */ + +var Ci = Components.interfaces; +var Cu = Components.utils; +const PREF_DEPRECATION_WARNINGS = "devtools.errorconsole.deprecation_warnings"; + +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/Deprecated.jsm", this); + +// Using this named functions to test deprecation and the properly logged +// callstacks. +function basicDeprecatedFunction () { + Deprecated.warning("this method is deprecated.", "http://example.com"); + return true; +} + +function deprecationFunctionBogusCallstack () { + Deprecated.warning("this method is deprecated.", "http://example.com", { + caller: {} + }); + return true; +} + +function deprecationFunctionCustomCallstack () { + // Get the nsIStackFrame that will contain the name of this function. + function getStack () { + return Components.stack; + } + Deprecated.warning("this method is deprecated.", "http://example.com", + getStack()); + return true; +} + +var tests = [ +// Test deprecation warning without passing the callstack. +{ + deprecatedFunction: basicDeprecatedFunction, + expectedObservation: function (aMessage) { + testAMessage(aMessage); + ok(aMessage.errorMessage.indexOf("basicDeprecatedFunction") > 0, + "Callstack is correctly logged."); + } +}, +// Test a reported error when URL to documentation is not passed. +{ + deprecatedFunction: function () { + Deprecated.warning("this method is deprecated."); + return true; + }, + expectedObservation: function (aMessage) { + ok(aMessage.errorMessage.indexOf("must provide a URL") > 0, + "Deprecation warning logged an empty URL argument."); + } +}, +// Test deprecation with a bogus callstack passed as an argument (it will be +// replaced with the current call stack). +{ + deprecatedFunction: deprecationFunctionBogusCallstack, + expectedObservation: function (aMessage) { + testAMessage(aMessage); + ok(aMessage.errorMessage.indexOf("deprecationFunctionBogusCallstack") > 0, + "Callstack is correctly logged."); + } +}, +// When pref is unset Deprecated.warning should not log anything. +{ + deprecatedFunction: basicDeprecatedFunction, + expectedObservation: null, + // Set pref to false. + logWarnings: false +}, +// Test deprecation with a valid custom callstack passed as an argument. +{ + deprecatedFunction: deprecationFunctionCustomCallstack, + expectedObservation: function (aMessage) { + testAMessage(aMessage); + ok(aMessage.errorMessage.indexOf("deprecationFunctionCustomCallstack") > 0, + "Callstack is correctly logged."); + }, + // Set pref to true. + logWarnings: true +}]; + +// Which test are we running now? +var idx = -1; + +function test() { + waitForExplicitFinish(); + + // Check if Deprecated is loaded. + ok(Deprecated, "Deprecated object exists"); + + nextTest(); +} + +// Test Consle Message attributes. +function testAMessage (aMessage) { + ok(aMessage.errorMessage.indexOf("DEPRECATION WARNING: " + + "this method is deprecated.") === 0, + "Deprecation is correctly logged."); + ok(aMessage.errorMessage.indexOf("http://example.com") > 0, + "URL is correctly logged."); +} + +function nextTest() { + idx++; + + if (idx == tests.length) { + finish(); + return; + } + + info("Running test #" + idx); + let test = tests[idx]; + + // Deprecation warnings will be logged only when the preference is set. + if (typeof test.logWarnings !== "undefined") { + Services.prefs.setBoolPref(PREF_DEPRECATION_WARNINGS, test.logWarnings); + } + + // Create a console listener. + let consoleListener = { + observe: function (aMessage) { + // Ignore unexpected messages. + if (!(aMessage instanceof Ci.nsIScriptError)) { + return; + } + if (aMessage.errorMessage.indexOf("DEPRECATION WARNING: ") < 0 && + aMessage.errorMessage.indexOf("must provide a URL") < 0) { + return; + } + ok(aMessage instanceof Ci.nsIScriptError, + "Deprecation log message is an instance of type nsIScriptError."); + + + if (test.expectedObservation === null) { + ok(false, "Deprecated warning not expected"); + } + else { + test.expectedObservation(aMessage); + } + + Services.console.unregisterListener(consoleListener); + executeSoon(nextTest); + } + }; + Services.console.registerListener(consoleListener); + test.deprecatedFunction(); + if (test.expectedObservation === null) { + executeSoon(function() { + Services.console.unregisterListener(consoleListener); + executeSoon(nextTest); + }); + } +} diff --git a/toolkit/modules/tests/browser/browser_Finder.js b/toolkit/modules/tests/browser/browser_Finder.js new file mode 100644 index 0000000000..4dfd921d0e --- /dev/null +++ b/toolkit/modules/tests/browser/browser_Finder.js @@ -0,0 +1,62 @@ +/* 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/. */ + +var Ci = Components.interfaces; + +add_task(function* () { + const url = "data:text/html;base64," + + btoa("<body><iframe srcdoc=\"content\"/></iframe>" + + "<a href=\"http://test.com\">test link</a>"); + let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + let finder = tab.linkedBrowser.finder; + let listener = { + onFindResult: function () { + ok(false, "onFindResult callback wasn't replaced"); + }, + onHighlightFinished: function () { + ok(false, "onHighlightFinished callback wasn't replaced"); + } + }; + finder.addResultListener(listener); + + function waitForFind(which = "onFindResult") { + return new Promise(resolve => { + listener[which] = resolve; + }) + } + + let promiseFind = waitForFind("onHighlightFinished"); + finder.highlight(true, "content"); + let findResult = yield promiseFind; + Assert.ok(findResult.found, "should find string"); + + promiseFind = waitForFind("onHighlightFinished"); + finder.highlight(true, "Bla"); + findResult = yield promiseFind; + Assert.ok(!findResult.found, "should not find string"); + + // Search only for links and draw outlines. + promiseFind = waitForFind(); + finder.fastFind("test link", true, true); + findResult = yield promiseFind; + is(findResult.result, Ci.nsITypeAheadFind.FIND_FOUND, "should find link"); + + yield ContentTask.spawn(tab.linkedBrowser, {}, function* (arg) { + Assert.ok(!!content.document.getElementsByTagName("a")[0].style.outline, "outline set"); + }); + + // Just a simple search for "test link". + promiseFind = waitForFind(); + finder.fastFind("test link", false, false); + findResult = yield promiseFind; + is(findResult.result, Ci.nsITypeAheadFind.FIND_FOUND, "should find link again"); + + yield ContentTask.spawn(tab.linkedBrowser, {}, function* (arg) { + Assert.ok(!content.document.getElementsByTagName("a")[0].style.outline, "outline not set"); + }); + + finder.removeResultListener(listener); + gBrowser.removeTab(tab); +}); diff --git a/toolkit/modules/tests/browser/browser_FinderHighlighter.js b/toolkit/modules/tests/browser/browser_FinderHighlighter.js new file mode 100644 index 0000000000..cd7eefa115 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_FinderHighlighter.js @@ -0,0 +1,460 @@ +"use strict"; + +Cu.import("resource://testing-common/BrowserTestUtils.jsm", this); +Cu.import("resource://testing-common/ContentTask.jsm", this); +Cu.import("resource://gre/modules/Promise.jsm", this); +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/Task.jsm", this); +Cu.import("resource://gre/modules/Timer.jsm", this); +Cu.import("resource://gre/modules/AppConstants.jsm"); + +const kHighlightAllPref = "findbar.highlightAll"; +const kPrefModalHighlight = "findbar.modalHighlight"; +const kFixtureBaseURL = "https://example.com/browser/toolkit/modules/tests/browser/"; +const kIteratorTimeout = Services.prefs.getIntPref("findbar.iteratorTimeout"); + +function promiseOpenFindbar(findbar) { + findbar.onFindCommand() + return gFindBar._startFindDeferred && gFindBar._startFindDeferred.promise; +} + +function promiseFindResult(findbar, str = null) { + let highlightFinished = false; + let findFinished = false; + return new Promise(resolve => { + let listener = { + onFindResult({ searchString }) { + if (str !== null && str != searchString) { + return; + } + findFinished = true; + if (highlightFinished) { + findbar.browser.finder.removeResultListener(listener); + resolve(); + } + }, + onHighlightFinished() { + highlightFinished = true; + if (findFinished) { + findbar.browser.finder.removeResultListener(listener); + resolve(); + } + }, + onMatchesCountResult: () => {} + }; + findbar.browser.finder.addResultListener(listener); + }); +} + +function promiseEnterStringIntoFindField(findbar, str) { + let promise = promiseFindResult(findbar, str); + for (let i = 0; i < str.length; i++) { + let event = document.createEvent("KeyboardEvent"); + event.initKeyEvent("keypress", true, true, null, false, false, + false, false, 0, str.charCodeAt(i)); + findbar._findField.inputField.dispatchEvent(event); + } + return promise; +} + +function promiseTestHighlighterOutput(browser, word, expectedResult, extraTest = () => {}) { + return ContentTask.spawn(browser, { word, expectedResult, extraTest: extraTest.toSource() }, + function* ({ word, expectedResult, extraTest }) { + Cu.import("resource://gre/modules/Timer.jsm", this); + + return new Promise((resolve, reject) => { + let stubbed = {}; + let callCounts = { + insertCalls: [], + removeCalls: [] + }; + let lastMaskNode, lastOutlineNode; + let rects = []; + + // Amount of milliseconds to wait after the last time one of our stubs + // was called. + const kTimeoutMs = 1000; + // The initial timeout may wait for a while for results to come in. + let timeout = setTimeout(() => finish(false, "Timeout"), kTimeoutMs * 5); + + function finish(ok = true, message = "finished with error") { + // Restore the functions we stubbed out. + try { + content.document.insertAnonymousContent = stubbed.insert; + content.document.removeAnonymousContent = stubbed.remove; + } catch (ex) {} + stubbed = {}; + clearTimeout(timeout); + + if (expectedResult.rectCount !== 0) + Assert.ok(ok, message); + + Assert.greaterOrEqual(callCounts.insertCalls.length, expectedResult.insertCalls[0], + `Min. insert calls should match for '${word}'.`); + Assert.lessOrEqual(callCounts.insertCalls.length, expectedResult.insertCalls[1], + `Max. insert calls should match for '${word}'.`); + Assert.greaterOrEqual(callCounts.removeCalls.length, expectedResult.removeCalls[0], + `Min. remove calls should match for '${word}'.`); + Assert.lessOrEqual(callCounts.removeCalls.length, expectedResult.removeCalls[1], + `Max. remove calls should match for '${word}'.`); + + // We reached the amount of calls we expected, so now we can check + // the amount of rects. + if (!lastMaskNode && expectedResult.rectCount !== 0) { + Assert.ok(false, `No mask node found, but expected ${expectedResult.rectCount} rects.`); + } + + Assert.equal(rects.length, expectedResult.rectCount, + `Amount of inserted rects should match for '${word}'.`); + + // Allow more specific assertions to be tested in `extraTest`. + extraTest = eval(extraTest); + extraTest(lastMaskNode, lastOutlineNode, rects); + + resolve(); + } + + function stubAnonymousContentNode(domNode, anonNode) { + let originals = [anonNode.setTextContentForElement, + anonNode.setAttributeForElement, anonNode.removeAttributeForElement, + anonNode.setCutoutRectsForElement]; + anonNode.setTextContentForElement = (id, text) => { + try { + (domNode.querySelector("#" + id) || domNode).textContent = text; + } catch (ex) {} + return originals[0].call(anonNode, id, text); + }; + anonNode.setAttributeForElement = (id, attrName, attrValue) => { + try { + (domNode.querySelector("#" + id) || domNode).setAttribute(attrName, attrValue); + } catch (ex) {} + return originals[1].call(anonNode, id, attrName, attrValue); + }; + anonNode.removeAttributeForElement = (id, attrName) => { + try { + let node = domNode.querySelector("#" + id) || domNode; + if (node.hasAttribute(attrName)) + node.removeAttribute(attrName); + } catch (ex) {} + return originals[2].call(anonNode, id, attrName); + }; + anonNode.setCutoutRectsForElement = (id, cutoutRects) => { + rects = cutoutRects; + return originals[3].call(anonNode, id, cutoutRects); + }; + } + + // Create a function that will stub the original version and collects + // the arguments so we can check the results later. + function stub(which) { + stubbed[which] = content.document[which + "AnonymousContent"]; + let prop = which + "Calls"; + return function(node) { + callCounts[prop].push(node); + if (which == "insert") { + if (node.outerHTML.indexOf("outlineMask") > -1) + lastMaskNode = node; + else + lastOutlineNode = node; + } + clearTimeout(timeout); + timeout = setTimeout(() => { + finish(); + }, kTimeoutMs); + let res = stubbed[which].call(content.document, node); + if (which == "insert") + stubAnonymousContentNode(node, res); + return res; + }; + } + content.document.insertAnonymousContent = stub("insert"); + content.document.removeAnonymousContent = stub("remove"); + }); + }); +} + +add_task(function* setup() { + yield SpecialPowers.pushPrefEnv({ set: [ + [kHighlightAllPref, true], + [kPrefModalHighlight, true] + ]}); +}); + +// Test the results of modal highlighting, which is on by default. +add_task(function* testModalResults() { + let tests = new Map([ + ["Roland", { + rectCount: 2, + insertCalls: [2, 4], + removeCalls: [0, 1] + }], + ["their law might propagate their kind", { + rectCount: 2, + insertCalls: [5, 6], + removeCalls: [4, 5], + extraTest: function(maskNode, outlineNode, rects) { + Assert.equal(outlineNode.getElementsByTagName("div").length, 2, + "There should be multiple rects drawn"); + } + }], + ["ro", { + rectCount: 41, + insertCalls: [1, 4], + removeCalls: [1, 3] + }], + ["new", { + rectCount: 2, + insertCalls: [1, 4], + removeCalls: [0, 2] + }], + ["o", { + rectCount: 492, + insertCalls: [1, 4], + removeCalls: [0, 2] + }] + ]); + let url = kFixtureBaseURL + "file_FinderSample.html"; + yield BrowserTestUtils.withNewTab(url, function* (browser) { + let findbar = gBrowser.getFindBar(); + + for (let [word, expectedResult] of tests) { + yield promiseOpenFindbar(findbar); + Assert.ok(!findbar.hidden, "Findbar should be open now."); + + let timeout = kIteratorTimeout; + if (word.length == 1) + timeout *= 4; + else if (word.length == 2) + timeout *= 2; + yield new Promise(resolve => setTimeout(resolve, timeout)); + let promise = promiseTestHighlighterOutput(browser, word, expectedResult, + expectedResult.extraTest); + yield promiseEnterStringIntoFindField(findbar, word); + yield promise; + + findbar.close(true); + } + }); +}); + +// Test if runtime switching of highlight modes between modal and non-modal works +// as expected. +add_task(function* testModalSwitching() { + let url = kFixtureBaseURL + "file_FinderSample.html"; + yield BrowserTestUtils.withNewTab(url, function* (browser) { + let findbar = gBrowser.getFindBar(); + + yield promiseOpenFindbar(findbar); + Assert.ok(!findbar.hidden, "Findbar should be open now."); + + let word = "Roland"; + let expectedResult = { + rectCount: 2, + insertCalls: [2, 4], + removeCalls: [0, 1] + }; + let promise = promiseTestHighlighterOutput(browser, word, expectedResult); + yield promiseEnterStringIntoFindField(findbar, word); + yield promise; + + yield SpecialPowers.pushPrefEnv({ "set": [[ kPrefModalHighlight, false ]] }); + + expectedResult = { + rectCount: 0, + insertCalls: [0, 0], + removeCalls: [0, 0] + }; + promise = promiseTestHighlighterOutput(browser, word, expectedResult); + findbar.clear(); + yield promiseEnterStringIntoFindField(findbar, word); + yield promise; + + findbar.close(true); + }); + + yield SpecialPowers.pushPrefEnv({ "set": [[ kPrefModalHighlight, true ]] }); +}); + +// Test if highlighting a dark page is detected properly. +add_task(function* testDarkPageDetection() { + let url = kFixtureBaseURL + "file_FinderSample.html"; + yield BrowserTestUtils.withNewTab(url, function* (browser) { + let findbar = gBrowser.getFindBar(); + + yield promiseOpenFindbar(findbar); + + let word = "Roland"; + let expectedResult = { + rectCount: 2, + insertCalls: [1, 3], + removeCalls: [0, 1] + }; + let promise = promiseTestHighlighterOutput(browser, word, expectedResult, function(node) { + Assert.ok(node.style.background.startsWith("rgba(0, 0, 0"), + "White HTML page should have a black background color set for the mask"); + }); + yield promiseEnterStringIntoFindField(findbar, word); + yield promise; + + findbar.close(true); + }); + + yield BrowserTestUtils.withNewTab(url, function* (browser) { + let findbar = gBrowser.getFindBar(); + + yield promiseOpenFindbar(findbar); + + let word = "Roland"; + let expectedResult = { + rectCount: 2, + insertCalls: [2, 4], + removeCalls: [0, 1] + }; + + yield ContentTask.spawn(browser, null, function* () { + let dwu = content.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + let uri = "data:text/css;charset=utf-8," + encodeURIComponent(` + body { + background: maroon radial-gradient(circle, #a01010 0%, #800000 80%) center center / cover no-repeat; + color: white; + }`); + try { + dwu.loadSheetUsingURIString(uri, dwu.USER_SHEET); + } catch (e) {} + }); + + let promise = promiseTestHighlighterOutput(browser, word, expectedResult, node => { + Assert.ok(node.style.background.startsWith("rgba(255, 255, 255"), + "Dark HTML page should have a white background color set for the mask"); + }); + yield promiseEnterStringIntoFindField(findbar, word); + yield promise; + + findbar.close(true); + }); +}); + +add_task(function* testHighlightAllToggle() { + let url = kFixtureBaseURL + "file_FinderSample.html"; + yield BrowserTestUtils.withNewTab(url, function* (browser) { + let findbar = gBrowser.getFindBar(); + + yield promiseOpenFindbar(findbar); + + let word = "Roland"; + let expectedResult = { + rectCount: 2, + insertCalls: [2, 4], + removeCalls: [0, 1] + }; + let promise = promiseTestHighlighterOutput(browser, word, expectedResult); + yield promiseEnterStringIntoFindField(findbar, word); + yield promise; + + // We now know we have multiple rectangles highlighted, so it's a good time + // to flip the pref. + expectedResult = { + rectCount: 0, + insertCalls: [0, 1], + removeCalls: [0, 1] + }; + promise = promiseTestHighlighterOutput(browser, word, expectedResult); + yield SpecialPowers.pushPrefEnv({ "set": [[ kHighlightAllPref, false ]] }); + yield promise; + + // For posterity, let's switch back. + expectedResult = { + rectCount: 2, + insertCalls: [1, 3], + removeCalls: [0, 1] + }; + promise = promiseTestHighlighterOutput(browser, word, expectedResult); + yield SpecialPowers.pushPrefEnv({ "set": [[ kHighlightAllPref, true ]] }); + yield promise; + }); +}); + +add_task(function* testXMLDocument() { + let url = "data:text/xml;charset=utf-8," + encodeURIComponent(`<?xml version="1.0"?> +<result> + <Title>Example</Title> + <Error>Error</Error> +</result>`); + yield BrowserTestUtils.withNewTab(url, function* (browser) { + let findbar = gBrowser.getFindBar(); + + yield promiseOpenFindbar(findbar); + + let word = "Example"; + let expectedResult = { + rectCount: 0, + insertCalls: [1, 4], + removeCalls: [0, 1] + }; + let promise = promiseTestHighlighterOutput(browser, word, expectedResult); + yield promiseEnterStringIntoFindField(findbar, word); + yield promise; + + findbar.close(true); + }); +}); + +add_task(function* testHideOnLocationChange() { + let url = kFixtureBaseURL + "file_FinderSample.html"; + let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, url); + let browser = tab.linkedBrowser; + let findbar = gBrowser.getFindBar(); + + yield promiseOpenFindbar(findbar); + + let word = "Roland"; + let expectedResult = { + rectCount: 2, + insertCalls: [2, 4], + removeCalls: [0, 1] + }; + let promise = promiseTestHighlighterOutput(browser, word, expectedResult); + yield promiseEnterStringIntoFindField(findbar, word); + yield promise; + + // Now we try to navigate away! (Using the same page) + promise = promiseTestHighlighterOutput(browser, word, { + rectCount: 0, + insertCalls: [0, 0], + removeCalls: [1, 2] + }); + yield BrowserTestUtils.loadURI(browser, url); + yield promise; + + yield BrowserTestUtils.removeTab(tab); +}); + +add_task(function* testHideOnClear() { + let url = kFixtureBaseURL + "file_FinderSample.html"; + yield BrowserTestUtils.withNewTab(url, function* (browser) { + let findbar = gBrowser.getFindBar(); + yield promiseOpenFindbar(findbar); + + let word = "Roland"; + let expectedResult = { + rectCount: 2, + insertCalls: [2, 4], + removeCalls: [0, 2] + }; + let promise = promiseTestHighlighterOutput(browser, word, expectedResult); + yield promiseEnterStringIntoFindField(findbar, word); + yield promise; + + yield new Promise(resolve => setTimeout(resolve, kIteratorTimeout)); + promise = promiseTestHighlighterOutput(browser, "", { + rectCount: 0, + insertCalls: [0, 0], + removeCalls: [1, 2] + }); + findbar.clear(); + yield promise; + + findbar.close(true); + }); +}); diff --git a/toolkit/modules/tests/browser/browser_Finder_hidden_textarea.js b/toolkit/modules/tests/browser/browser_Finder_hidden_textarea.js new file mode 100644 index 0000000000..99d838adaf --- /dev/null +++ b/toolkit/modules/tests/browser/browser_Finder_hidden_textarea.js @@ -0,0 +1,52 @@ +/* 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/. */ +add_task(function* test_bug1174036() { + const URI = + "<body><textarea>e1</textarea><textarea>e2</textarea><textarea>e3</textarea></body>"; + yield BrowserTestUtils.withNewTab({ gBrowser, url: "data:text/html;charset=utf-8," + encodeURIComponent(URI) }, + function* (browser) { + // Hide the first textarea. + yield ContentTask.spawn(browser, null, function() { + content.document.getElementsByTagName("textarea")[0].style.display = "none"; + }); + + let finder = browser.finder; + let listener = { + onFindResult: function () { + ok(false, "callback wasn't replaced"); + } + }; + finder.addResultListener(listener); + + function waitForFind() { + return new Promise(resolve => { + listener.onFindResult = resolve; + }) + } + + // Find the first 'e' (which should be in the second textarea). + let promiseFind = waitForFind(); + finder.fastFind("e", false, false); + let findResult = yield promiseFind; + is(findResult.result, Ci.nsITypeAheadFind.FIND_FOUND, "find first string"); + + let firstRect = findResult.rect; + + // Find the second 'e' (in the third textarea). + promiseFind = waitForFind(); + finder.findAgain(false, false, false); + findResult = yield promiseFind; + is(findResult.result, Ci.nsITypeAheadFind.FIND_FOUND, "find second string"); + ok(!findResult.rect.equals(firstRect), "found new string"); + + // Ensure that we properly wrap to the second textarea. + promiseFind = waitForFind(); + finder.findAgain(false, false, false); + findResult = yield promiseFind; + is(findResult.result, Ci.nsITypeAheadFind.FIND_WRAPPED, "wrapped to first string"); + ok(findResult.rect.equals(firstRect), "wrapped to original string"); + + finder.removeResultListener(listener); + }); +}); diff --git a/toolkit/modules/tests/browser/browser_Geometry.js b/toolkit/modules/tests/browser/browser_Geometry.js new file mode 100644 index 0000000000..aaca79a06b --- /dev/null +++ b/toolkit/modules/tests/browser/browser_Geometry.js @@ -0,0 +1,111 @@ +/* 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/. */ + +var tempScope = {}; +Components.utils.import("resource://gre/modules/Geometry.jsm", tempScope); +var Point = tempScope.Point; +var Rect = tempScope.Rect; + +function test() { + ok(Rect, "Rect class exists"); + for (var fname in tests) { + tests[fname](); + } +} + +var tests = { + testGetDimensions: function() { + let r = new Rect(5, 10, 100, 50); + ok(r.left == 5, "rect has correct left value"); + ok(r.top == 10, "rect has correct top value"); + ok(r.right == 105, "rect has correct right value"); + ok(r.bottom == 60, "rect has correct bottom value"); + ok(r.width == 100, "rect has correct width value"); + ok(r.height == 50, "rect has correct height value"); + ok(r.x == 5, "rect has correct x value"); + ok(r.y == 10, "rect has correct y value"); + }, + + testIsEmpty: function() { + let r = new Rect(0, 0, 0, 10); + ok(r.isEmpty(), "rect with nonpositive width is empty"); + r = new Rect(0, 0, 10, 0); + ok(r.isEmpty(), "rect with nonpositive height is empty"); + r = new Rect(0, 0, 10, 10); + ok(!r.isEmpty(), "rect with positive dimensions is not empty"); + }, + + testRestrictTo: function() { + let r1 = new Rect(10, 10, 100, 100); + let r2 = new Rect(50, 50, 100, 100); + r1.restrictTo(r2); + ok(r1.equals(new Rect(50, 50, 60, 60)), "intersection is non-empty"); + + r1 = new Rect(10, 10, 100, 100); + r2 = new Rect(120, 120, 100, 100); + r1.restrictTo(r2); + ok(r1.isEmpty(), "intersection is empty"); + + r1 = new Rect(10, 10, 100, 100); + r2 = new Rect(0, 0, 0, 0); + r1.restrictTo(r2); + ok(r1.isEmpty(), "intersection of rect and empty is empty"); + + r1 = new Rect(0, 0, 0, 0); + r2 = new Rect(0, 0, 0, 0); + r1.restrictTo(r2); + ok(r1.isEmpty(), "intersection of empty and empty is empty"); + }, + + testExpandToContain: function() { + let r1 = new Rect(10, 10, 100, 100); + let r2 = new Rect(50, 50, 100, 100); + r1.expandToContain(r2); + ok(r1.equals(new Rect(10, 10, 140, 140)), "correct expandToContain on intersecting rectangles"); + + r1 = new Rect(10, 10, 100, 100); + r2 = new Rect(120, 120, 100, 100); + r1.expandToContain(r2); + ok(r1.equals(new Rect(10, 10, 210, 210)), "correct expandToContain on non-intersecting rectangles"); + + r1 = new Rect(10, 10, 100, 100); + r2 = new Rect(0, 0, 0, 0); + r1.expandToContain(r2); + ok(r1.equals(new Rect(10, 10, 100, 100)), "expandToContain of rect and empty is rect"); + + r1 = new Rect(10, 10, 0, 0); + r2 = new Rect(0, 0, 0, 0); + r1.expandToContain(r2); + ok(r1.isEmpty(), "expandToContain of empty and empty is empty"); + }, + + testSubtract: function testSubtract() { + function equals(rects1, rects2) { + return rects1.length == rects2.length && rects1.every(function(r, i) { + return r.equals(rects2[i]); + }); + } + + let r1 = new Rect(0, 0, 100, 100); + let r2 = new Rect(500, 500, 100, 100); + ok(equals(r1.subtract(r2), [r1]), "subtract area outside of region yields same region"); + + r1 = new Rect(0, 0, 100, 100); + r2 = new Rect(-10, -10, 50, 120); + ok(equals(r1.subtract(r2), [new Rect(40, 0, 60, 100)]), "subtracting vertical bar from edge leaves one rect"); + + r1 = new Rect(0, 0, 100, 100); + r2 = new Rect(-10, -10, 120, 50); + ok(equals(r1.subtract(r2), [new Rect(0, 40, 100, 60)]), "subtracting horizontal bar from edge leaves one rect"); + + r1 = new Rect(0, 0, 100, 100); + r2 = new Rect(40, 40, 20, 20); + ok(equals(r1.subtract(r2), [ + new Rect(0, 0, 40, 100), + new Rect(40, 0, 20, 40), + new Rect(40, 60, 20, 40), + new Rect(60, 0, 40, 100)]), + "subtracting rect in middle leaves union of rects"); + }, +}; diff --git a/toolkit/modules/tests/browser/browser_InlineSpellChecker.js b/toolkit/modules/tests/browser/browser_InlineSpellChecker.js new file mode 100644 index 0000000000..2bffc97222 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_InlineSpellChecker.js @@ -0,0 +1,121 @@ +function test() { + let tempScope = {}; + Components.utils.import("resource://gre/modules/InlineSpellChecker.jsm", tempScope); + let InlineSpellChecker = tempScope.InlineSpellChecker; + + ok(InlineSpellChecker, "InlineSpellChecker class exists"); + for (var fname in tests) { + tests[fname](); + } +} + +var tests = { + // Test various possible dictionary name to ensure they display as expected. + // XXX: This only works for the 'en-US' locale, as the testing involves localized output. + testDictionaryDisplayNames: function() { + let isc = new InlineSpellChecker(); + + // Check non-well-formed language tag. + is(isc.getDictionaryDisplayName("-invalid-"), "-invalid-", "'-invalid-' should display as '-invalid-'"); + + // XXX: It isn't clear how we'd ideally want to display variant subtags. + + // Check valid language subtag. + is(isc.getDictionaryDisplayName("en"), "English", "'en' should display as 'English'"); + is(isc.getDictionaryDisplayName("en-fonipa"), "English (fonipa)", "'en-fonipa' should display as 'English (fonipa)'"); + is(isc.getDictionaryDisplayName("en-qxqaaaaz"), "English (qxqaaaaz)", "'en-qxqaaaaz' should display as 'English (qxqaaaaz)'"); + + // Check valid language subtag and valid region subtag. + is(isc.getDictionaryDisplayName("en-US"), "English (United States)", "'en-US' should display as 'English (United States)'"); + is(isc.getDictionaryDisplayName("en-US-fonipa"), "English (United States) (fonipa)", "'en-US-fonipa' should display as 'English (United States) (fonipa)'"); + is(isc.getDictionaryDisplayName("en-US-qxqaaaaz"), "English (United States) (qxqaaaaz)", "'en-US-qxqaaaaz' should display as 'English (United States) (qxqaaaaz)'"); + + // Check valid language subtag and invalid but well-formed region subtag. + is(isc.getDictionaryDisplayName("en-WO"), "English (WO)", "'en-WO' should display as 'English (WO)'"); + is(isc.getDictionaryDisplayName("en-WO-fonipa"), "English (WO) (fonipa)", "'en-WO-fonipa' should display as 'English (WO) (fonipa)'"); + is(isc.getDictionaryDisplayName("en-WO-qxqaaaaz"), "English (WO) (qxqaaaaz)", "'en-WO-qxqaaaaz' should display as 'English (WO) (qxqaaaaz)'"); + + // Check valid language subtag and valid script subtag. + todo_is(isc.getDictionaryDisplayName("en-Cyrl"), "English / Cyrillic", "'en-Cyrl' should display as 'English / Cyrillic'"); + todo_is(isc.getDictionaryDisplayName("en-Cyrl-fonipa"), "English / Cyrillic (fonipa)", "'en-Cyrl-fonipa' should display as 'English / Cyrillic (fonipa)'"); + todo_is(isc.getDictionaryDisplayName("en-Cyrl-qxqaaaaz"), "English / Cyrillic (qxqaaaaz)", "'en-Cyrl-qxqaaaaz' should display as 'English / Cyrillic (qxqaaaaz)'"); + todo_is(isc.getDictionaryDisplayName("en-Cyrl-US"), "English (United States) / Cyrillic", "'en-Cyrl-US' should display as 'English (United States) / Cyrillic'"); + todo_is(isc.getDictionaryDisplayName("en-Cyrl-US-fonipa"), "English (United States) / Cyrillic (fonipa)", "'en-Cyrl-US-fonipa' should display as 'English (United States) / Cyrillic (fonipa)'"); + todo_is(isc.getDictionaryDisplayName("en-Cyrl-US-qxqaaaaz"), "English (United States) / Cyrillic (qxqaaaaz)", "'en-Cyrl-US-qxqaaaaz' should display as 'English (United States) / Cyrillic (qxqaaaaz)'"); + todo_is(isc.getDictionaryDisplayName("en-Cyrl-WO"), "English (WO) / Cyrillic", "'en-Cyrl-WO' should display as 'English (WO) / Cyrillic'"); + todo_is(isc.getDictionaryDisplayName("en-Cyrl-WO-fonipa"), "English (WO) / Cyrillic (fonipa)", "'en-Cyrl-WO-fonipa' should display as 'English (WO) / Cyrillic (fonipa)'"); + todo_is(isc.getDictionaryDisplayName("en-Cyrl-WO-qxqaaaaz"), "English (WO) / Cyrillic (qxqaaaaz)", "'en-Cyrl-WO-qxqaaaaz' should display as 'English (WO) / Cyrillic (qxqaaaaz)'"); + + // Check valid language subtag and invalid but well-formed script subtag. + is(isc.getDictionaryDisplayName("en-Qaaz"), "English / Qaaz", "'en-Qaaz' should display as 'English / Qaaz'"); + is(isc.getDictionaryDisplayName("en-Qaaz-fonipa"), "English / Qaaz (fonipa)", "'en-Qaaz-fonipa' should display as 'English / Qaaz (fonipa)'"); + is(isc.getDictionaryDisplayName("en-Qaaz-qxqaaaaz"), "English / Qaaz (qxqaaaaz)", "'en-Qaaz-qxqaaaaz' should display as 'English / Qaaz (qxqaaaaz)'"); + is(isc.getDictionaryDisplayName("en-Qaaz-US"), "English (United States) / Qaaz", "'en-Qaaz-US' should display as 'English (United States) / Qaaz'"); + is(isc.getDictionaryDisplayName("en-Qaaz-US-fonipa"), "English (United States) / Qaaz (fonipa)", "'en-Qaaz-US-fonipa' should display as 'English (United States) / Qaaz (fonipa)'"); + is(isc.getDictionaryDisplayName("en-Qaaz-US-qxqaaaaz"), "English (United States) / Qaaz (qxqaaaaz)", "'en-Qaaz-US-qxqaaaaz' should display as 'English (United States) / Qaaz (qxqaaaaz)'"); + is(isc.getDictionaryDisplayName("en-Qaaz-WO"), "English (WO) / Qaaz", "'en-Qaaz-WO' should display as 'English (WO) / Qaaz'"); + is(isc.getDictionaryDisplayName("en-Qaaz-WO-fonipa"), "English (WO) / Qaaz (fonipa)", "'en-Qaaz-WO-fonipa' should display as 'English (WO) / Qaaz (fonipa)'"); + is(isc.getDictionaryDisplayName("en-Qaaz-WO-qxqaaaaz"), "English (WO) / Qaaz (qxqaaaaz)", "'en-Qaaz-WO-qxqaaaaz' should display as 'English (WO) / Qaaz (qxqaaaaz)'"); + + // Check invalid but well-formed language subtag. + is(isc.getDictionaryDisplayName("qaz"), "qaz", "'qaz' should display as 'qaz'"); + is(isc.getDictionaryDisplayName("qaz-fonipa"), "qaz (fonipa)", "'qaz-fonipa' should display as 'qaz (fonipa)'"); + is(isc.getDictionaryDisplayName("qaz-qxqaaaaz"), "qaz (qxqaaaaz)", "'qaz-qxqaaaaz' should display as 'qaz (qxqaaaaz)'"); + + // Check invalid but well-formed language subtag and valid region subtag. + is(isc.getDictionaryDisplayName("qaz-US"), "qaz (United States)", "'qaz-US' should display as 'qaz (United States)'"); + is(isc.getDictionaryDisplayName("qaz-US-fonipa"), "qaz (United States) (fonipa)", "'qaz-US-fonipa' should display as 'qaz (United States) (fonipa)'"); + is(isc.getDictionaryDisplayName("qaz-US-qxqaaaaz"), "qaz (United States) (qxqaaaaz)", "'qaz-US-qxqaaaaz' should display as 'qaz (United States) (qxqaaaaz)'"); + + // Check invalid but well-formed language subtag and invalid but well-formed region subtag. + is(isc.getDictionaryDisplayName("qaz-WO"), "qaz (WO)", "'qaz-WO' should display as 'qaz (WO)'"); + is(isc.getDictionaryDisplayName("qaz-WO-fonipa"), "qaz (WO) (fonipa)", "'qaz-WO-fonipa' should display as 'qaz (WO) (fonipa)'"); + is(isc.getDictionaryDisplayName("qaz-WO-qxqaaaaz"), "qaz (WO) (qxqaaaaz)", "'qaz-WO-qxqaaaaz' should display as 'qaz (WO) (qxqaaaaz)'"); + + // Check invalid but well-formed language subtag and valid script subtag. + todo_is(isc.getDictionaryDisplayName("qaz-Cyrl"), "qaz / Cyrillic", "'qaz-Cyrl' should display as 'qaz / Cyrillic'"); + todo_is(isc.getDictionaryDisplayName("qaz-Cyrl-fonipa"), "qaz / Cyrillic (fonipa)", "'qaz-Cyrl-fonipa' should display as 'qaz / Cyrillic (fonipa)'"); + todo_is(isc.getDictionaryDisplayName("qaz-Cyrl-qxqaaaaz"), "qaz / Cyrillic (qxqaaaaz)", "'qaz-Cyrl-qxqaaaaz' should display as 'qaz / Cyrillic (qxqaaaaz)'"); + todo_is(isc.getDictionaryDisplayName("qaz-Cyrl-US"), "qaz (United States) / Cyrillic", "'qaz-Cyrl-US' should display as 'qaz (United States) / Cyrillic'"); + todo_is(isc.getDictionaryDisplayName("qaz-Cyrl-US-fonipa"), "qaz (United States) / Cyrillic (fonipa)", "'qaz-Cyrl-US-fonipa' should display as 'qaz (United States) / Cyrillic (fonipa)'"); + todo_is(isc.getDictionaryDisplayName("qaz-Cyrl-US-qxqaaaaz"), "qaz (United States) / Cyrillic (qxqaaaaz)", "'qaz-Cyrl-US-qxqaaaaz' should display as 'qaz (United States) / Cyrillic (qxqaaaaz)'"); + todo_is(isc.getDictionaryDisplayName("qaz-Cyrl-WO"), "qaz (WO) / Cyrillic", "'qaz-Cyrl-WO' should display as 'qaz (WO) / Cyrillic'"); + todo_is(isc.getDictionaryDisplayName("qaz-Cyrl-WO-fonipa"), "qaz (WO) / Cyrillic (fonipa)", "'qaz-Cyrl-WO-fonipa' should display as 'qaz (WO) / Cyrillic (fonipa)'"); + todo_is(isc.getDictionaryDisplayName("qaz-Cyrl-WO-qxqaaaaz"), "qaz (WO) / Cyrillic (qxqaaaaz)", "'qaz-Cyrl-WO-qxqaaaaz' should display as 'qaz (WO) / Cyrillic (qxqaaaaz)'"); + + // Check invalid but well-formed language subtag and invalid but well-formed script subtag. + is(isc.getDictionaryDisplayName("qaz-Qaaz"), "qaz / Qaaz", "'qaz-Qaaz' should display as 'qaz / Qaaz'"); + is(isc.getDictionaryDisplayName("qaz-Qaaz-fonipa"), "qaz / Qaaz (fonipa)", "'qaz-Qaaz-fonipa' should display as 'qaz / Qaaz (fonipa)'"); + is(isc.getDictionaryDisplayName("qaz-Qaaz-qxqaaaaz"), "qaz / Qaaz (qxqaaaaz)", "'qaz-Qaaz-qxqaaaaz' should display as 'qaz / Qaaz (qxqaaaaz)'"); + is(isc.getDictionaryDisplayName("qaz-Qaaz-US"), "qaz (United States) / Qaaz", "'qaz-Qaaz-US' should display as 'qaz (United States) / Qaaz'"); + is(isc.getDictionaryDisplayName("qaz-Qaaz-US-fonipa"), "qaz (United States) / Qaaz (fonipa)", "'qaz-Qaaz-US-fonipa' should display as 'qaz (United States) / Qaaz (fonipa)'"); + is(isc.getDictionaryDisplayName("qaz-Qaaz-US-qxqaaaaz"), "qaz (United States) / Qaaz (qxqaaaaz)", "'qaz-Qaaz-US-qxqaaaaz' should display as 'qaz (United States) / Qaaz (qxqaaaaz)'"); + is(isc.getDictionaryDisplayName("qaz-Qaaz-WO"), "qaz (WO) / Qaaz", "'qaz-Qaaz-WO' should display as 'qaz (WO) / Qaaz'"); + is(isc.getDictionaryDisplayName("qaz-Qaaz-WO-fonipa"), "qaz (WO) / Qaaz (fonipa)", "'qaz-Qaaz-WO-fonipa' should display as 'qaz (WO) / Qaaz (fonipa)'"); + is(isc.getDictionaryDisplayName("qaz-Qaaz-WO-qxqaaaaz"), "qaz (WO) / Qaaz (qxqaaaaz)", "'qaz-Qaaz-WO-qxqaaaaz' should display as 'qaz (WO) / Qaaz (qxqaaaaz)'"); + + // Check multiple variant subtags. + todo_is(isc.getDictionaryDisplayName("en-Cyrl-US-fonipa-fonxsamp"), "English (United States) / Cyrillic (fonipa / fonxsamp)", "'en-Cyrl-US-fonipa-fonxsamp' should display as 'English (United States) / Cyrillic (fonipa / fonxsamp)'"); + todo_is(isc.getDictionaryDisplayName("en-Cyrl-US-fonipa-qxqaaaaz"), "English (United States) / Cyrillic (fonipa / qxqaaaaz)", "'en-Cyrl-US-fonipa-qxqaaaaz' should display as 'English (United States) / Cyrillic (fonipa / qxqaaaaz)'"); + todo_is(isc.getDictionaryDisplayName("en-Cyrl-US-fonipa-fonxsamp-qxqaaaaz"), "English (United States) / Cyrillic (fonipa / fonxsamp / qxqaaaaz)", "'en-Cyrl-US-fonipa-fonxsamp-qxqaaaaz' should display as 'English (United States) / Cyrillic (fonipa / fonxsamp / qxqaaaaz)'"); + is(isc.getDictionaryDisplayName("qaz-Qaaz-WO-fonipa-fonxsamp"), "qaz (WO) / Qaaz (fonipa / fonxsamp)", "'qaz-Qaaz-WO-fonipa-fonxsamp' should display as 'qaz (WO) / Qaaz (fonipa / fonxsamp)'"); + is(isc.getDictionaryDisplayName("qaz-Qaaz-WO-fonipa-qxqaaaaz"), "qaz (WO) / Qaaz (fonipa / qxqaaaaz)", "'qaz-Qaaz-WO-fonipa-qxqaaaaz' should display as 'qaz (WO) / Qaaz (fonipa / qxqaaaaz)'"); + is(isc.getDictionaryDisplayName("qaz-Qaaz-WO-fonipa-fonxsamp-qxqaaaaz"), "qaz (WO) / Qaaz (fonipa / fonxsamp / qxqaaaaz)", "'qaz-Qaaz-WO-fonipa-fonxsamp-qxqaaaaz' should display as 'qaz (WO) / Qaaz (fonipa / fonxsamp / qxqaaaaz)'"); + + // Check numeric region subtag. + todo_is(isc.getDictionaryDisplayName("es-419"), "Spanish (Latin America and the Caribbean)", "'es-419' should display as 'Spanish (Latin America and the Caribbean)'"); + + // Check that extension subtags are ignored. + todo_is(isc.getDictionaryDisplayName("en-Cyrl-t-en-latn-m0-ungegn-2007"), "English / Cyrillic", "'en-Cyrl-t-en-latn-m0-ungegn-2007' should display as 'English / Cyrillic'"); + + // Check that privateuse subtags are ignored. + is(isc.getDictionaryDisplayName("en-x-ignore"), "English", "'en-x-ignore' should display as 'English'"); + is(isc.getDictionaryDisplayName("en-x-ignore-this"), "English", "'en-x-ignore-this' should display as 'English'"); + is(isc.getDictionaryDisplayName("en-x-ignore-this-subtag"), "English", "'en-x-ignore-this-subtag' should display as 'English'"); + + // Check that both extension and privateuse subtags are ignored. + todo_is(isc.getDictionaryDisplayName("en-Cyrl-t-en-latn-m0-ungegn-2007-x-ignore-this-subtag"), "English / Cyrillic", "'en-Cyrl-t-en-latn-m0-ungegn-2007-x-ignore-this-subtag' should display as 'English / Cyrillic'"); + + // XXX: Check grandfathered tags. + }, +}; diff --git a/toolkit/modules/tests/browser/browser_PageMetadata.js b/toolkit/modules/tests/browser/browser_PageMetadata.js new file mode 100644 index 0000000000..ca6e183687 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_PageMetadata.js @@ -0,0 +1,73 @@ +/** + * Tests PageMetadata.jsm, which extracts metadata and microdata from a + * document. + */ + +var {PageMetadata} = Cu.import("resource://gre/modules/PageMetadata.jsm", {}); + +var rootURL = "http://example.com/browser/toolkit/modules/tests/browser/"; + +function promiseDocument(fileName) { + let url = rootURL + fileName; + + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.onload = () => resolve(xhr.responseXML); + xhr.onerror = () => reject(new Error("Error loading document")); + xhr.open("GET", url); + xhr.responseType = "document"; + xhr.send(); + }); +} + +/** + * Load a simple document. + */ +add_task(function* simpleDoc() { + let fileName = "metadata_simple.html"; + info(`Loading a simple page, ${fileName}`); + + let doc = yield promiseDocument(fileName); + Assert.notEqual(doc, null, + "Should have a document to analyse"); + + let data = PageMetadata.getData(doc); + Assert.notEqual(data, null, + "Should have non-null result"); + Assert.equal(data.url, rootURL + fileName, + "Should have expected url property"); + Assert.equal(data.title, "Test Title", + "Should have expected title property"); + Assert.equal(data.description, "A very simple test page", + "Should have expected title property"); +}); + +add_task(function* titlesDoc() { + let fileName = "metadata_titles.html"; + info(`Loading titles page, ${fileName}`); + + let doc = yield promiseDocument(fileName); + Assert.notEqual(doc, null, + "Should have a document to analyse"); + + let data = PageMetadata.getData(doc); + Assert.notEqual(data, null, + "Should have non-null result"); + Assert.equal(data.title, "Test Titles", + "Should use the page title, not the open graph title"); +}); + +add_task(function* titlesFallbackDoc() { + let fileName = "metadata_titles_fallback.html"; + info(`Loading titles page, ${fileName}`); + + let doc = yield promiseDocument(fileName); + Assert.notEqual(doc, null, + "Should have a document to analyse"); + + let data = PageMetadata.getData(doc); + Assert.notEqual(data, null, + "Should have non-null result"); + Assert.equal(data.title, "Title", + "Should use the open graph title"); +}); diff --git a/toolkit/modules/tests/browser/browser_PromiseMessage.js b/toolkit/modules/tests/browser/browser_PromiseMessage.js new file mode 100644 index 0000000000..e967ac4c97 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_PromiseMessage.js @@ -0,0 +1,38 @@ +/* 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/. */ +/* global Cu, BrowserTestUtils, is, ok, add_task, gBrowser */ +"use strict"; +Cu.import("resource://gre/modules/PromiseMessage.jsm", this); + + +const url = "http://example.org/tests/dom/manifest/test/resource.sjs"; + +/** + * Test basic API error conditions + */ +add_task(function* () { + yield BrowserTestUtils.withNewTab({gBrowser, url}, testPromiseMessageAPI) +}); + +function* testPromiseMessageAPI(aBrowser) { + // Reusing an existing message. + const msgKey = "DOM:WebManifest:hasManifestLink"; + const mm = aBrowser.messageManager; + const id = "this should not change"; + const foo = "neitherShouldThis"; + const data = {id, foo}; + + // This just returns false, and it doesn't matter for this test. + yield PromiseMessage.send(mm, msgKey, data); + + // Check that no new props were added + const props = Object.getOwnPropertyNames(data); + ok(props.length === 2, "There should only be 2 props"); + ok(props.includes("id"), "Has the id property"); + ok(props.includes("foo"), "Has the foo property"); + + // Check that the props didn't change. + is(data.id, id, "The id prop must not change."); + is(data.foo, foo, "The foo prop must not change."); +} diff --git a/toolkit/modules/tests/browser/browser_RemotePageManager.js b/toolkit/modules/tests/browser/browser_RemotePageManager.js new file mode 100644 index 0000000000..774d330348 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_RemotePageManager.js @@ -0,0 +1,400 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const TEST_URL = "http://www.example.com/browser/toolkit/modules/tests/browser/testremotepagemanager.html"; + +var { RemotePages, RemotePageManager } = Cu.import("resource://gre/modules/RemotePageManager.jsm", {}); + +function failOnMessage(message) { + ok(false, "Should not have seen message " + message.name); +} + +function waitForMessage(port, message, expectedPort = port) { + return new Promise((resolve) => { + function listener(message) { + is(message.target, expectedPort, "Message should be from the right port."); + + port.removeMessageListener(listener); + resolve(message); + } + + port.addMessageListener(message, listener); + }); +} + +function waitForPort(url, createTab = true) { + return new Promise((resolve) => { + RemotePageManager.addRemotePageListener(url, (port) => { + RemotePageManager.removeRemotePageListener(url); + + waitForMessage(port, "RemotePage:Load").then(() => resolve(port)); + }); + + if (createTab) + gBrowser.selectedTab = gBrowser.addTab(url); + }); +} + +function waitForPage(pages) { + return new Promise((resolve) => { + function listener({ target }) { + pages.removeMessageListener("RemotePage:Init", listener); + + waitForMessage(target, "RemotePage:Load").then(() => resolve(target)); + } + + pages.addMessageListener("RemotePage:Init", listener); + gBrowser.selectedTab = gBrowser.addTab(TEST_URL); + }); +} + +function swapDocShells(browser1, browser2) { + // Swap frameLoaders. + browser1.swapDocShells(browser2); + + // Swap permanentKeys. + let tmp = browser1.permanentKey; + browser1.permanentKey = browser2.permanentKey; + browser2.permanentKey = tmp; +} + +// Test that opening a page creates a port, sends the load event and then +// navigating to a new page sends the unload event. Going back should create a +// new port +add_task(function* init_navigate() { + let port = yield waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + let loaded = new Promise(resolve => { + function listener() { + gBrowser.selectedBrowser.removeEventListener("load", listener, true); + resolve(); + } + gBrowser.selectedBrowser.addEventListener("load", listener, true); + gBrowser.loadURI("about:blank"); + }); + + yield waitForMessage(port, "RemotePage:Unload"); + + // Port should be destroyed now + try { + port.addMessageListener("Foo", failOnMessage); + ok(false, "Should have seen exception"); + } + catch (e) { + ok(true, "Should have seen exception"); + } + + try { + port.sendAsyncMessage("Foo"); + ok(false, "Should have seen exception"); + } + catch (e) { + ok(true, "Should have seen exception"); + } + + yield loaded; + + gBrowser.goBack(); + port = yield waitForPort(TEST_URL, false); + + port.sendAsyncMessage("Ping2"); + let message = yield waitForMessage(port, "Pong2"); + port.destroy(); + + gBrowser.removeCurrentTab(); +}); + +// Test that opening a page creates a port, sends the load event and then +// closing the tab sends the unload event +add_task(function* init_close() { + let port = yield waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + let unloadPromise = waitForMessage(port, "RemotePage:Unload"); + gBrowser.removeCurrentTab(); + yield unloadPromise; + + // Port should be destroyed now + try { + port.addMessageListener("Foo", failOnMessage); + ok(false, "Should have seen exception"); + } + catch (e) { + ok(true, "Should have seen exception"); + } + + try { + port.sendAsyncMessage("Foo"); + ok(false, "Should have seen exception"); + } + catch (e) { + ok(true, "Should have seen exception"); + } +}); + +// Tests that we can send messages to individual pages even when more than one +// is open +add_task(function* multiple_ports() { + let port1 = yield waitForPort(TEST_URL); + is(port1.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + let port2 = yield waitForPort(TEST_URL); + is(port2.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + port2.addMessageListener("Pong", failOnMessage); + port1.sendAsyncMessage("Ping", { str: "foobar", counter: 0 }); + let message = yield waitForMessage(port1, "Pong"); + port2.removeMessageListener("Pong", failOnMessage); + is(message.data.str, "foobar", "String should pass through"); + is(message.data.counter, 1, "Counter should be incremented"); + + port1.addMessageListener("Pong", failOnMessage); + port2.sendAsyncMessage("Ping", { str: "foobaz", counter: 5 }); + message = yield waitForMessage(port2, "Pong"); + port1.removeMessageListener("Pong", failOnMessage); + is(message.data.str, "foobaz", "String should pass through"); + is(message.data.counter, 6, "Counter should be incremented"); + + let unloadPromise = waitForMessage(port2, "RemotePage:Unload"); + gBrowser.removeTab(gBrowser.getTabForBrowser(port2.browser)); + yield unloadPromise; + + try { + port2.addMessageListener("Pong", failOnMessage); + ok(false, "Should not have been able to add a new message listener to a destroyed port."); + } + catch (e) { + ok(true, "Should not have been able to add a new message listener to a destroyed port."); + } + + port1.sendAsyncMessage("Ping", { str: "foobar", counter: 0 }); + message = yield waitForMessage(port1, "Pong"); + is(message.data.str, "foobar", "String should pass through"); + is(message.data.counter, 1, "Counter should be incremented"); + + unloadPromise = waitForMessage(port1, "RemotePage:Unload"); + gBrowser.removeTab(gBrowser.getTabForBrowser(port1.browser)); + yield unloadPromise; +}); + +// Tests that swapping browser docshells doesn't break the ports +add_task(function* browser_switch() { + let port1 = yield waitForPort(TEST_URL); + is(port1.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + let browser1 = gBrowser.selectedBrowser; + port1.sendAsyncMessage("SetCookie", { value: "om nom" }); + + let port2 = yield waitForPort(TEST_URL); + is(port2.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + let browser2 = gBrowser.selectedBrowser; + port2.sendAsyncMessage("SetCookie", { value: "om nom nom" }); + + port2.addMessageListener("Cookie", failOnMessage); + port1.sendAsyncMessage("GetCookie"); + let message = yield waitForMessage(port1, "Cookie"); + port2.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom", "Should have the right cookie"); + + port1.addMessageListener("Cookie", failOnMessage); + port2.sendAsyncMessage("GetCookie", { str: "foobaz", counter: 5 }); + message = yield waitForMessage(port2, "Cookie"); + port1.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom nom", "Should have the right cookie"); + + swapDocShells(browser1, browser2); + is(port1.browser, browser2, "Should have noticed the swap"); + is(port2.browser, browser1, "Should have noticed the swap"); + + // Cookies should have stayed the same + port2.addMessageListener("Cookie", failOnMessage); + port1.sendAsyncMessage("GetCookie"); + message = yield waitForMessage(port1, "Cookie"); + port2.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom", "Should have the right cookie"); + + port1.addMessageListener("Cookie", failOnMessage); + port2.sendAsyncMessage("GetCookie", { str: "foobaz", counter: 5 }); + message = yield waitForMessage(port2, "Cookie"); + port1.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom nom", "Should have the right cookie"); + + swapDocShells(browser1, browser2); + is(port1.browser, browser1, "Should have noticed the swap"); + is(port2.browser, browser2, "Should have noticed the swap"); + + // Cookies should have stayed the same + port2.addMessageListener("Cookie", failOnMessage); + port1.sendAsyncMessage("GetCookie"); + message = yield waitForMessage(port1, "Cookie"); + port2.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom", "Should have the right cookie"); + + port1.addMessageListener("Cookie", failOnMessage); + port2.sendAsyncMessage("GetCookie", { str: "foobaz", counter: 5 }); + message = yield waitForMessage(port2, "Cookie"); + port1.removeMessageListener("Cookie", failOnMessage); + is(message.data.value, "om nom nom", "Should have the right cookie"); + + let unloadPromise = waitForMessage(port2, "RemotePage:Unload"); + gBrowser.removeTab(gBrowser.getTabForBrowser(browser2)); + yield unloadPromise; + + unloadPromise = waitForMessage(port1, "RemotePage:Unload"); + gBrowser.removeTab(gBrowser.getTabForBrowser(browser1)); + yield unloadPromise; +}); + +// Tests that removeMessageListener in chrome works +add_task(function* remove_chrome_listener() { + let port = yield waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + // This relies on messages sent arriving in the same order. Pong will be + // sent back before Pong2 so if removeMessageListener fails the test will fail + port.addMessageListener("Pong", failOnMessage); + port.removeMessageListener("Pong", failOnMessage); + port.sendAsyncMessage("Ping", { str: "remove_listener", counter: 27 }); + port.sendAsyncMessage("Ping2"); + yield waitForMessage(port, "Pong2"); + + let unloadPromise = waitForMessage(port, "RemotePage:Unload"); + gBrowser.removeCurrentTab(); + yield unloadPromise; +}); + +// Tests that removeMessageListener in content works +add_task(function* remove_content_listener() { + let port = yield waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + // This relies on messages sent arriving in the same order. Pong3 would be + // sent back before Pong2 so if removeMessageListener fails the test will fail + port.addMessageListener("Pong3", failOnMessage); + port.sendAsyncMessage("Ping3"); + port.sendAsyncMessage("Ping2"); + yield waitForMessage(port, "Pong2"); + + let unloadPromise = waitForMessage(port, "RemotePage:Unload"); + gBrowser.removeCurrentTab(); + yield unloadPromise; +}); + +// Test RemotePages works +add_task(function* remote_pages_basic() { + let pages = new RemotePages(TEST_URL); + let port = yield waitForPage(pages); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + // Listening to global messages should work + let unloadPromise = waitForMessage(pages, "RemotePage:Unload", port); + gBrowser.removeCurrentTab(); + yield unloadPromise; + + pages.destroy(); + + // RemotePages should be destroyed now + try { + pages.addMessageListener("Foo", failOnMessage); + ok(false, "Should have seen exception"); + } + catch (e) { + ok(true, "Should have seen exception"); + } + + try { + pages.sendAsyncMessage("Foo"); + ok(false, "Should have seen exception"); + } + catch (e) { + ok(true, "Should have seen exception"); + } +}); + +// Test sending messages to all remote pages works +add_task(function* remote_pages_multiple() { + let pages = new RemotePages(TEST_URL); + let port1 = yield waitForPage(pages); + let port2 = yield waitForPage(pages); + + let pongPorts = []; + yield new Promise((resolve) => { + function listener({ name, target, data }) { + is(name, "Pong", "Should have seen the right response."); + is(data.str, "remote_pages", "String should pass through"); + is(data.counter, 43, "Counter should be incremented"); + pongPorts.push(target); + if (pongPorts.length == 2) + resolve(); + } + + pages.addMessageListener("Pong", listener); + pages.sendAsyncMessage("Ping", { str: "remote_pages", counter: 42 }); + }); + + // We don't make any guarantees about which order messages are sent to known + // pages so the pongs could have come back in any order. + isnot(pongPorts[0], pongPorts[1], "Should have received pongs from different ports"); + ok(pongPorts.indexOf(port1) >= 0, "Should have seen a pong from port1"); + ok(pongPorts.indexOf(port2) >= 0, "Should have seen a pong from port2"); + + // After destroy we should see no messages + pages.addMessageListener("RemotePage:Unload", failOnMessage); + pages.destroy(); + + gBrowser.removeTab(gBrowser.getTabForBrowser(port1.browser)); + gBrowser.removeTab(gBrowser.getTabForBrowser(port2.browser)); +}); + +// Test sending various types of data across the boundary +add_task(function* send_data() { + let port = yield waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + let data = { + integer: 45, + real: 45.78, + str: "foobar", + array: [1, 2, 3, 5, 27] + }; + + port.sendAsyncMessage("SendData", data); + let message = yield waitForMessage(port, "ReceivedData"); + + ok(message.data.result, message.data.status); + + gBrowser.removeCurrentTab(); +}); + +// Test sending an object of data across the boundary +add_task(function* send_data2() { + let port = yield waitForPort(TEST_URL); + is(port.browser, gBrowser.selectedBrowser, "Port is for the correct browser"); + + let data = { + integer: 45, + real: 45.78, + str: "foobar", + array: [1, 2, 3, 5, 27] + }; + + port.sendAsyncMessage("SendData2", {data}); + let message = yield waitForMessage(port, "ReceivedData2"); + + ok(message.data.result, message.data.status); + + gBrowser.removeCurrentTab(); +}); + +add_task(function* get_ports_for_browser() { + let pages = new RemotePages(TEST_URL); + let port = yield waitForPage(pages); + // waitForPage creates a new tab and selects it by default, so + // the selected tab should be the one hosting this port. + let browser = gBrowser.selectedBrowser; + let foundPorts = pages.portsForBrowser(browser); + is(foundPorts.length, 1, "There should only be one port for this simple page"); + is(foundPorts[0], port, "Should find the port"); + gBrowser.removeCurrentTab(); +}); diff --git a/toolkit/modules/tests/browser/browser_Troubleshoot.js b/toolkit/modules/tests/browser/browser_Troubleshoot.js new file mode 100644 index 0000000000..34c2a27916 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_Troubleshoot.js @@ -0,0 +1,546 @@ +/* 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/. */ + +// Ideally this would be an xpcshell test, but Troubleshoot relies on things +// that aren't initialized outside of a XUL app environment like AddonManager +// and the "@mozilla.org/xre/app-info;1" component. + +Components.utils.import("resource://gre/modules/AppConstants.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Troubleshoot.jsm"); + +function test() { + waitForExplicitFinish(); + function doNextTest() { + if (!tests.length) { + finish(); + return; + } + tests.shift()(doNextTest); + } + doNextTest(); +} + +registerCleanupFunction(function () { + // Troubleshoot.jsm is imported into the global scope -- the window -- above. + // If it's not deleted, it outlives the test and is reported as a leak. + delete window.Troubleshoot; +}); + +var tests = [ + + function snapshotSchema(done) { + Troubleshoot.snapshot(function (snapshot) { + try { + validateObject(snapshot, SNAPSHOT_SCHEMA); + ok(true, "The snapshot should conform to the schema."); + } + catch (err) { + ok(false, "Schema mismatch, " + err); + } + done(); + }); + }, + + function modifiedPreferences(done) { + let prefs = [ + "javascript.troubleshoot", + "troubleshoot.foo", + "javascript.print_to_filename", + "network.proxy.troubleshoot", + ]; + prefs.forEach(function (p) { + Services.prefs.setBoolPref(p, true); + is(Services.prefs.getBoolPref(p), true, "The pref should be set: " + p); + }); + Troubleshoot.snapshot(function (snapshot) { + let p = snapshot.modifiedPreferences; + is(p["javascript.troubleshoot"], true, + "The pref should be present because it's whitelisted " + + "but not blacklisted."); + ok(!("troubleshoot.foo" in p), + "The pref should be absent because it's not in the whitelist."); + ok(!("javascript.print_to_filename" in p), + "The pref should be absent because it's blacklisted."); + ok(!("network.proxy.troubleshoot" in p), + "The pref should be absent because it's blacklisted."); + prefs.forEach(p => Services.prefs.deleteBranch(p)); + done(); + }); + }, + + function unicodePreferences(done) { + let name = "font.name.sans-serif.x-western"; + let utf8Value = "\xc4\x8capk\xc5\xafv Krasopis" + let unicodeValue = "\u010Capk\u016Fv Krasopis"; + + // set/getCharPref work with 8bit strings (utf8) + Services.prefs.setCharPref(name, utf8Value); + + Troubleshoot.snapshot(function (snapshot) { + let p = snapshot.modifiedPreferences; + is(p[name], unicodeValue, "The pref should have correct Unicode value."); + Services.prefs.deleteBranch(name); + done(); + }); + } +]; + +// This is inspired by JSON Schema, or by the example on its Wikipedia page +// anyway. +const SNAPSHOT_SCHEMA = { + type: "object", + required: true, + properties: { + application: { + required: true, + type: "object", + properties: { + name: { + required: true, + type: "string", + }, + version: { + required: true, + type: "string", + }, + buildID: { + required: true, + type: "string", + }, + userAgent: { + required: true, + type: "string", + }, + osVersion: { + required: true, + type: "string", + }, + vendor: { + type: "string", + }, + updateChannel: { + type: "string", + }, + supportURL: { + type: "string", + }, + remoteAutoStart: { + type: "boolean", + required: true, + }, + autoStartStatus: { + type: "number", + }, + numTotalWindows: { + type: "number", + }, + numRemoteWindows: { + type: "number", + }, + safeMode: { + type: "boolean", + }, + }, + }, + crashes: { + required: false, + type: "object", + properties: { + pending: { + required: true, + type: "number", + }, + submitted: { + required: true, + type: "array", + items: { + type: "object", + properties: { + id: { + required: true, + type: "string", + }, + date: { + required: true, + type: "number", + }, + pending: { + required: true, + type: "boolean", + }, + }, + }, + }, + }, + }, + extensions: { + required: true, + type: "array", + items: { + type: "object", + properties: { + name: { + required: true, + type: "string", + }, + version: { + required: true, + type: "string", + }, + id: { + required: true, + type: "string", + }, + isActive: { + required: true, + type: "boolean", + }, + }, + }, + }, + modifiedPreferences: { + required: true, + type: "object", + }, + lockedPreferences: { + required: true, + type: "object", + }, + graphics: { + required: true, + type: "object", + properties: { + numTotalWindows: { + required: true, + type: "number", + }, + numAcceleratedWindows: { + required: true, + type: "number", + }, + windowLayerManagerType: { + type: "string", + }, + windowLayerManagerRemote: { + type: "boolean", + }, + supportsHardwareH264: { + type: "string", + }, + currentAudioBackend: { + type: "string", + }, + numAcceleratedWindowsMessage: { + type: "array", + }, + adapterDescription: { + type: "string", + }, + adapterVendorID: { + type: "string", + }, + adapterDeviceID: { + type: "string", + }, + adapterSubsysID: { + type: "string", + }, + adapterRAM: { + type: "string", + }, + adapterDrivers: { + type: "string", + }, + driverVersion: { + type: "string", + }, + driverDate: { + type: "string", + }, + adapterDescription2: { + type: "string", + }, + adapterVendorID2: { + type: "string", + }, + adapterDeviceID2: { + type: "string", + }, + adapterSubsysID2: { + type: "string", + }, + adapterRAM2: { + type: "string", + }, + adapterDrivers2: { + type: "string", + }, + driverVersion2: { + type: "string", + }, + driverDate2: { + type: "string", + }, + isGPU2Active: { + type: "boolean", + }, + direct2DEnabled: { + type: "boolean", + }, + directWriteEnabled: { + type: "boolean", + }, + directWriteVersion: { + type: "string", + }, + clearTypeParameters: { + type: "string", + }, + webglRenderer: { + type: "string", + }, + webgl2Renderer: { + type: "string", + }, + info: { + type: "object", + }, + failures: { + type: "array", + items: { + type: "string", + }, + }, + indices: { + type: "array", + items: { + type: "number", + }, + }, + featureLog: { + type: "object", + }, + crashGuards: { + type: "array", + }, + direct2DEnabledMessage: { + type: "array", + }, + }, + }, + javaScript: { + required: true, + type: "object", + properties: { + incrementalGCEnabled: { + type: "boolean", + }, + }, + }, + accessibility: { + required: true, + type: "object", + properties: { + isActive: { + required: true, + type: "boolean", + }, + forceDisabled: { + type: "number", + }, + }, + }, + libraryVersions: { + required: true, + type: "object", + properties: { + NSPR: { + required: true, + type: "object", + properties: { + minVersion: { + required: true, + type: "string", + }, + version: { + required: true, + type: "string", + }, + }, + }, + NSS: { + required: true, + type: "object", + properties: { + minVersion: { + required: true, + type: "string", + }, + version: { + required: true, + type: "string", + }, + }, + }, + NSSUTIL: { + required: true, + type: "object", + properties: { + minVersion: { + required: true, + type: "string", + }, + version: { + required: true, + type: "string", + }, + }, + }, + NSSSSL: { + required: true, + type: "object", + properties: { + minVersion: { + required: true, + type: "string", + }, + version: { + required: true, + type: "string", + }, + }, + }, + NSSSMIME: { + required: true, + type: "object", + properties: { + minVersion: { + required: true, + type: "string", + }, + version: { + required: true, + type: "string", + }, + }, + }, + }, + }, + userJS: { + required: true, + type: "object", + properties: { + exists: { + required: true, + type: "boolean", + }, + }, + }, + experiments: { + type: "array", + }, + sandbox: { + required: false, + type: "object", + properties: { + hasSeccompBPF: { + required: AppConstants.platform == "linux", + type: "boolean" + }, + hasSeccompTSync: { + required: AppConstants.platform == "linux", + type: "boolean" + }, + hasUserNamespaces: { + required: AppConstants.platform == "linux", + type: "boolean" + }, + hasPrivilegedUserNamespaces: { + required: AppConstants.platform == "linux", + type: "boolean" + }, + canSandboxContent: { + required: false, + type: "boolean" + }, + canSandboxMedia: { + required: false, + type: "boolean" + }, + contentSandboxLevel: { + required: AppConstants.MOZ_CONTENT_SANDBOX, + type: "number" + }, + }, + }, + }, +}; + +/** + * Throws an Error if obj doesn't conform to schema. That way you get a nice + * error message and a stack to help you figure out what went wrong, which you + * wouldn't get if this just returned true or false instead. There's still + * room for improvement in communicating validation failures, however. + * + * @param obj The object to validate. + * @param schema The schema that obj should conform to. + */ +function validateObject(obj, schema) { + if (obj === undefined && !schema.required) + return; + if (typeof(schema.type) != "string") + throw schemaErr("'type' must be a string", schema); + if (objType(obj) != schema.type) + throw validationErr("Object is not of the expected type", obj, schema); + let validatorFnName = "validateObject_" + schema.type; + if (!(validatorFnName in this)) + throw schemaErr("Validator function not defined for type", schema); + this[validatorFnName](obj, schema); +} + +function validateObject_object(obj, schema) { + if (typeof(schema.properties) != "object") + // Don't care what obj's properties are. + return; + // First check that all the schema's properties match the object. + for (let prop in schema.properties) + validateObject(obj[prop], schema.properties[prop]); + // Now check that the object doesn't have any properties not in the schema. + for (let prop in obj) + if (!(prop in schema.properties)) + throw validationErr("Object has property "+prop+" not in schema", obj, schema); +} + +function validateObject_array(array, schema) { + if (typeof(schema.items) != "object") + // Don't care what the array's elements are. + return; + array.forEach(elt => validateObject(elt, schema.items)); +} + +function validateObject_string(str, schema) {} +function validateObject_boolean(bool, schema) {} +function validateObject_number(num, schema) {} + +function validationErr(msg, obj, schema) { + return new Error("Validation error: " + msg + + ": object=" + JSON.stringify(obj) + + ", schema=" + JSON.stringify(schema)); +} + +function schemaErr(msg, schema) { + return new Error("Schema error: " + msg + ": " + JSON.stringify(schema)); +} + +function objType(obj) { + let type = typeof(obj); + if (type != "object") + return type; + if (Array.isArray(obj)) + return "array"; + if (obj === null) + return "null"; + return type; +} diff --git a/toolkit/modules/tests/browser/browser_WebNavigation.js b/toolkit/modules/tests/browser/browser_WebNavigation.js new file mode 100644 index 0000000000..e09cb19948 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_WebNavigation.js @@ -0,0 +1,140 @@ +"use strict"; + +var { interfaces: Ci, classes: Cc, utils: Cu, results: Cr } = Components; + +var {WebNavigation} = Cu.import("resource://gre/modules/WebNavigation.jsm", {}); + +const BASE = "http://example.com/browser/toolkit/modules/tests/browser"; +const URL = BASE + "/file_WebNavigation_page1.html"; +const FRAME = BASE + "/file_WebNavigation_page2.html"; +const FRAME2 = BASE + "/file_WebNavigation_page3.html"; + +const EVENTS = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", +]; + +const REQUIRED = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", +]; + +var expectedBrowser; +var received = []; +var completedResolve; +var waitingURL, waitingEvent; +var rootWindowID; + +function gotEvent(event, details) +{ + if (!details.url.startsWith(BASE)) { + return; + } + info(`Got ${event} ${details.url} ${details.windowId} ${details.parentWindowId}`); + + is(details.browser, expectedBrowser, "correct <browser> element"); + + received.push({url: details.url, event}); + + if (typeof(rootWindowID) == "undefined") { + rootWindowID = details.windowId; + } + + if (details.url == URL) { + is(details.windowId, rootWindowID, "root window ID correct"); + } else { + is(details.parentWindowId, rootWindowID, "parent window ID correct"); + isnot(details.windowId, rootWindowID, "window ID probably okay"); + } + + isnot(details.windowId, undefined); + isnot(details.parentWindowId, undefined); + + if (details.url == waitingURL && event == waitingEvent) { + completedResolve(); + } +} + +function loadViaFrameScript(url, event, script) +{ + // Loading via a frame script ensures that the chrome process never + // "gets ahead" of frame scripts in non-e10s mode. + received = []; + waitingURL = url; + waitingEvent = event; + expectedBrowser.messageManager.loadFrameScript("data:," + script, false); + return new Promise(resolve => { completedResolve = resolve; }); +} + +add_task(function* webnav_ordering() { + let listeners = {}; + for (let event of EVENTS) { + listeners[event] = gotEvent.bind(null, event); + WebNavigation[event].addListener(listeners[event]); + } + + gBrowser.selectedTab = gBrowser.addTab(); + let browser = gBrowser.selectedBrowser; + expectedBrowser = browser; + + yield BrowserTestUtils.browserLoaded(browser); + + yield loadViaFrameScript(URL, "onCompleted", `content.location = "${URL}";`); + + function checkRequired(url) { + for (let event of REQUIRED) { + let found = false; + for (let r of received) { + if (r.url == url && r.event == event) { + found = true; + } + } + ok(found, `Received event ${event} from ${url}`); + } + } + + checkRequired(URL); + checkRequired(FRAME); + + function checkBefore(action1, action2) { + function find(action) { + for (let i = 0; i < received.length; i++) { + if (received[i].url == action.url && received[i].event == action.event) { + return i; + } + } + return -1; + } + + let index1 = find(action1); + let index2 = find(action2); + ok(index1 != -1, `Action ${JSON.stringify(action1)} happened`); + ok(index2 != -1, `Action ${JSON.stringify(action2)} happened`); + ok(index1 < index2, `Action ${JSON.stringify(action1)} happened before ${JSON.stringify(action2)}`); + } + + checkBefore({url: URL, event: "onCommitted"}, {url: FRAME, event: "onBeforeNavigate"}); + checkBefore({url: FRAME, event: "onCompleted"}, {url: URL, event: "onCompleted"}); + + yield loadViaFrameScript(FRAME2, "onCompleted", `content.frames[0].location = "${FRAME2}";`); + + checkRequired(FRAME2); + + yield loadViaFrameScript(FRAME2 + "#ref", "onReferenceFragmentUpdated", + "content.frames[0].document.getElementById('elt').click();"); + + info("Received onReferenceFragmentUpdated from FRAME2"); + + gBrowser.removeCurrentTab(); + + for (let event of EVENTS) { + WebNavigation[event].removeListener(listeners[event]); + } +}); + diff --git a/toolkit/modules/tests/browser/browser_WebRequest.js b/toolkit/modules/tests/browser/browser_WebRequest.js new file mode 100644 index 0000000000..cdb28b16cd --- /dev/null +++ b/toolkit/modules/tests/browser/browser_WebRequest.js @@ -0,0 +1,214 @@ +"use strict"; + +var { interfaces: Ci, classes: Cc, utils: Cu, results: Cr } = Components; + +var {WebRequest} = Cu.import("resource://gre/modules/WebRequest.jsm", {}); + +const BASE = "http://example.com/browser/toolkit/modules/tests/browser"; +const URL = BASE + "/file_WebRequest_page1.html"; + +var expected_browser; + +function checkType(details) +{ + let expected_type = "???"; + if (details.url.indexOf("style") != -1) { + expected_type = "stylesheet"; + } else if (details.url.indexOf("image") != -1) { + expected_type = "image"; + } else if (details.url.indexOf("script") != -1) { + expected_type = "script"; + } else if (details.url.indexOf("page1") != -1) { + expected_type = "main_frame"; + } else if (/page2|_redirection\.|dummy_page/.test(details.url)) { + expected_type = "sub_frame"; + } else if (details.url.indexOf("xhr") != -1) { + expected_type = "xmlhttprequest"; + } + is(details.type, expected_type, "resource type is correct"); +} + +var windowIDs = new Map(); + +var requested = []; + +function onBeforeRequest(details) +{ + info(`onBeforeRequest ${details.url}`); + if (details.url.startsWith(BASE)) { + requested.push(details.url); + + is(details.browser, expected_browser, "correct <browser> element"); + checkType(details); + + windowIDs.set(details.url, details.windowId); + if (details.url.indexOf("page2") != -1) { + let page1id = windowIDs.get(URL); + ok(details.windowId != page1id, "sub-frame gets its own window ID"); + is(details.parentWindowId, page1id, "parent window id is correct"); + } + } + if (details.url.indexOf("_bad.") != -1) { + return {cancel: true}; + } + return undefined; +} + +var sendHeaders = []; + +function onBeforeSendHeaders(details) +{ + info(`onBeforeSendHeaders ${details.url}`); + if (details.url.startsWith(BASE)) { + sendHeaders.push(details.url); + + is(details.browser, expected_browser, "correct <browser> element"); + checkType(details); + + let id = windowIDs.get(details.url); + is(id, details.windowId, "window ID same in onBeforeSendHeaders as onBeforeRequest"); + } + if (details.url.indexOf("_redirect.") != -1) { + return {redirectUrl: details.url.replace("_redirect.", "_good.")}; + } + return undefined; +} + +var beforeRedirect = []; + +function onBeforeRedirect(details) +{ + info(`onBeforeRedirect ${details.url} -> ${details.redirectUrl}`); + checkType(details); + if (details.url.startsWith(BASE)) { + beforeRedirect.push(details.url); + + is(details.browser, expected_browser, "correct <browser> element"); + checkType(details); + + let expectedUrl = details.url.replace("_redirect.", "_good.").replace(/\w+_redirection\..*/, "dummy_page.html") + is(details.redirectUrl, expectedUrl, "Correct redirectUrl value"); + } + let id = windowIDs.get(details.url); + is(id, details.windowId, "window ID same in onBeforeRedirect as onBeforeRequest"); + // associate stored windowId with final url + windowIDs.set(details.redirectUrl, details.windowId); + return {}; +} + +var headersReceived = []; + +function onResponseStarted(details) +{ + if (details.url.startsWith(BASE)) { + headersReceived.push(details.url); + } +} + +const expected_requested = [BASE + "/file_WebRequest_page1.html", + BASE + "/file_style_good.css", + BASE + "/file_style_bad.css", + BASE + "/file_style_redirect.css", + BASE + "/file_image_good.png", + BASE + "/file_image_bad.png", + BASE + "/file_image_redirect.png", + BASE + "/file_script_good.js", + BASE + "/file_script_bad.js", + BASE + "/file_script_redirect.js", + BASE + "/file_script_xhr.js", + BASE + "/file_WebRequest_page2.html", + BASE + "/nonexistent_script_url.js", + BASE + "/WebRequest_redirection.sjs", + BASE + "/dummy_page.html", + BASE + "/xhr_resource"]; + +const expected_sendHeaders = [BASE + "/file_WebRequest_page1.html", + BASE + "/file_style_good.css", + BASE + "/file_style_redirect.css", + BASE + "/file_image_good.png", + BASE + "/file_image_redirect.png", + BASE + "/file_script_good.js", + BASE + "/file_script_redirect.js", + BASE + "/file_script_xhr.js", + BASE + "/file_WebRequest_page2.html", + BASE + "/nonexistent_script_url.js", + BASE + "/WebRequest_redirection.sjs", + BASE + "/dummy_page.html", + BASE + "/xhr_resource"]; + +const expected_beforeRedirect = expected_sendHeaders.filter(u => /_redirect\./.test(u)) + .concat(BASE + "/WebRequest_redirection.sjs"); + +const expected_headersReceived = [BASE + "/file_WebRequest_page1.html", + BASE + "/file_style_good.css", + BASE + "/file_image_good.png", + BASE + "/file_script_good.js", + BASE + "/file_script_xhr.js", + BASE + "/file_WebRequest_page2.html", + BASE + "/nonexistent_script_url.js", + BASE + "/dummy_page.html", + BASE + "/xhr_resource"]; + +function removeDupes(list) +{ + let j = 0; + for (let i = 1; i < list.length; i++) { + if (list[i] != list[j]) { + j++; + if (i != j) { + list[j] = list[i]; + } + } + } + list.length = j + 1; +} + +function compareLists(list1, list2, kind) +{ + list1.sort(); + removeDupes(list1); + list2.sort(); + removeDupes(list2); + is(String(list1), String(list2), `${kind} URLs correct`); +} + +function* test_once() +{ + WebRequest.onBeforeRequest.addListener(onBeforeRequest, null, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, null, ["blocking"]); + WebRequest.onBeforeRedirect.addListener(onBeforeRedirect); + WebRequest.onResponseStarted.addListener(onResponseStarted); + + yield BrowserTestUtils.withNewTab({ gBrowser, url: "about:blank" }, + function* (browser) { + expected_browser = browser; + BrowserTestUtils.loadURI(browser, URL); + yield BrowserTestUtils.browserLoaded(expected_browser); + + expected_browser = null; + + yield ContentTask.spawn(browser, null, function() { + let win = content.wrappedJSObject; + is(win.success, 2, "Good script ran"); + is(win.failure, undefined, "Failure script didn't run"); + + let style = + content.getComputedStyle(content.document.getElementById("test"), null); + is(style.getPropertyValue("color"), "rgb(255, 0, 0)", "Good CSS loaded"); + }); + }); + + compareLists(requested, expected_requested, "requested"); + compareLists(sendHeaders, expected_sendHeaders, "sendHeaders"); + compareLists(beforeRedirect, expected_beforeRedirect, "beforeRedirect"); + compareLists(headersReceived, expected_headersReceived, "headersReceived"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onBeforeRedirect.removeListener(onBeforeRedirect); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +} + +// Run the test twice to make sure it works with caching. +add_task(test_once); +add_task(test_once); diff --git a/toolkit/modules/tests/browser/browser_WebRequest_cookies.js b/toolkit/modules/tests/browser/browser_WebRequest_cookies.js new file mode 100644 index 0000000000..b8c4f24cb4 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_WebRequest_cookies.js @@ -0,0 +1,89 @@ +"use strict"; + +var { interfaces: Ci, classes: Cc, utils: Cu, results: Cr } = Components; + +var {WebRequest} = Cu.import("resource://gre/modules/WebRequest.jsm", {}); + +const BASE = "http://example.com/browser/toolkit/modules/tests/browser"; +const URL = BASE + "/WebRequest_dynamic.sjs"; + +var countBefore = 0; +var countAfter = 0; + +function onBeforeSendHeaders(details) +{ + if (details.url != URL) { + return undefined; + } + + countBefore++; + + info(`onBeforeSendHeaders ${details.url}`); + let found = false; + let headers = []; + for (let {name, value} of details.requestHeaders) { + info(`Saw header ${name} '${value}'`); + if (name == "Cookie") { + is(value, "foopy=1", "Cookie is correct"); + headers.push({name, value: "blinky=1"}); + found = true; + } else { + headers.push({name, value}); + } + } + ok(found, "Saw cookie header"); + + return {requestHeaders: headers}; +} + +function onResponseStarted(details) +{ + if (details.url != URL) { + return; + } + + countAfter++; + + info(`onResponseStarted ${details.url}`); + let found = false; + for (let {name, value} of details.responseHeaders) { + info(`Saw header ${name} '${value}'`); + if (name == "Set-Cookie") { + is(value, "dinky=1", "Cookie is correct"); + found = true; + } + } + ok(found, "Saw cookie header"); +} + +add_task(function* filter_urls() { + // First load the URL so that we set cookie foopy=1. + gBrowser.selectedTab = gBrowser.addTab(URL); + yield waitForLoad(); + gBrowser.removeCurrentTab(); + + // Now load with WebRequest set up. + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, null, ["blocking"]); + WebRequest.onResponseStarted.addListener(onResponseStarted, null); + + gBrowser.selectedTab = gBrowser.addTab(URL); + + yield waitForLoad(); + + gBrowser.removeCurrentTab(); + + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); + + is(countBefore, 1, "onBeforeSendHeaders hit once"); + is(countAfter, 1, "onResponseStarted hit once"); +}); + +function waitForLoad(browser = gBrowser.selectedBrowser) { + return new Promise(resolve => { + browser.addEventListener("load", function listener() { + browser.removeEventListener("load", listener, true); + resolve(); + }, true); + }); +} diff --git a/toolkit/modules/tests/browser/browser_WebRequest_filtering.js b/toolkit/modules/tests/browser/browser_WebRequest_filtering.js new file mode 100644 index 0000000000..a456678c16 --- /dev/null +++ b/toolkit/modules/tests/browser/browser_WebRequest_filtering.js @@ -0,0 +1,118 @@ +"use strict"; + +var { interfaces: Ci, classes: Cc, utils: Cu, results: Cr } = Components; + +var {WebRequest} = Cu.import("resource://gre/modules/WebRequest.jsm", {}); +var {MatchPattern} = Cu.import("resource://gre/modules/MatchPattern.jsm", {}); + +const BASE = "http://example.com/browser/toolkit/modules/tests/browser"; +const URL = BASE + "/file_WebRequest_page2.html"; + +var requested = []; + +function onBeforeRequest(details) +{ + info(`onBeforeRequest ${details.url}`); + if (details.url.startsWith(BASE)) { + requested.push(details.url); + } +} + +var sendHeaders = []; + +function onBeforeSendHeaders(details) +{ + info(`onBeforeSendHeaders ${details.url}`); + if (details.url.startsWith(BASE)) { + sendHeaders.push(details.url); + } +} + +var completed = []; + +function onResponseStarted(details) +{ + if (details.url.startsWith(BASE)) { + completed.push(details.url); + } +} + +const expected_urls = [BASE + "/file_style_good.css", + BASE + "/file_style_bad.css", + BASE + "/file_style_redirect.css"]; + +function removeDupes(list) +{ + let j = 0; + for (let i = 1; i < list.length; i++) { + if (list[i] != list[j]) { + j++; + if (i != j) { + list[j] = list[i]; + } + } + } + list.length = j + 1; +} + +function compareLists(list1, list2, kind) +{ + list1.sort(); + removeDupes(list1); + list2.sort(); + removeDupes(list2); + is(String(list1), String(list2), `${kind} URLs correct`); +} + +add_task(function* filter_urls() { + let filter = {urls: new MatchPattern("*://*/*_style_*")}; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, ["blocking"]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + gBrowser.selectedTab = gBrowser.addTab(URL); + + yield waitForLoad(); + + gBrowser.removeCurrentTab(); + + compareLists(requested, expected_urls, "requested"); + compareLists(sendHeaders, expected_urls, "sendHeaders"); + compareLists(completed, expected_urls, "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); + +add_task(function* filter_types() { + let filter = {types: ["stylesheet"]}; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, ["blocking"]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + gBrowser.selectedTab = gBrowser.addTab(URL); + + yield waitForLoad(); + + gBrowser.removeCurrentTab(); + + compareLists(requested, expected_urls, "requested"); + compareLists(sendHeaders, expected_urls, "sendHeaders"); + compareLists(completed, expected_urls, "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); + +function waitForLoad(browser = gBrowser.selectedBrowser) { + return new Promise(resolve => { + browser.addEventListener("load", function listener() { + browser.removeEventListener("load", listener, true); + resolve(); + }, true); + }); +} diff --git a/toolkit/modules/tests/browser/dummy_page.html b/toolkit/modules/tests/browser/dummy_page.html new file mode 100644 index 0000000000..c1c9a4e043 --- /dev/null +++ b/toolkit/modules/tests/browser/dummy_page.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> + +<html> +<body> +<p>Page</p> +</body> +</html> diff --git a/toolkit/modules/tests/browser/file_FinderSample.html b/toolkit/modules/tests/browser/file_FinderSample.html new file mode 100644 index 0000000000..e952d1fe97 --- /dev/null +++ b/toolkit/modules/tests/browser/file_FinderSample.html @@ -0,0 +1,824 @@ +<!DOCTYPE html> +<html> +<head> + <title>Childe Roland</title> +</head> +<body> +<h1>"Childe Roland to the Dark Tower Came"</h1><h5>Robert Browning</h5> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>I.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>My first thought was, he lied in every word, +<dl> +<dd>That hoary cripple, with malicious eye</dd> +<dd>Askance to watch the working of his lie</dd> +</dl> +</dd> +<dd>On mine, and mouth scarce able to afford</dd> +<dd>Suppression of the glee that pursed and scored +<dl> +<dd>Its edge, at one more victim gained thereby.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>II.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>What else should he be set for, with his staff? +<dl> +<dd>What, save to waylay with his lies, ensnare</dd> +<dd>All travellers who might find him posted there,</dd> +</dl> +</dd> +<dd>And ask the road? I guessed what skull-like laugh</dd> +<dd>Would break, what crutch 'gin write my epitaph +<dl> +<dd>For pastime in the dusty thoroughfare,</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>III.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>If at his counsel I should turn aside +<dl> +<dd>Into that ominous tract which, all agree,</dd> +<dd>Hides the Dark Tower. Yet acquiescingly</dd> +</dl> +</dd> +<dd>I did turn as he pointed: neither pride</dd> +<dd>Nor hope rekindling at the end descried, +<dl> +<dd>So much as gladness that some end might be.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>IV.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>For, what with my whole world-wide wandering, +<dl> +<dd>What with my search drawn out thro' years, my hope</dd> +<dd>Dwindled into a ghost not fit to cope</dd> +</dl> +</dd> +<dd>With that obstreperous joy success would bring,</dd> +<dd>I hardly tried now to rebuke the spring +<dl> +<dd>My heart made, finding failure in its scope.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>V.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>As when a sick man very near to death +<dl> +<dd>Seems dead indeed, and feels begin and end</dd> +<dd>The tears and takes the farewell of each friend,</dd> +</dl> +</dd> +<dd>And hears one bid the other go, draw breath</dd> +<dd>Freelier outside ("since all is o'er," he saith, +<dl> +<dd>"And the blow fallen no grieving can amend;")</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>VI.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>While some discuss if near the other graves +<dl> +<dd>Be room enough for this, and when a day</dd> +<dd>Suits best for carrying the corpse away,</dd> +</dl> +</dd> +<dd>With care about the banners, scarves and staves:</dd> +<dd>And still the man hears all, and only craves +<dl> +<dd>He may not shame such tender love and stay.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>VII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Thus, I had so long suffered in this quest, +<dl> +<dd>Heard failure prophesied so oft, been writ</dd> +<dd>So many times among "The Band" - to wit,</dd> +</dl> +</dd> +<dd>The knights who to the Dark Tower's search addressed</dd> +<dd>Their steps - that just to fail as they, seemed best, +<dl> +<dd>And all the doubt was now—should I be fit?</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>VIII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>So, quiet as despair, I turned from him, +<dl> +<dd>That hateful cripple, out of his highway</dd> +<dd>Into the path he pointed. All the day</dd> +</dl> +</dd> +<dd>Had been a dreary one at best, and dim</dd> +<dd>Was settling to its close, yet shot one grim +<dl> +<dd>Red leer to see the plain catch its estray.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>IX.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>For mark! no sooner was I fairly found +<dl> +<dd>Pledged to the plain, after a pace or two,</dd> +<dd>Than, pausing to throw backward a last view</dd> +</dl> +</dd> +<dd>O'er the safe road, 'twas gone; grey plain all round:</dd> +<dd>Nothing but plain to the horizon's bound. +<dl> +<dd>I might go on; nought else remained to do.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>X.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>So, on I went. I think I never saw +<dl> +<dd>Such starved ignoble nature; nothing throve:</dd> +<dd>For flowers - as well expect a cedar grove!</dd> +</dl> +</dd> +<dd>But cockle, spurge, according to their law</dd> +<dd>Might propagate their kind, with none to awe, +<dl> +<dd>You'd think; a burr had been a treasure trove.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XI.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>No! penury, inertness and grimace, +<dl> +<dd>In some strange sort, were the land's portion. "See</dd> +<dd>Or shut your eyes," said Nature peevishly,</dd> +</dl> +</dd> +<dd>"It nothing skills: I cannot help my case:</dd> +<dd>'Tis the Last Judgment's fire must cure this place, +<dl> +<dd>Calcine its clods and set my prisoners free."</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>If there pushed any ragged thistle-stalk +<dl> +<dd>Above its mates, the head was chopped; the bents</dd> +<dd>Were jealous else. What made those holes and rents</dd> +</dl> +</dd> +<dd>In the dock's harsh swarth leaves, bruised as to baulk</dd> +<dd>All hope of greenness? 'tis a brute must walk +<dl> +<dd>Pashing their life out, with a brute's intents.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XIII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>As for the grass, it grew as scant as hair +<dl> +<dd>In leprosy; thin dry blades pricked the mud</dd> +<dd>Which underneath looked kneaded up with blood.</dd> +</dl> +</dd> +<dd>One stiff blind horse, his every bone a-stare,</dd> +<dd>Stood stupefied, however he came there: +<dl> +<dd>Thrust out past service from the devil's stud!</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XIV.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Alive? he might be dead for aught I know, +<dl> +<dd>With that red gaunt and colloped neck a-strain,</dd> +<dd>And shut eyes underneath the rusty mane;</dd> +</dl> +</dd> +<dd>Seldom went such grotesqueness with such woe;</dd> +<dd>I never saw a brute I hated so; +<dl> +<dd>He must be wicked to deserve such pain.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XV.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>I shut my eyes and turned them on my heart. +<dl> +<dd>As a man calls for wine before he fights,</dd> +<dd>I asked one draught of earlier, happier sights,</dd> +</dl> +</dd> +<dd>Ere fitly I could hope to play my part.</dd> +<dd>Think first, fight afterwards - the soldier's art: +<dl> +<dd>One taste of the old time sets all to rights.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XVI.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Not it! I fancied Cuthbert's reddening face +<dl> +<dd>Beneath its garniture of curly gold,</dd> +<dd>Dear fellow, till I almost felt him fold</dd> +</dl> +</dd> +<dd>An arm in mine to fix me to the place</dd> +<dd>That way he used. Alas, one night's disgrace! +<dl> +<dd>Out went my heart's new fire and left it cold.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XVII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Giles then, the soul of honour - there he stands +<dl> +<dd>Frank as ten years ago when knighted first.</dd> +<dd>What honest men should dare (he said) he durst.</dd> +</dl> +</dd> +<dd>Good - but the scene shifts - faugh! what hangman hands</dd> +<dd>Pin to his breast a parchment? His own bands +<dl> +<dd>Read it. Poor traitor, spit upon and curst!</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XVIII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Better this present than a past like that; +<dl> +<dd>Back therefore to my darkening path again!</dd> +<dd>No sound, no sight as far as eye could strain.</dd> +</dl> +</dd> +<dd>Will the night send a howlet or a bat?</dd> +<dd>I asked: when something on the dismal flat +<dl> +<dd>Came to arrest my thoughts and change their train.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XIX.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>A sudden little river crossed my path +<dl> +<dd>As unexpected as a serpent comes.</dd> +<dd>No sluggish tide congenial to the glooms;</dd> +</dl> +</dd> +<dd>This, as it frothed by, might have been a bath</dd> +<dd>For the fiend's glowing hoof - to see the wrath +<dl> +<dd>Of its black eddy bespate with flakes and spumes.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XX.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>So petty yet so spiteful! All along +<dl> +<dd>Low scrubby alders kneeled down over it;</dd> +<dd>Drenched willows flung them headlong in a fit</dd> +</dl> +</dd> +<dd>Of mute despair, a suicidal throng:</dd> +<dd>The river which had done them all the wrong, +<dl> +<dd>Whate'er that was, rolled by, deterred no whit.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXI.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Which, while I forded, - good saints, how I feared +<dl> +<dd>To set my foot upon a dead man's cheek,</dd> +<dd>Each step, or feel the spear I thrust to seek</dd> +</dl> +</dd> +<dd>For hollows, tangled in his hair or beard!</dd> +<dd>—It may have been a water-rat I speared, +<dl> +<dd>But, ugh! it sounded like a baby's shriek.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Glad was I when I reached the other bank. +<dl> +<dd>Now for a better country. Vain presage!</dd> +<dd>Who were the strugglers, what war did they wage,</dd> +</dl> +</dd> +<dd>Whose savage trample thus could pad the dank</dd> +<dd>Soil to a plash? Toads in a poisoned tank, +<dl> +<dd>Or wild cats in a red-hot iron cage—</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXIII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>The fight must so have seemed in that fell cirque. +<dl> +<dd>What penned them there, with all the plain to choose?</dd> +<dd>No foot-print leading to that horrid mews,</dd> +</dl> +</dd> +<dd>None out of it. Mad brewage set to work</dd> +<dd>Their brains, no doubt, like galley-slaves the Turk +<dl> +<dd>Pits for his pastime, Christians against Jews.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXIV.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>And more than that - a furlong on - why, there! +<dl> +<dd>What bad use was that engine for, that wheel,</dd> +<dd>Or brake, not wheel - that harrow fit to reel</dd> +</dl> +</dd> +<dd>Men's bodies out like silk? with all the air</dd> +<dd>Of Tophet's tool, on earth left unaware, +<dl> +<dd>Or brought to sharpen its rusty teeth of steel.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXV.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Then came a bit of stubbed ground, once a wood, +<dl> +<dd>Next a marsh, it would seem, and now mere earth</dd> +<dd>Desperate and done with; (so a fool finds mirth,</dd> +</dl> +</dd> +<dd>Makes a thing and then mars it, till his mood</dd> +<dd>Changes and off he goes!) within a rood— +<dl> +<dd>Bog, clay and rubble, sand and stark black dearth.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXVI.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Now blotches rankling, coloured gay and grim, +<dl> +<dd>Now patches where some leanness of the soil's</dd> +<dd>Broke into moss or substances like boils;</dd> +</dl> +</dd> +<dd>Then came some palsied oak, a cleft in him</dd> +<dd>Like a distorted mouth that splits its rim +<dl> +<dd>Gaping at death, and dies while it recoils.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXVII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>And just as far as ever from the end! +<dl> +<dd>Nought in the distance but the evening, nought</dd> +<dd>To point my footstep further! At the thought,</dd> +</dl> +</dd> +<dd>A great black bird, Apollyon's bosom-friend,</dd> +<dd>Sailed past, nor beat his wide wing dragon-penned +<dl> +<dd>That brushed my cap—perchance the guide I sought.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXVIII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>For, looking up, aware I somehow grew, +<dl> +<dd>'Spite of the dusk, the plain had given place</dd> +<dd>All round to mountains - with such name to grace</dd> +</dl> +</dd> +<dd>Mere ugly heights and heaps now stolen in view.</dd> +<dd>How thus they had surprised me, - solve it, you! +<dl> +<dd>How to get from them was no clearer case.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXIX.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Yet half I seemed to recognise some trick +<dl> +<dd>Of mischief happened to me, God knows when—</dd> +<dd>In a bad dream perhaps. Here ended, then,</dd> +</dl> +</dd> +<dd>Progress this way. When, in the very nick</dd> +<dd>Of giving up, one time more, came a click +<dl> +<dd>As when a trap shuts - you're inside the den!</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXX.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Burningly it came on me all at once, +<dl> +<dd>This was the place! those two hills on the right,</dd> +<dd>Crouched like two bulls locked horn in horn in fight;</dd> +</dl> +</dd> +<dd>While to the left, a tall scalped mountain... Dunce,</dd> +<dd>Dotard, a-dozing at the very nonce, +<dl> +<dd>After a life spent training for the sight!</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXXI.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>What in the midst lay but the Tower itself? +<dl> +<dd>The round squat turret, blind as the fool's heart</dd> +<dd>Built of brown stone, without a counterpart</dd> +</dl> +</dd> +<dd>In the whole world. The tempest's mocking elf</dd> +<dd>Points to the shipman thus the unseen shelf +<dl> +<dd>He strikes on, only when the timbers start.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXXII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Not see? because of night perhaps? - why, day +<dl> +<dd>Came back again for that! before it left,</dd> +<dd>The dying sunset kindled through a cleft:</dd> +</dl> +</dd> +<dd>The hills, like giants at a hunting, lay</dd> +<dd>Chin upon hand, to see the game at bay,— +<dl> +<dd>"Now stab and end the creature - to the heft!"</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXXIII.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>Not hear? when noise was everywhere! it tolled +<dl> +<dd>Increasing like a bell. Names in my ears</dd> +<dd>Of all the lost adventurers my peers,—</dd> +</dl> +</dd> +<dd>How such a one was strong, and such was bold,</dd> +<dd>And such was fortunate, yet each of old +<dl> +<dd>Lost, lost! one moment knelled the woe of years.</dd> +</dl> +</dd> +</dl> +<p><br /></p> +<dl> +<dd> +<dl> +<dd> +<dl> +<dd>XXXIV.</dd> +</dl> +</dd> +</dl> +</dd> +<dd>There they stood, ranged along the hillsides, met +<dl> +<dd>To view the last of me, a living frame</dd> +<dd>For one more picture! in a sheet of flame</dd> +</dl> +</dd> +<dd>I saw them and I knew them all. And yet</dd> +<dd>Dauntless the slug-horn to my lips I set, +<dl> +<dd>And blew "<i>Childe Roland to the Dark Tower came.</i>"</dd> +</dl> +</dd> +</dl> +</body> +</html> diff --git a/toolkit/modules/tests/browser/file_WebNavigation_page1.html b/toolkit/modules/tests/browser/file_WebNavigation_page1.html new file mode 100644 index 0000000000..1b68697562 --- /dev/null +++ b/toolkit/modules/tests/browser/file_WebNavigation_page1.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<iframe src="file_WebNavigation_page2.html" width="200" height="200"></iframe> + +</body> +</html> diff --git a/toolkit/modules/tests/browser/file_WebNavigation_page2.html b/toolkit/modules/tests/browser/file_WebNavigation_page2.html new file mode 100644 index 0000000000..cc1acc83d6 --- /dev/null +++ b/toolkit/modules/tests/browser/file_WebNavigation_page2.html @@ -0,0 +1,7 @@ +<!DOCTYPE HTML> + +<html> +<body> + +</body> +</html> diff --git a/toolkit/modules/tests/browser/file_WebNavigation_page3.html b/toolkit/modules/tests/browser/file_WebNavigation_page3.html new file mode 100644 index 0000000000..a0a26a2e9d --- /dev/null +++ b/toolkit/modules/tests/browser/file_WebNavigation_page3.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<a id="elt" href="file_WebNavigation_page3.html#ref">click me</a> + +</body> +</html> diff --git a/toolkit/modules/tests/browser/file_WebRequest_page1.html b/toolkit/modules/tests/browser/file_WebRequest_page1.html new file mode 100644 index 0000000000..00a0b9b4b6 --- /dev/null +++ b/toolkit/modules/tests/browser/file_WebRequest_page1.html @@ -0,0 +1,29 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +<link rel="stylesheet" href="file_style_good.css"> +<link rel="stylesheet" href="file_style_bad.css"> +<link rel="stylesheet" href="file_style_redirect.css"> +</head> +<body> + +<div id="test">Sample text</div> + +<img id="img_good" src="file_image_good.png"> +<img id="img_bad" src="file_image_bad.png"> +<img id="img_redirect" src="file_image_redirect.png"> + +<script src="file_script_good.js"></script> +<script src="file_script_bad.js"></script> +<script src="file_script_redirect.js"></script> + +<script src="file_script_xhr.js"></script> + +<script src="nonexistent_script_url.js"></script> + +<iframe src="file_WebRequest_page2.html" width="200" height="200"></iframe> +<iframe src="WebRequest_redirection.sjs" width="200" height="50"></iframe> +</body> +</html> diff --git a/toolkit/modules/tests/browser/file_WebRequest_page2.html b/toolkit/modules/tests/browser/file_WebRequest_page2.html new file mode 100644 index 0000000000..b2cf48f9e1 --- /dev/null +++ b/toolkit/modules/tests/browser/file_WebRequest_page2.html @@ -0,0 +1,25 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +<link rel="stylesheet" href="file_style_good.css"> +<link rel="stylesheet" href="file_style_bad.css"> +<link rel="stylesheet" href="file_style_redirect.css"> +</head> +<body> + +<div class="test">Sample text</div> + +<img id="img_good" src="file_image_good.png"> +<img id="img_bad" src="file_image_bad.png"> +<img id="img_redirect" src="file_image_redirect.png"> + +<script src="file_script_good.js"></script> +<script src="file_script_bad.js"></script> +<script src="file_script_redirect.js"></script> + +<script src="nonexistent_script_url.js"></script> + +</body> +</html> diff --git a/toolkit/modules/tests/browser/file_image_bad.png b/toolkit/modules/tests/browser/file_image_bad.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/modules/tests/browser/file_image_bad.png diff --git a/toolkit/modules/tests/browser/file_image_good.png b/toolkit/modules/tests/browser/file_image_good.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/toolkit/modules/tests/browser/file_image_good.png diff --git a/toolkit/modules/tests/browser/file_image_redirect.png b/toolkit/modules/tests/browser/file_image_redirect.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/modules/tests/browser/file_image_redirect.png diff --git a/toolkit/modules/tests/browser/file_script_bad.js b/toolkit/modules/tests/browser/file_script_bad.js new file mode 100644 index 0000000000..90655f136f --- /dev/null +++ b/toolkit/modules/tests/browser/file_script_bad.js @@ -0,0 +1 @@ +window.failure = true; diff --git a/toolkit/modules/tests/browser/file_script_good.js b/toolkit/modules/tests/browser/file_script_good.js new file mode 100644 index 0000000000..b128e54a16 --- /dev/null +++ b/toolkit/modules/tests/browser/file_script_good.js @@ -0,0 +1 @@ +window.success = window.success ? window.success + 1 : 1; diff --git a/toolkit/modules/tests/browser/file_script_redirect.js b/toolkit/modules/tests/browser/file_script_redirect.js new file mode 100644 index 0000000000..917b5d6202 --- /dev/null +++ b/toolkit/modules/tests/browser/file_script_redirect.js @@ -0,0 +1,2 @@ +window.failure = true; + diff --git a/toolkit/modules/tests/browser/file_script_xhr.js b/toolkit/modules/tests/browser/file_script_xhr.js new file mode 100644 index 0000000000..bc1f65eae1 --- /dev/null +++ b/toolkit/modules/tests/browser/file_script_xhr.js @@ -0,0 +1,3 @@ +var request = new XMLHttpRequest(); +request.open("get", "http://example.com/browser/toolkit/modules/tests/browser/xhr_resource", false); +request.send(); diff --git a/toolkit/modules/tests/browser/file_style_bad.css b/toolkit/modules/tests/browser/file_style_bad.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/modules/tests/browser/file_style_bad.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/modules/tests/browser/file_style_good.css b/toolkit/modules/tests/browser/file_style_good.css new file mode 100644 index 0000000000..46f9774b5f --- /dev/null +++ b/toolkit/modules/tests/browser/file_style_good.css @@ -0,0 +1,3 @@ +#test { + color: red; +} diff --git a/toolkit/modules/tests/browser/file_style_redirect.css b/toolkit/modules/tests/browser/file_style_redirect.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/modules/tests/browser/file_style_redirect.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/modules/tests/browser/head.js b/toolkit/modules/tests/browser/head.js new file mode 100644 index 0000000000..777e087e11 --- /dev/null +++ b/toolkit/modules/tests/browser/head.js @@ -0,0 +1,23 @@ +function removeDupes(list) +{ + let j = 0; + for (let i = 1; i < list.length; i++) { + if (list[i] != list[j]) { + j++; + if (i != j) { + list[j] = list[i]; + } + } + } + list.length = j + 1; +} + +function compareLists(list1, list2, kind) +{ + list1.sort(); + removeDupes(list1); + list2.sort(); + removeDupes(list2); + is(String(list1), String(list2), `${kind} URLs correct`); +} + diff --git a/toolkit/modules/tests/browser/metadata_simple.html b/toolkit/modules/tests/browser/metadata_simple.html new file mode 100644 index 0000000000..18089e3992 --- /dev/null +++ b/toolkit/modules/tests/browser/metadata_simple.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> + <head> + <title>Test Title</title> + <meta property="description" content="A very simple test page"> + </head> + <body> + Llama. + </body> +</html> diff --git a/toolkit/modules/tests/browser/metadata_titles.html b/toolkit/modules/tests/browser/metadata_titles.html new file mode 100644 index 0000000000..bd42013044 --- /dev/null +++ b/toolkit/modules/tests/browser/metadata_titles.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head> + <title>Test Titles</title> + <meta property="description" content="A very simple test page" /> + <meta property="og:title" content="Title" /> + </head> + <body> + Llama. + </body> +</html> diff --git a/toolkit/modules/tests/browser/metadata_titles_fallback.html b/toolkit/modules/tests/browser/metadata_titles_fallback.html new file mode 100644 index 0000000000..5b71879b22 --- /dev/null +++ b/toolkit/modules/tests/browser/metadata_titles_fallback.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> + <head> + <meta property="description" content="A very simple test page" /> + <meta property="og:title" content="Title" /> + </head> + <body> + Llama. + </body> +</html> diff --git a/toolkit/modules/tests/browser/testremotepagemanager.html b/toolkit/modules/tests/browser/testremotepagemanager.html new file mode 100644 index 0000000000..4303a38f54 --- /dev/null +++ b/toolkit/modules/tests/browser/testremotepagemanager.html @@ -0,0 +1,66 @@ +<!DOCTYPE HTML> + +<html> +<head> +<script type="text/javascript"> +addMessageListener("Ping", function(message) { + sendAsyncMessage("Pong", { + str: message.data.str, + counter: message.data.counter + 1 + }); +}); + +addMessageListener("Ping2", function(message) { + sendAsyncMessage("Pong2", message.data); +}); + +function neverCalled() { + sendAsyncMessage("Pong3"); +} +addMessageListener("Pong3", neverCalled); +removeMessageListener("Pong3", neverCalled); + +function testData(data) { + var response = { + result: true, + status: "All data correctly received" + } + + function compare(prop, expected) { + if (uneval(data[prop]) == uneval(expected)) + return; + if (response.result) + response.status = ""; + response.result = false; + response.status += "Property " + prop + " should have been " + expected + " but was " + data[prop] + "\n"; + } + + compare("integer", 45); + compare("real", 45.78); + compare("str", "foobar"); + compare("array", [1, 2, 3, 5, 27]); + + return response; +} + +addMessageListener("SendData", function(message) { + sendAsyncMessage("ReceivedData", testData(message.data)); +}); + +addMessageListener("SendData2", function(message) { + sendAsyncMessage("ReceivedData2", testData(message.data.data)); +}); + +var cookie = "nom"; +addMessageListener("SetCookie", function(message) { + cookie = message.data.value; +}); + +addMessageListener("GetCookie", function(message) { + sendAsyncMessage("Cookie", { value: cookie }); +}); +</script> +</head> +<body> +</body> +</html> diff --git a/toolkit/modules/tests/chrome/.eslintrc.js b/toolkit/modules/tests/chrome/.eslintrc.js new file mode 100644 index 0000000000..2c669d844e --- /dev/null +++ b/toolkit/modules/tests/chrome/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../testing/mochitest/chrome.eslintrc.js" + ] +}; diff --git a/toolkit/modules/tests/chrome/chrome.ini b/toolkit/modules/tests/chrome/chrome.ini new file mode 100644 index 0000000000..a272309190 --- /dev/null +++ b/toolkit/modules/tests/chrome/chrome.ini @@ -0,0 +1,3 @@ +[DEFAULT] + +[test_bug544442_checkCert.xul] diff --git a/toolkit/modules/tests/chrome/test_bug544442_checkCert.xul b/toolkit/modules/tests/chrome/test_bug544442_checkCert.xul new file mode 100644 index 0000000000..dd0ce8fbd0 --- /dev/null +++ b/toolkit/modules/tests/chrome/test_bug544442_checkCert.xul @@ -0,0 +1,155 @@ +<?xml version="1.0"?> +<!-- +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +--> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Test CertUtils.jsm checkCert - bug 340198 and bug 544442" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="testStart();"> +<script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + +<script type="application/javascript"> +<![CDATA[ + +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cr = Components.results; + +SimpleTest.waitForExplicitFinish(); + +Components.utils.import("resource://gre/modules/CertUtils.jsm"); + +function testStart() { + ok(true, "Entering testStart"); + + var request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. + createInstance(Ci.nsIXMLHttpRequest); + request.open("GET", "https://example.com/", true); + request.channel.notificationCallbacks = new BadCertHandler(true); + request.onerror = function(event) { testXHRError(event); }; + request.onload = function(event) { testXHRLoad(event); }; + request.send(null); +} + +function testXHRError(aEvent) { + ok(true, "Entering testXHRError - something went wrong"); + + var request = aEvent.target; + var status = 0; + try { + status = request.status; + } + catch (e) { + } + + if (status == 0) + status = request.channel.QueryInterface(Ci.nsIRequest).status; + + ok(false, "XHR onerror called: " + status); + + SimpleTest.finish(); +} + +function getCheckCertResult(aChannel, aAllowNonBuiltIn, aCerts) { + try { + checkCert(aChannel, aAllowNonBuiltIn, aCerts); + } + catch (e) { + return e.result; + } + return Cr.NS_OK; +} + +function testXHRLoad(aEvent) { + ok(true, "Entering testXHRLoad"); + + var channel = aEvent.target.channel; + + var certs = null; + is(getCheckCertResult(channel, false, certs), Cr.NS_ERROR_ABORT, + "checkCert should throw NS_ERROR_ABORT when the certificate attributes " + + "array passed to checkCert is null and the certificate is not builtin"); + + is(getCheckCertResult(channel, true, certs), Cr.NS_OK, + "checkCert should not throw when the certificate attributes array " + + "passed to checkCert is null and builtin certificates aren't enforced"); + + certs = [ { invalidAttribute: "Invalid attribute" } ]; + is(getCheckCertResult(channel, false, certs), Cr.NS_ERROR_ILLEGAL_VALUE, + "checkCert should throw NS_ERROR_ILLEGAL_VALUE when the certificate " + + "attributes array passed to checkCert has an element that has an " + + "attribute that does not exist on the certificate"); + + certs = [ { issuerName: "Incorrect issuerName" } ]; + is(getCheckCertResult(channel, false, certs), Cr.NS_ERROR_ILLEGAL_VALUE, + "checkCert should throw NS_ERROR_ILLEGAL_VALUE when the certificate " + + "attributes array passed to checkCert has an element that has an " + + "issuerName that is not the same as the certificate's"); + + var cert = channel.securityInfo.QueryInterface(Ci.nsISSLStatusProvider). + SSLStatus.QueryInterface(Ci.nsISSLStatus).serverCert; + + certs = [ { issuerName: cert.issuerName, + commonName: cert.commonName } ]; + is(getCheckCertResult(channel, false, certs), Cr.NS_ERROR_ABORT, + "checkCert should throw NS_ERROR_ABORT when the certificate attributes " + + "array passed to checkCert has a single element that has the same " + + "issuerName and commonName as the certificate's and the certificate is " + + "not builtin"); + + is(getCheckCertResult(channel, true, certs), Cr.NS_OK, + "checkCert should not throw when the certificate attributes array " + + "passed to checkCert has a single element that has the same issuerName " + + "and commonName as the certificate's and and builtin certificates " + + "aren't enforced"); + + certs = [ { issuerName: "Incorrect issuerName", + invalidAttribute: "Invalid attribute" }, + { issuerName: cert.issuerName, + commonName: "Invalid Common Name" }, + { issuerName: cert.issuerName, + commonName: cert.commonName } ]; + is(getCheckCertResult(channel, false, certs), Cr.NS_ERROR_ABORT, + "checkCert should throw NS_ERROR_ABORT when the certificate attributes " + + "array passed to checkCert has an element that has the same issuerName " + + "and commonName as the certificate's and the certificate is not builtin"); + + is(getCheckCertResult(channel, true, certs), Cr.NS_OK, + "checkCert should not throw when the certificate attributes array " + + "passed to checkCert has an element that has the same issuerName and " + + "commonName as the certificate's and builtin certificates aren't enforced"); + + var mockChannel = { originalURI: Cc["@mozilla.org/network/io-service;1"]. + getService(Ci.nsIIOService). + newURI("http://example.com/", null, null) }; + + certs = [ ]; + is(getCheckCertResult(mockChannel, false, certs), Cr.NS_ERROR_UNEXPECTED, + "checkCert should throw NS_ERROR_UNEXPECTED when the certificate " + + "attributes array passed to checkCert is not null and the channel's " + + "originalURI is not https"); + + certs = null; + is(getCheckCertResult(mockChannel, false, certs), Cr.NS_OK, + "checkCert should not throw when the certificate attributes object " + + "passed to checkCert is null and the the channel's originalURI is not " + + "https"); + + SimpleTest.finish(); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> + <p id="display"></p> + <div id="content" style="display: none"></div> + <pre id="test"></pre> +</body> +</window> diff --git a/toolkit/modules/tests/mochitest/.eslintrc.js b/toolkit/modules/tests/mochitest/.eslintrc.js new file mode 100644 index 0000000000..3c788d6d68 --- /dev/null +++ b/toolkit/modules/tests/mochitest/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../testing/mochitest/mochitest.eslintrc.js" + ] +}; diff --git a/toolkit/modules/tests/mochitest/mochitest.ini b/toolkit/modules/tests/mochitest/mochitest.ini new file mode 100644 index 0000000000..852d95539f --- /dev/null +++ b/toolkit/modules/tests/mochitest/mochitest.ini @@ -0,0 +1,3 @@ +[DEFAULT] + +[test_spatial_navigation.html] diff --git a/toolkit/modules/tests/mochitest/test_spatial_navigation.html b/toolkit/modules/tests/mochitest/test_spatial_navigation.html new file mode 100644 index 0000000000..c1fbb0eec8 --- /dev/null +++ b/toolkit/modules/tests/mochitest/test_spatial_navigation.html @@ -0,0 +1,76 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=698437 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 698437</title> + <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="application/javascript" src="/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> + <script type="application/javascript"> + + /** Test for Bug 698437 **/ + + SimpleTest.waitForExplicitFinish(); + + function Test() { + if (!SpecialPowers.getBoolPref("snav.enabled")) { + todo(false, "Enable spatial navigiation on this platform."); + SimpleTest.finish(); + return; + } + + var center = document.getElementById("center"); + var right = document.getElementById("right"); + var left = document.getElementById("left"); + var top = document.getElementById("top"); + var bottom = document.getElementById("bottom"); + + console.log(top); + console.log(bottom); + console.log(center); + console.log(left); + console.log(right); + + center.focus(); + is(center.id, document.activeElement.id, "How did we call focus on center and it did" + + " not become the active element?"); + + synthesizeKey("VK_UP", { }); + is(document.activeElement.id, top.id, + "Spatial navigation up key is not handled correctly."); + + center.focus(); + synthesizeKey("VK_DOWN", { }); + is(document.activeElement.id, bottom.id, + "Spatial navigation down key is not handled correctly."); + + center.focus(); + synthesizeKey("VK_RIGHT", { }); + is(document.activeElement.id, right.id, + "Spatial navigation right key is not handled correctly."); + + center.focus(); + synthesizeKey("VK_LEFT", { }); + is(document.activeElement.id, left.id, + "Spatial navigation left key is not handled correctly."); + + SimpleTest.finish(); + } + + </script> +</head> +<body onload="Test();"> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=698437">Mozilla Bug 698437</a> +<p id="display"></p> +<div id="content"> + <button id="lefttop">1</button><button id="top">2</button><button id="righttop">3</button><br> + <button id="left">4</button><button id="center">5</button><button id="right">6</button><br> + <button id="leftbottom">7</button><button id="bottom">8</button><button id="rightbottom">9</button><br> +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/toolkit/modules/tests/xpcshell/.eslintrc.js b/toolkit/modules/tests/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..fee088c179 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../testing/xpcshell/xpcshell.eslintrc.js" + ] +}; diff --git a/toolkit/modules/tests/xpcshell/TestIntegration.jsm b/toolkit/modules/tests/xpcshell/TestIntegration.jsm new file mode 100644 index 0000000000..78a0b72671 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/TestIntegration.jsm @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Internal module used to test the generation of Integration.jsm getters. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "TestIntegration", +]; + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/Task.jsm"); + +this.TestIntegration = { + value: "value", + + get valueFromThis() { + return this.value; + }, + + get property() { + return this._property; + }, + + set property(value) { + this._property = value; + }, + + method(argument) { + this.methodArgument = argument; + return "method" + argument; + }, + + asyncMethod: Task.async(function* (argument) { + this.asyncMethodArgument = argument; + return "asyncMethod" + argument; + }), +}; diff --git a/toolkit/modules/tests/xpcshell/chromeappsstore.sqlite b/toolkit/modules/tests/xpcshell/chromeappsstore.sqlite Binary files differnew file mode 100644 index 0000000000..15d309df50 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/chromeappsstore.sqlite diff --git a/toolkit/modules/tests/xpcshell/propertyLists/bug710259_propertyListBinary.plist b/toolkit/modules/tests/xpcshell/propertyLists/bug710259_propertyListBinary.plist Binary files differnew file mode 100644 index 0000000000..5888c9c9c5 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/propertyLists/bug710259_propertyListBinary.plist diff --git a/toolkit/modules/tests/xpcshell/propertyLists/bug710259_propertyListXML.plist b/toolkit/modules/tests/xpcshell/propertyLists/bug710259_propertyListXML.plist new file mode 100644 index 0000000000..9b6decc1e6 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/propertyLists/bug710259_propertyListXML.plist @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Boolean</key> + <false/> + <key>Array</key> + <array> + <string>abc</string> + <string>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</string> + <string>אאא</string> + <string>אאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאאא</string> + <string>𐀀𐀀𐀀</string> + <date>2011-12-31T11:15:23Z</date> + <data>MjAxMS0xMi0zMVQxMToxNTozM1o=</data> + <dict> + <key>Negative Number</key> + <integer>-400</integer> + <key>Real Number</key> + <real>2.71828183</real> + <key>Big Int</key> + <integer>9007199254740993</integer> + <key>Negative Big Int</key> + <integer>-9007199254740993</integer> + </dict> + </array> +</dict> +</plist> diff --git a/toolkit/modules/tests/xpcshell/test_BinarySearch.js b/toolkit/modules/tests/xpcshell/test_BinarySearch.js new file mode 100644 index 0000000000..f48b0bccff --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_BinarySearch.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Components.utils.import("resource://gre/modules/BinarySearch.jsm"); + +function run_test() { + // empty array + ok([], 1, false, 0); + + // one-element array + ok([2], 2, true, 0); + ok([2], 1, false, 0); + ok([2], 3, false, 1); + + // two-element array + ok([2, 4], 2, true, 0); + ok([2, 4], 4, true, 1); + ok([2, 4], 1, false, 0); + ok([2, 4], 3, false, 1); + ok([2, 4], 5, false, 2); + + // three-element array + ok([2, 4, 6], 2, true, 0); + ok([2, 4, 6], 4, true, 1); + ok([2, 4, 6], 6, true, 2); + ok([2, 4, 6], 1, false, 0); + ok([2, 4, 6], 3, false, 1); + ok([2, 4, 6], 5, false, 2); + ok([2, 4, 6], 7, false, 3); + + // duplicates + ok([2, 2], 2, true, 0); + ok([2, 2], 1, false, 0); + ok([2, 2], 3, false, 2); + + // duplicates on the left + ok([2, 2, 4], 2, true, 1); + ok([2, 2, 4], 4, true, 2); + ok([2, 2, 4], 1, false, 0); + ok([2, 2, 4], 3, false, 2); + ok([2, 2, 4], 5, false, 3); + + // duplicates on the right + ok([2, 4, 4], 2, true, 0); + ok([2, 4, 4], 4, true, 1); + ok([2, 4, 4], 1, false, 0); + ok([2, 4, 4], 3, false, 1); + ok([2, 4, 4], 5, false, 3); + + // duplicates in the middle + ok([2, 4, 4, 6], 2, true, 0); + ok([2, 4, 4, 6], 4, true, 1); + ok([2, 4, 4, 6], 6, true, 3); + ok([2, 4, 4, 6], 1, false, 0); + ok([2, 4, 4, 6], 3, false, 1); + ok([2, 4, 4, 6], 5, false, 3); + ok([2, 4, 4, 6], 7, false, 4); + + // duplicates all around + ok([2, 2, 4, 4, 6, 6], 2, true, 0); + ok([2, 2, 4, 4, 6, 6], 4, true, 2); + ok([2, 2, 4, 4, 6, 6], 6, true, 4); + ok([2, 2, 4, 4, 6, 6], 1, false, 0); + ok([2, 2, 4, 4, 6, 6], 3, false, 2); + ok([2, 2, 4, 4, 6, 6], 5, false, 4); + ok([2, 2, 4, 4, 6, 6], 7, false, 6); +} + +function ok(array, target, expectedFound, expectedIdx) { + let [found, idx] = BinarySearch.search(cmp, array, target); + do_check_eq(found, expectedFound); + do_check_eq(idx, expectedIdx); + + idx = expectedFound ? expectedIdx : -1; + do_check_eq(BinarySearch.indexOf(cmp, array, target), idx); + do_check_eq(BinarySearch.insertionIndexOf(cmp, array, target), expectedIdx); +} + +function cmp(num1, num2) { + return num1 - num2; +} diff --git a/toolkit/modules/tests/xpcshell/test_CanonicalJSON.js b/toolkit/modules/tests/xpcshell/test_CanonicalJSON.js new file mode 100644 index 0000000000..fa61f5a010 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_CanonicalJSON.js @@ -0,0 +1,146 @@ +const { CanonicalJSON } = Components.utils.import("resource://gre/modules/CanonicalJSON.jsm"); + +function stringRepresentation(obj) { + const clone = JSON.parse(JSON.stringify(obj)); + return JSON.stringify(clone); +} + +add_task(function* test_canonicalJSON_should_preserve_array_order() { + const input = ['one', 'two', 'three']; + // No sorting should be done on arrays. + do_check_eq(CanonicalJSON.stringify(input), '["one","two","three"]'); +}); + +add_task(function* test_canonicalJSON_orders_object_keys() { + const input = [{ + b: ['two', 'three'], + a: ['zero', 'one'] + }]; + do_check_eq( + CanonicalJSON.stringify(input), + '[{"a":["zero","one"],"b":["two","three"]}]' + ); +}); + +add_task(function* test_canonicalJSON_orders_nested_object_keys() { + const input = [{ + b: {d: 'd', c: 'c'}, + a: {b: 'b', a: 'a'} + }]; + do_check_eq( + CanonicalJSON.stringify(input), + '[{"a":{"a":"a","b":"b"},"b":{"c":"c","d":"d"}}]' + ); +}); + +add_task(function* test_canonicalJSON_escapes_unicode_values() { + do_check_eq( + CanonicalJSON.stringify([{key: '✓'}]), + '[{"key":"\\u2713"}]' + ); + // Unicode codepoints should be output in lowercase. + do_check_eq( + CanonicalJSON.stringify([{key: 'é'}]), + '[{"key":"\\u00e9"}]' + ); +}); + +add_task(function* test_canonicalJSON_escapes_unicode_object_keys() { + do_check_eq( + CanonicalJSON.stringify([{'é': 'check'}]), + '[{"\\u00e9":"check"}]' + ); +}); + + +add_task(function* test_canonicalJSON_does_not_alter_input() { + const records = [ + {'foo': 'bar', 'last_modified': '12345', 'id': '1'}, + {'bar': 'baz', 'last_modified': '45678', 'id': '2'} + ]; + const serializedJSON = JSON.stringify(records); + CanonicalJSON.stringify(records); + do_check_eq(JSON.stringify(records), serializedJSON); +}); + + +add_task(function* test_canonicalJSON_preserves_data() { + const records = [ + {'foo': 'bar', 'last_modified': '12345', 'id': '1'}, + {'bar': 'baz', 'last_modified': '45678', 'id': '2'}, + ] + const serialized = CanonicalJSON.stringify(records); + const expected = '[{"foo":"bar","id":"1","last_modified":"12345"},' + + '{"bar":"baz","id":"2","last_modified":"45678"}]'; + do_check_eq(CanonicalJSON.stringify(records), expected); +}); + +add_task(function* test_canonicalJSON_does_not_add_space_separators() { + const records = [ + {'foo': 'bar', 'last_modified': '12345', 'id': '1'}, + {'bar': 'baz', 'last_modified': '45678', 'id': '2'}, + ] + const serialized = CanonicalJSON.stringify(records); + do_check_false(serialized.includes(" ")); +}); + +add_task(function* test_canonicalJSON_serializes_empty_object() { + do_check_eq(CanonicalJSON.stringify({}), "{}"); +}); + +add_task(function* test_canonicalJSON_serializes_empty_array() { + do_check_eq(CanonicalJSON.stringify([]), "[]"); +}); + +add_task(function* test_canonicalJSON_serializes_NaN() { + do_check_eq(CanonicalJSON.stringify(NaN), "null"); +}); + +add_task(function* test_canonicalJSON_serializes_inf() { + // This isn't part of the JSON standard. + do_check_eq(CanonicalJSON.stringify(Infinity), "null"); +}); + + +add_task(function* test_canonicalJSON_serializes_empty_string() { + do_check_eq(CanonicalJSON.stringify(""), '""'); +}); + +add_task(function* test_canonicalJSON_escapes_backslashes() { + do_check_eq(CanonicalJSON.stringify("This\\and this"), '"This\\\\and this"'); +}); + +add_task(function* test_canonicalJSON_handles_signed_zeros() { + // do_check_eq doesn't support comparison of -0 and 0 properly. + do_check_true(CanonicalJSON.stringify(-0) === '-0'); + do_check_true(CanonicalJSON.stringify(0) === '0'); +}); + + +add_task(function* test_canonicalJSON_with_deeply_nested_dicts() { + const records = [{ + 'a': { + 'b': 'b', + 'a': 'a', + 'c': { + 'b': 'b', + 'a': 'a', + 'c': ['b', 'a', 'c'], + 'd': {'b': 'b', 'a': 'a'}, + 'id': '1', + 'e': 1, + 'f': [2, 3, 1], + 'g': {2: 2, 3: 3, 1: { + 'b': 'b', 'a': 'a', 'c': 'c'}}}}, + 'id': '1'}] + const expected = + '[{"a":{"a":"a","b":"b","c":{"a":"a","b":"b","c":["b","a","c"],' + + '"d":{"a":"a","b":"b"},"e":1,"f":[2,3,1],"g":{' + + '"1":{"a":"a","b":"b","c":"c"},"2":2,"3":3},"id":"1"}},"id":"1"}]'; + + do_check_eq(CanonicalJSON.stringify(records), expected); +}); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/modules/tests/xpcshell/test_Color.js b/toolkit/modules/tests/xpcshell/test_Color.js new file mode 100644 index 0000000000..9bf9bf861c --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_Color.js @@ -0,0 +1,53 @@ +"use strict"; + +Components.utils.import("resource://gre/modules/Color.jsm"); + +function run_test() { + testRelativeLuminance(); + testIsBright(); + testContrastRatio(); + testIsContrastRatioAcceptable(); +} + +function testRelativeLuminance() { + let c = new Color(0, 0, 0); + Assert.equal(c.relativeLuminance, 0, "Black is not illuminating"); + + c = new Color(255, 255, 255); + Assert.equal(c.relativeLuminance, 1, "White is quite the luminant one"); + + c = new Color(142, 42, 142); + Assert.equal(c.relativeLuminance, 0.25263952353998204, + "This purple is not that luminant"); +} + +function testIsBright() { + let c = new Color(0, 0, 0); + Assert.equal(c.isBright, 0, "Black is bright"); + + c = new Color(255, 255, 255); + Assert.equal(c.isBright, 1, "White is bright"); +} + +function testContrastRatio() { + let c = new Color(0, 0, 0); + let c2 = new Color(255, 255, 255); + Assert.equal(c.contrastRatio(c2), 21, "Contrast between black and white is max"); + Assert.equal(c.contrastRatio(c), 1, "Contrast between equals is min"); + + let c3 = new Color(142, 42, 142); + Assert.equal(c.contrastRatio(c3), 6.05279047079964, "Contrast between black and purple"); + Assert.equal(c2.contrastRatio(c3), 3.469474137806338, "Contrast between white and purple"); +} + +function testIsContrastRatioAcceptable() { + // Let's assert what browser.js is doing for window frames. + let c = new Color(...[55, 156, 152]); + let c2 = new Color(0, 0, 0); + Assert.equal(c.r, 55, "Reds should match"); + Assert.equal(c.g, 156, "Greens should match"); + Assert.equal(c.b, 152, "Blues should match"); + Assert.ok(c.isContrastRatioAcceptable(c2), "The blue is high contrast enough"); + c = new Color(...[35, 65, 100]); + Assert.ok(!c.isContrastRatioAcceptable(c2), "The blue is not high contrast enough"); +} diff --git a/toolkit/modules/tests/xpcshell/test_DeferredTask.js b/toolkit/modules/tests/xpcshell/test_DeferredTask.js new file mode 100644 index 0000000000..441f9054c2 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_DeferredTask.js @@ -0,0 +1,390 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests the DeferredTask.jsm module. + */ + +// Globals + +var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask", + "resource://gre/modules/DeferredTask.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); + +/** + * Due to the nature of this module, most of the tests are time-dependent. All + * the timeouts are designed to occur at multiples of this granularity value, + * in milliseconds, that should be high enough to prevent intermittent failures, + * but low enough to prevent an excessive overall test execution time. + */ +const T = 100; + +/** + * Waits for the specified timeout before resolving the returned promise. + */ +function promiseTimeout(aTimeoutMs) +{ + let deferred = Promise.defer(); + do_timeout(aTimeoutMs, deferred.resolve); + return deferred.promise; +} + +function run_test() +{ + run_next_test(); +} + +// Tests + +/** + * Creates a simple DeferredTask and executes it once. + */ +add_test(function test_arm_simple() +{ + new DeferredTask(run_next_test, 10).arm(); +}); + +/** + * Checks that the delay set for the task is respected. + */ +add_test(function test_arm_delay_respected() +{ + let executed1 = false; + let executed2 = false; + + new DeferredTask(function () { + executed1 = true; + do_check_false(executed2); + }, 1*T).arm(); + + new DeferredTask(function () { + executed2 = true; + do_check_true(executed1); + run_next_test(); + }, 2*T).arm(); +}); + +/** + * Checks that calling "arm" again does not introduce further delay. + */ +add_test(function test_arm_delay_notrestarted() +{ + let executed = false; + + // Create a task that will run later. + let deferredTask = new DeferredTask(() => { executed = true; }, 4*T); + deferredTask.arm(); + + // Before the task starts, call "arm" again. + do_timeout(2*T, () => deferredTask.arm()); + + // The "arm" call should not have introduced further delays. + do_timeout(5*T, function () { + do_check_true(executed); + run_next_test(); + }); +}); + +/** + * Checks that a task runs only once when armed multiple times synchronously. + */ +add_test(function test_arm_coalesced() +{ + let executed = false; + + let deferredTask = new DeferredTask(function () { + do_check_false(executed); + executed = true; + run_next_test(); + }, 50); + + deferredTask.arm(); + deferredTask.arm(); +}); + +/** + * Checks that a task runs only once when armed multiple times synchronously, + * even when it has been created with a delay of zero milliseconds. + */ +add_test(function test_arm_coalesced_nodelay() +{ + let executed = false; + + let deferredTask = new DeferredTask(function () { + do_check_false(executed); + executed = true; + run_next_test(); + }, 0); + + deferredTask.arm(); + deferredTask.arm(); +}); + +/** + * Checks that a task can be armed again while running. + */ +add_test(function test_arm_recursive() +{ + let executed = false; + + let deferredTask = new DeferredTask(function () { + if (!executed) { + executed = true; + deferredTask.arm(); + } else { + run_next_test(); + } + }, 50); + + deferredTask.arm(); +}); + +/** + * Checks that calling "arm" while an asynchronous task is running waits until + * the task is finished before restarting the delay. + */ +add_test(function test_arm_async() +{ + let finishedExecution = false; + let finishedExecutionAgain = false; + + // Create a task that will run later. + let deferredTask = new DeferredTask(function* () { + yield promiseTimeout(4*T); + if (!finishedExecution) { + finishedExecution = true; + } else if (!finishedExecutionAgain) { + finishedExecutionAgain = true; + } + }, 2*T); + deferredTask.arm(); + + // While the task is running, call "arm" again. This will result in a wait + // of 2*T until the task finishes, then another 2*T for the normal task delay + // specified on construction. + do_timeout(4*T, function () { + do_check_true(deferredTask.isRunning); + do_check_false(finishedExecution); + deferredTask.arm(); + }); + + // This will fail in case the task was started without waiting 2*T after it + // has finished. + do_timeout(7*T, function () { + do_check_false(deferredTask.isRunning); + do_check_true(finishedExecution); + }); + + // This is in the middle of the second execution. + do_timeout(10*T, function () { + do_check_true(deferredTask.isRunning); + do_check_false(finishedExecutionAgain); + }); + + // Wait enough time to verify that the task was executed as expected. + do_timeout(13*T, function () { + do_check_false(deferredTask.isRunning); + do_check_true(finishedExecutionAgain); + run_next_test(); + }); +}); + +/** + * Checks that an armed task can be disarmed. + */ +add_test(function test_disarm() +{ + // Create a task that will run later. + let deferredTask = new DeferredTask(function () { + do_throw("This task should not run."); + }, 2*T); + deferredTask.arm(); + + // Disable execution later, but before the task starts. + do_timeout(1*T, () => deferredTask.disarm()); + + // Wait enough time to verify that the task did not run. + do_timeout(3*T, run_next_test); +}); + +/** + * Checks that calling "disarm" allows the delay to be restarted. + */ +add_test(function test_disarm_delay_restarted() +{ + let executed = false; + + let deferredTask = new DeferredTask(() => { executed = true; }, 4*T); + deferredTask.arm(); + + do_timeout(2*T, function () { + deferredTask.disarm(); + deferredTask.arm(); + }); + + do_timeout(5*T, function () { + do_check_false(executed); + }); + + do_timeout(7*T, function () { + do_check_true(executed); + run_next_test(); + }); +}); + +/** + * Checks that calling "disarm" while an asynchronous task is running does not + * prevent the task to finish. + */ +add_test(function test_disarm_async() +{ + let finishedExecution = false; + + let deferredTask = new DeferredTask(function* () { + deferredTask.arm(); + yield promiseTimeout(2*T); + finishedExecution = true; + }, 1*T); + deferredTask.arm(); + + do_timeout(2*T, function () { + do_check_true(deferredTask.isRunning); + do_check_true(deferredTask.isArmed); + do_check_false(finishedExecution); + deferredTask.disarm(); + }); + + do_timeout(4*T, function () { + do_check_false(deferredTask.isRunning); + do_check_false(deferredTask.isArmed); + do_check_true(finishedExecution); + run_next_test(); + }); +}); + +/** + * Checks that calling "arm" immediately followed by "disarm" while an + * asynchronous task is running does not cause it to run again. + */ +add_test(function test_disarm_immediate_async() +{ + let executed = false; + + let deferredTask = new DeferredTask(function* () { + do_check_false(executed); + executed = true; + yield promiseTimeout(2*T); + }, 1*T); + deferredTask.arm(); + + do_timeout(2*T, function () { + do_check_true(deferredTask.isRunning); + do_check_false(deferredTask.isArmed); + deferredTask.arm(); + deferredTask.disarm(); + }); + + do_timeout(4*T, function () { + do_check_true(executed); + do_check_false(deferredTask.isRunning); + do_check_false(deferredTask.isArmed); + run_next_test(); + }); +}); + +/** + * Checks the isArmed and isRunning properties with a synchronous task. + */ +add_test(function test_isArmed_isRunning() +{ + let deferredTask = new DeferredTask(function () { + do_check_true(deferredTask.isRunning); + do_check_false(deferredTask.isArmed); + deferredTask.arm(); + do_check_true(deferredTask.isArmed); + deferredTask.disarm(); + do_check_false(deferredTask.isArmed); + run_next_test(); + }, 50); + + do_check_false(deferredTask.isArmed); + deferredTask.arm(); + do_check_true(deferredTask.isArmed); + do_check_false(deferredTask.isRunning); +}); + +/** + * Checks that the "finalize" method executes a synchronous task. + */ +add_test(function test_finalize() +{ + let executed = false; + let timePassed = false; + + let deferredTask = new DeferredTask(function () { + do_check_false(timePassed); + executed = true; + }, 2*T); + deferredTask.arm(); + + do_timeout(1*T, () => { timePassed = true; }); + + // This should trigger the immediate execution of the task. + deferredTask.finalize().then(function () { + do_check_true(executed); + run_next_test(); + }); +}); + +/** + * Checks that the "finalize" method executes the task again from start to + * finish in case it is already running. + */ +add_test(function test_finalize_executes_entirely() +{ + let executed = false; + let executedAgain = false; + let timePassed = false; + + let deferredTask = new DeferredTask(function* () { + // The first time, we arm the timer again and set up the finalization. + if (!executed) { + deferredTask.arm(); + do_check_true(deferredTask.isArmed); + do_check_true(deferredTask.isRunning); + + deferredTask.finalize().then(function () { + // When we reach this point, the task must be finished. + do_check_true(executedAgain); + do_check_false(timePassed); + do_check_false(deferredTask.isArmed); + do_check_false(deferredTask.isRunning); + run_next_test(); + }); + + // The second execution triggered by the finalization waits 1*T for the + // current task to finish (see the timeout below), but then it must not + // wait for the 2*T specified on construction as normal task delay. The + // second execution will finish after the timeout below has passed again, + // for a total of 2*T of wait time. + do_timeout(3*T, () => { timePassed = true; }); + } + + yield promiseTimeout(1*T); + + // Just before finishing, indicate if we completed the second execution. + if (executed) { + do_check_true(deferredTask.isRunning); + executedAgain = true; + } else { + executed = true; + } + }, 2*T); + + deferredTask.arm(); +}); diff --git a/toolkit/modules/tests/xpcshell/test_FileUtils.js b/toolkit/modules/tests/xpcshell/test_FileUtils.js new file mode 100644 index 0000000000..86ac743890 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_FileUtils.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Components.utils.import("resource://gre/modules/FileUtils.jsm"); + +function do_check_throws(f, result, stack) { + if (!stack) + stack = Components.stack.caller; + + try { + f(); + } catch (exc) { + if (exc.result == result) + return; + do_throw("expected result " + result + ", caught " + exc, stack); + } + do_throw("expected result " + result + ", none thrown", stack); +} + +const gProfD = do_get_profile(); + +add_test(function test_getFile() { + let file = FileUtils.getFile("ProfD", ["foobar"]); + do_check_true(file instanceof Components.interfaces.nsIFile); + do_check_false(file.exists()); + + let other = gProfD.clone(); + other.append("foobar"); + do_check_true(file.equals(other)); + + run_next_test(); +}); + +add_test(function test_getFile_nonexistentDir() { + do_check_throws(function () { + let file = FileUtils.getFile("NonexistentD", ["foobar"]); + }, Components.results.NS_ERROR_FAILURE); + + run_next_test(); +}); + +add_test(function test_getFile_createDirs() { + let file = FileUtils.getFile("ProfD", ["a", "b", "foobar"]); + do_check_true(file instanceof Components.interfaces.nsIFile); + do_check_false(file.exists()); + + let other = gProfD.clone(); + other.append("a"); + do_check_true(other.isDirectory()); + other.append("b"); + do_check_true(other.isDirectory()); + other.append("foobar"); + do_check_true(file.equals(other)); + + run_next_test(); +}); + +add_test(function test_getDir() { + let dir = FileUtils.getDir("ProfD", ["foodir"]); + do_check_true(dir instanceof Components.interfaces.nsIFile); + do_check_false(dir.exists()); + + let other = gProfD.clone(); + other.append("foodir"); + do_check_true(dir.equals(other)); + + run_next_test(); +}); + +add_test(function test_getDir_nonexistentDir() { + do_check_throws(function () { + let file = FileUtils.getDir("NonexistentD", ["foodir"]); + }, Components.results.NS_ERROR_FAILURE); + + run_next_test(); +}); + +add_test(function test_getDir_shouldCreate() { + let dir = FileUtils.getDir("ProfD", ["c", "d", "foodir"], true); + do_check_true(dir instanceof Components.interfaces.nsIFile); + do_check_true(dir.exists()); + + let other = gProfD.clone(); + other.append("c"); + do_check_true(other.isDirectory()); + other.append("d"); + do_check_true(other.isDirectory()); + other.append("foodir"); + do_check_true(dir.equals(other)); + + run_next_test(); +}); + +var openFileOutputStream_defaultFlags = function (aKind, aFileName) { + let file = FileUtils.getFile("ProfD", [aFileName]); + let fos; + do_check_true(aKind == "atomic" || aKind == "safe" || aKind == ""); + if (aKind == "atomic") { + fos = FileUtils.openAtomicFileOutputStream(file); + } else if (aKind == "safe") { + fos = FileUtils.openSafeFileOutputStream(file); + } else { + fos = FileUtils.openFileOutputStream(file); + } + do_check_true(fos instanceof Components.interfaces.nsIFileOutputStream); + if (aKind == "atomic" || aKind == "safe") { + do_check_true(fos instanceof Components.interfaces.nsISafeOutputStream); + } + + // FileUtils.openFileOutputStream or FileUtils.openAtomicFileOutputStream() + // or FileUtils.openSafeFileOutputStream() opens the stream with DEFER_OPEN + // which means the file will not be open until we write to it. + do_check_false(file.exists()); + + let data = "test_default_flags"; + fos.write(data, data.length); + do_check_true(file.exists()); + + // No nsIXULRuntime in xpcshell, so use this trick to determine whether we're + // on Windows. + if ("@mozilla.org/windows-registry-key;1" in Components.classes) { + do_check_eq(file.permissions, 0o666); + } else { + do_check_eq(file.permissions, FileUtils.PERMS_FILE); + } + + run_next_test(); +}; + +var openFileOutputStream_modeFlags = function(aKind, aFileName) { + let file = FileUtils.getFile("ProfD", [aFileName]); + let fos; + do_check_true(aKind == "atomic" || aKind == "safe" || aKind == ""); + if (aKind == "atomic") { + fos = FileUtils.openAtomicFileOutputStream(file, FileUtils.MODE_WRONLY); + } else if (aKind == "safe") { + fos = FileUtils.openSafeFileOutputStream(file, FileUtils.MODE_WRONLY); + } else { + fos = FileUtils.openFileOutputStream(file, FileUtils.MODE_WRONLY); + } + let data = "test_modeFlags"; + do_check_throws(function () { + fos.write(data, data.length); + }, Components.results.NS_ERROR_FILE_NOT_FOUND); + do_check_false(file.exists()); + + run_next_test(); +}; + +var closeFileOutputStream = function(aKind, aFileName) { + let file = FileUtils.getFile("ProfD", [aFileName]); + let fos; + do_check_true(aKind == "atomic" || aKind == "safe"); + if (aKind == "atomic") { + fos = FileUtils.openAtomicFileOutputStream(file); + } else if (aKind == "safe") { + fos = FileUtils.openSafeFileOutputStream(file); + } + + // We can write data to the stream just fine while it's open. + let data = "testClose"; + fos.write(data, data.length); + + // But once we close it, we can't anymore. + if (aKind == "atomic") { + FileUtils.closeAtomicFileOutputStream(fos); + } else if (aKind == "safe") { + FileUtils.closeSafeFileOutputStream(fos); + } + do_check_throws(function () { + fos.write(data, data.length); + }, Components.results.NS_BASE_STREAM_CLOSED); + run_next_test(); +}; + +add_test(function test_openFileOutputStream_defaultFlags() { + openFileOutputStream_defaultFlags("", "george"); +}); + +// openFileOutputStream will uses MODE_WRONLY | MODE_CREATE | MODE_TRUNCATE +// as the default mode flags, but we can pass in our own if we want to. +add_test(function test_openFileOutputStream_modeFlags() { + openFileOutputStream_modeFlags("", "ringo"); +}); + +add_test(function test_openAtomicFileOutputStream_defaultFlags() { + openFileOutputStream_defaultFlags("atomic", "peiyong"); +}); + +// openAtomicFileOutputStream will uses MODE_WRONLY | MODE_CREATE | MODE_TRUNCATE +// as the default mode flags, but we can pass in our own if we want to. +add_test(function test_openAtomicFileOutputStream_modeFlags() { + openFileOutputStream_modeFlags("atomic", "lin"); +}); + +add_test(function test_closeAtomicFileOutputStream() { + closeFileOutputStream("atomic", "peiyonglin"); +}); + +add_test(function test_openSafeFileOutputStream_defaultFlags() { + openFileOutputStream_defaultFlags("safe", "john"); +}); + +// openSafeFileOutputStream will uses MODE_WRONLY | MODE_CREATE | MODE_TRUNCATE +// as the default mode flags, but we can pass in our own if we want to. +add_test(function test_openSafeFileOutputStream_modeFlags() { + openFileOutputStream_modeFlags("safe", "paul"); +}); + +add_test(function test_closeSafeFileOutputStream() { + closeFileOutputStream("safe", "georgee"); +}); + +add_test(function test_newFile() { + let testfile = FileUtils.getFile("ProfD", ["test"]); + let testpath = testfile.path; + let file = new FileUtils.File(testpath); + do_check_true(file instanceof Components.interfaces.nsILocalFile); + do_check_true(file.equals(testfile)); + do_check_eq(file.path, testpath); + run_next_test(); +}); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/modules/tests/xpcshell/test_FinderIterator.js b/toolkit/modules/tests/xpcshell/test_FinderIterator.js new file mode 100644 index 0000000000..02c923a007 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_FinderIterator.js @@ -0,0 +1,265 @@ +const { interfaces: Ci, classes: Cc, utils: Cu } = Components; +const { FinderIterator } = Cu.import("resource://gre/modules/FinderIterator.jsm", {}); +Cu.import("resource://gre/modules/Promise.jsm"); + +var gFindResults = []; +// Stub the method that instantiates nsIFind and does all the interaction with +// the docShell to be searched through. +FinderIterator._iterateDocument = function* (word, window, finder) { + for (let range of gFindResults) + yield range; +}; + +FinderIterator._rangeStartsInLink = fakeRange => fakeRange.startsInLink; + +function FakeRange(textContent, startsInLink = false) { + this.startContainer = {}; + this.startsInLink = startsInLink; + this.toString = () => textContent; +} + +var gMockWindow = { + setTimeout(cb, delay) { + Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer) + .initWithCallback(cb, delay, Ci.nsITimer.TYPE_ONE_SHOT); + } +}; + +var gMockFinder = { + _getWindow() { return gMockWindow; } +}; + +function prepareIterator(findText, rangeCount) { + gFindResults = []; + for (let i = rangeCount; --i >= 0;) + gFindResults.push(new FakeRange(findText)); +} + +add_task(function* test_start() { + let findText = "test"; + let rangeCount = 300; + prepareIterator(findText, rangeCount); + + let count = 0; + yield FinderIterator.start({ + caseSensitive: false, + entireWord: false, + finder: gMockFinder, + listener: { + onIteratorRangeFound(range) { + ++count; + Assert.equal(range.toString(), findText, "Text content should match"); + } + }, + word: findText + }); + + Assert.equal(rangeCount, count, "Amount of ranges yielded should match!"); + Assert.ok(!FinderIterator.running, "Running state should match"); + Assert.equal(FinderIterator._previousRanges.length, rangeCount, "Ranges cache should match"); + + FinderIterator.reset(); +}); + +add_task(function* test_valid_arguments() { + let findText = "foo"; + let rangeCount = 20; + prepareIterator(findText, rangeCount); + + let count = 0; + + yield FinderIterator.start({ + caseSensitive: false, + entireWord: false, + finder: gMockFinder, + listener: { onIteratorRangeFound(range) { ++count; } }, + word: findText + }); + + let params = FinderIterator._previousParams; + Assert.ok(!params.linksOnly, "Default for linksOnly is false"); + Assert.ok(!params.useCache, "Default for useCache is false"); + Assert.equal(params.word, findText, "Words should match"); + + count = 0; + Assert.throws(() => FinderIterator.start({ + entireWord: false, + listener: { onIteratorRangeFound(range) { ++count; } }, + word: findText + }), /Missing required option 'caseSensitive'/, "Should throw when missing an argument"); + FinderIterator.reset(); + + Assert.throws(() => FinderIterator.start({ + caseSensitive: false, + listener: { onIteratorRangeFound(range) { ++count; } }, + word: findText + }), /Missing required option 'entireWord'/, "Should throw when missing an argument"); + FinderIterator.reset(); + + Assert.throws(() => FinderIterator.start({ + caseSensitive: false, + entireWord: false, + listener: { onIteratorRangeFound(range) { ++count; } }, + word: findText + }), /Missing required option 'finder'/, "Should throw when missing an argument"); + FinderIterator.reset(); + + Assert.throws(() => FinderIterator.start({ + caseSensitive: true, + entireWord: false, + finder: gMockFinder, + word: findText + }), /Missing valid, required option 'listener'/, "Should throw when missing an argument"); + FinderIterator.reset(); + + Assert.throws(() => FinderIterator.start({ + caseSensitive: false, + entireWord: true, + finder: gMockFinder, + listener: { onIteratorRangeFound(range) { ++count; } }, + }), /Missing required option 'word'/, "Should throw when missing an argument"); + FinderIterator.reset(); + + Assert.equal(count, 0, "No ranges should've been counted"); +}); + +add_task(function* test_stop() { + let findText = "bar"; + let rangeCount = 120; + prepareIterator(findText, rangeCount); + + let count = 0; + let whenDone = FinderIterator.start({ + caseSensitive: false, + entireWord: false, + finder: gMockFinder, + listener: { onIteratorRangeFound(range) { ++count; } }, + word: findText + }); + + FinderIterator.stop(); + + yield whenDone; + + Assert.equal(count, 0, "Number of ranges should be 0"); + + FinderIterator.reset(); +}); + +add_task(function* test_reset() { + let findText = "tik"; + let rangeCount = 142; + prepareIterator(findText, rangeCount); + + let count = 0; + let whenDone = FinderIterator.start({ + caseSensitive: false, + entireWord: false, + finder: gMockFinder, + listener: { onIteratorRangeFound(range) { ++count; } }, + word: findText + }); + + Assert.ok(FinderIterator.running, "Yup, running we are"); + Assert.equal(count, 0, "Number of ranges should match 0"); + Assert.equal(FinderIterator.ranges.length, 0, "Number of ranges should match 0"); + + FinderIterator.reset(); + + Assert.ok(!FinderIterator.running, "Nope, running we are not"); + Assert.equal(FinderIterator.ranges.length, 0, "No ranges after reset"); + Assert.equal(FinderIterator._previousRanges.length, 0, "No ranges after reset"); + + yield whenDone; + + Assert.equal(count, 0, "Number of ranges should match 0"); +}); + +add_task(function* test_parallel_starts() { + let findText = "tak"; + let rangeCount = 2143; + prepareIterator(findText, rangeCount); + + // Start off the iterator. + let count = 0; + let whenDone = FinderIterator.start({ + caseSensitive: false, + entireWord: false, + finder: gMockFinder, + listener: { onIteratorRangeFound(range) { ++count; } }, + word: findText + }); + + yield new Promise(resolve => gMockWindow.setTimeout(resolve, 120)); + Assert.ok(FinderIterator.running, "We ought to be running here"); + + let count2 = 0; + let whenDone2 = FinderIterator.start({ + caseSensitive: false, + entireWord: false, + finder: gMockFinder, + listener: { onIteratorRangeFound(range) { ++count2; } }, + word: findText + }); + + // Let the iterator run for a little while longer before we assert the world. + yield new Promise(resolve => gMockWindow.setTimeout(resolve, 10)); + FinderIterator.stop(); + + Assert.ok(!FinderIterator.running, "Stop means stop"); + + yield whenDone; + yield whenDone2; + + Assert.greater(count, FinderIterator.kIterationSizeMax, "At least one range should've been found"); + Assert.less(count, rangeCount, "Not all ranges should've been found"); + Assert.greater(count2, FinderIterator.kIterationSizeMax, "At least one range should've been found"); + Assert.less(count2, rangeCount, "Not all ranges should've been found"); + + Assert.equal(count2, count, "The second start was later, but should have caught up"); + + FinderIterator.reset(); +}); + +add_task(function* test_allowDistance() { + let findText = "gup"; + let rangeCount = 20; + prepareIterator(findText, rangeCount); + + // Start off the iterator. + let count = 0; + let whenDone = FinderIterator.start({ + caseSensitive: false, + entireWord: false, + finder: gMockFinder, + listener: { onIteratorRangeFound(range) { ++count; } }, + word: findText + }); + + let count2 = 0; + let whenDone2 = FinderIterator.start({ + caseSensitive: false, + entireWord: false, + finder: gMockFinder, + listener: { onIteratorRangeFound(range) { ++count2; } }, + word: "gu" + }); + + let count3 = 0; + let whenDone3 = FinderIterator.start({ + allowDistance: 1, + caseSensitive: false, + entireWord: false, + finder: gMockFinder, + listener: { onIteratorRangeFound(range) { ++count3; } }, + word: "gu" + }); + + yield Promise.all([whenDone, whenDone2, whenDone3]); + + Assert.equal(count, rangeCount, "The first iterator invocation should yield all results"); + Assert.equal(count2, 0, "The second iterator invocation should yield _no_ results"); + Assert.equal(count3, rangeCount, "The first iterator invocation should yield all results"); + + FinderIterator.reset(); +}); diff --git a/toolkit/modules/tests/xpcshell/test_GMPInstallManager.js b/toolkit/modules/tests/xpcshell/test_GMPInstallManager.js new file mode 100644 index 0000000000..74d5ad43d0 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_GMPInstallManager.js @@ -0,0 +1,794 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu, manager: Cm} = Components; +const URL_HOST = "http://localhost"; + +var GMPScope = Cu.import("resource://gre/modules/GMPInstallManager.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://testing-common/httpd.js"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm") +Cu.import("resource://gre/modules/UpdateUtils.jsm"); + +var { computeHash } = Cu.import("resource://gre/modules/addons/ProductAddonChecker.jsm"); +var ProductAddonCheckerScope = Cu.import("resource://gre/modules/addons/ProductAddonChecker.jsm"); + +do_get_profile(); + +function run_test() { Cu.import("resource://gre/modules/Preferences.jsm") + Preferences.set("media.gmp.log.dump", true); + Preferences.set("media.gmp.log.level", 0); + run_next_test(); +} + +/** + * Tests that the helper used for preferences works correctly + */ +add_task(function* test_prefs() { + let addon1 = "addon1", addon2 = "addon2"; + + GMPScope.GMPPrefs.set(GMPScope.GMPPrefs.KEY_URL, "http://not-really-used"); + GMPScope.GMPPrefs.set(GMPScope.GMPPrefs.KEY_URL_OVERRIDE, "http://not-really-used-2"); + GMPScope.GMPPrefs.set(GMPScope.GMPPrefs.KEY_PLUGIN_LAST_UPDATE, "1", addon1); + GMPScope.GMPPrefs.set(GMPScope.GMPPrefs.KEY_PLUGIN_VERSION, "2", addon1); + GMPScope.GMPPrefs.set(GMPScope.GMPPrefs.KEY_PLUGIN_LAST_UPDATE, "3", addon2); + GMPScope.GMPPrefs.set(GMPScope.GMPPrefs.KEY_PLUGIN_VERSION, "4", addon2); + GMPScope.GMPPrefs.set(GMPScope.GMPPrefs.KEY_PLUGIN_AUTOUPDATE, false, addon2); + GMPScope.GMPPrefs.set(GMPScope.GMPPrefs.KEY_CERT_CHECKATTRS, true); + + do_check_eq(GMPScope.GMPPrefs.get(GMPScope.GMPPrefs.KEY_URL), "http://not-really-used"); + do_check_eq(GMPScope.GMPPrefs.get(GMPScope.GMPPrefs.KEY_URL_OVERRIDE), + "http://not-really-used-2"); + do_check_eq(GMPScope.GMPPrefs.get(GMPScope.GMPPrefs.KEY_PLUGIN_LAST_UPDATE, "", addon1), "1"); + do_check_eq(GMPScope.GMPPrefs.get(GMPScope.GMPPrefs.KEY_PLUGIN_VERSION, "", addon1), "2"); + do_check_eq(GMPScope.GMPPrefs.get(GMPScope.GMPPrefs.KEY_PLUGIN_LAST_UPDATE, "", addon2), "3"); + do_check_eq(GMPScope.GMPPrefs.get(GMPScope.GMPPrefs.KEY_PLUGIN_VERSION, "", addon2), "4"); + do_check_eq(GMPScope.GMPPrefs.get(GMPScope.GMPPrefs.KEY_PLUGIN_AUTOUPDATE, undefined, addon2), + false); + do_check_true(GMPScope.GMPPrefs.get(GMPScope.GMPPrefs.KEY_CERT_CHECKATTRS)); + GMPScope.GMPPrefs.set(GMPScope.GMPPrefs.KEY_PLUGIN_AUTOUPDATE, true, addon2); +}); + +/** + * Tests that an uninit without a check works fine + */ +add_task(function* test_checkForAddons_uninitWithoutCheck() { + let installManager = new GMPInstallManager(); + installManager.uninit(); +}); + +/** + * Tests that an uninit without an install works fine + */ +add_test(function test_checkForAddons_uninitWithoutInstall() { + overrideXHR(200, ""); + let installManager = new GMPInstallManager(); + let promise = installManager.checkForAddons(); + promise.then(res => { + do_check_true(res.usedFallback); + installManager.uninit(); + run_next_test(); + }); +}); + +/** + * Tests that no response returned rejects + */ +add_test(function test_checkForAddons_noResponse() { + overrideXHR(200, ""); + let installManager = new GMPInstallManager(); + let promise = installManager.checkForAddons(); + promise.then(res => { + do_check_true(res.usedFallback); + installManager.uninit(); + run_next_test(); + }); +}); + +/** + * Tests that no addons element returned resolves with no addons + */ +add_task(function* test_checkForAddons_noAddonsElement() { + overrideXHR(200, "<updates></updates>"); + let installManager = new GMPInstallManager(); + let res = yield installManager.checkForAddons(); + do_check_eq(res.gmpAddons.length, 0); + installManager.uninit(); +}); + +/** + * Tests that empty addons element returned resolves with no addons + */ +add_task(function* test_checkForAddons_emptyAddonsElement() { + overrideXHR(200, "<updates><addons/></updates>"); + let installManager = new GMPInstallManager(); + let res = yield installManager.checkForAddons(); + do_check_eq(res.gmpAddons.length, 0); + installManager.uninit(); +}); + +/** + * Tests that a response with the wrong root element rejects + */ +add_test(function test_checkForAddons_wrongResponseXML() { + overrideXHR(200, "<digits_of_pi>3.141592653589793....</digits_of_pi>"); + let installManager = new GMPInstallManager(); + let promise = installManager.checkForAddons(); + promise.then(res => { + do_check_true(res.usedFallback); + installManager.uninit(); + run_next_test(); + }); +}); + +/** + * Tests that a 404 error works as expected + */ +add_test(function test_checkForAddons_404Error() { + overrideXHR(404, ""); + let installManager = new GMPInstallManager(); + let promise = installManager.checkForAddons(); + promise.then(res => { + do_check_true(res.usedFallback); + installManager.uninit(); + run_next_test(); + }); +}); + +/** + * Tests that a xhr abort() works as expected + */ +add_test(function test_checkForAddons_abort() { + let overriddenXhr = overrideXHR(200, "", { dropRequest: true} ); + let installManager = new GMPInstallManager(); + let promise = installManager.checkForAddons(); + overriddenXhr.abort(); + promise.then(res => { + do_check_true(res.usedFallback); + installManager.uninit(); + run_next_test(); + }); +}); + +/** + * Tests that a defensive timeout works as expected + */ +add_test(function test_checkForAddons_timeout() { + overrideXHR(200, "", { dropRequest: true, timeout: true }); + let installManager = new GMPInstallManager(); + let promise = installManager.checkForAddons(); + promise.then(res => { + do_check_true(res.usedFallback); + installManager.uninit(); + run_next_test(); + }); +}); + +/** + * Tests that we throw correctly in case of ssl certification error. + */ +add_test(function test_checkForAddons_bad_ssl() { + // + // Add random stuff that cause CertUtil to require https. + // + let PREF_KEY_URL_OVERRIDE_BACKUP = + Preferences.get(GMPScope.GMPPrefs.KEY_URL_OVERRIDE, undefined); + Preferences.reset(GMPScope.GMPPrefs.KEY_URL_OVERRIDE); + + let CERTS_BRANCH_DOT_ONE = GMPScope.GMPPrefs.KEY_CERTS_BRANCH + ".1"; + let PREF_CERTS_BRANCH_DOT_ONE_BACKUP = + Preferences.get(CERTS_BRANCH_DOT_ONE, undefined); + Services.prefs.setCharPref(CERTS_BRANCH_DOT_ONE, "funky value"); + + + overrideXHR(200, ""); + let installManager = new GMPInstallManager(); + let promise = installManager.checkForAddons(); + promise.then(res => { + do_check_true(res.usedFallback); + installManager.uninit(); + if (PREF_KEY_URL_OVERRIDE_BACKUP) { + Preferences.set(GMPScope.GMPPrefs.KEY_URL_OVERRIDE, + PREF_KEY_URL_OVERRIDE_BACKUP); + } + if (PREF_CERTS_BRANCH_DOT_ONE_BACKUP) { + Preferences.set(CERTS_BRANCH_DOT_ONE, + PREF_CERTS_BRANCH_DOT_ONE_BACKUP); + } + run_next_test(); + }); +}); + +/** + * Tests that gettinga a funky non XML response works as expected + */ +add_test(function test_checkForAddons_notXML() { + overrideXHR(200, "3.141592653589793...."); + let installManager = new GMPInstallManager(); + let promise = installManager.checkForAddons(); + + promise.then(res => { + do_check_true(res.usedFallback); + installManager.uninit(); + run_next_test(); + }); +}); + +/** + * Tests that getting a response with a single addon works as expected + */ +add_task(function* test_checkForAddons_singleAddon() { + let responseXML = + "<?xml version=\"1.0\"?>" + + "<updates>" + + " <addons>" + + " <addon id=\"gmp-gmpopenh264\"" + + " URL=\"http://127.0.0.1:8011/gmp-gmpopenh264-1.1.zip\"" + + " hashFunction=\"sha256\"" + + " hashValue=\"1118b90d6f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee\"" + + " version=\"1.1\"/>" + + " </addons>" + + "</updates>" + overrideXHR(200, responseXML); + let installManager = new GMPInstallManager(); + let res = yield installManager.checkForAddons(); + do_check_eq(res.gmpAddons.length, 1); + let gmpAddon = res.gmpAddons[0]; + do_check_eq(gmpAddon.id, "gmp-gmpopenh264"); + do_check_eq(gmpAddon.URL, "http://127.0.0.1:8011/gmp-gmpopenh264-1.1.zip"); + do_check_eq(gmpAddon.hashFunction, "sha256"); + do_check_eq(gmpAddon.hashValue, "1118b90d6f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee"); + do_check_eq(gmpAddon.version, "1.1"); + do_check_eq(gmpAddon.size, undefined); + do_check_true(gmpAddon.isValid); + do_check_false(gmpAddon.isInstalled); + installManager.uninit(); +}); + +/** + * Tests that getting a response with a single addon with the optional size + * attribute parses as expected. + */ +add_task(function* test_checkForAddons_singleAddonWithSize() { + let responseXML = + "<?xml version=\"1.0\"?>" + + "<updates>" + + " <addons>" + + " <addon id=\"openh264-plugin-no-at-symbol\"" + + " URL=\"http://127.0.0.1:8011/gmp-gmpopenh264-1.1.zip\"" + + " hashFunction=\"sha256\"" + + " size=\"42\"" + + " hashValue=\"1118b90d6f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee\"" + + " version=\"1.1\"/>" + + " </addons>" + + "</updates>" + overrideXHR(200, responseXML); + let installManager = new GMPInstallManager(); + let res = yield installManager.checkForAddons(); + do_check_eq(res.gmpAddons.length, 1); + let gmpAddon = res.gmpAddons[0]; + do_check_eq(gmpAddon.id, "openh264-plugin-no-at-symbol"); + do_check_eq(gmpAddon.URL, "http://127.0.0.1:8011/gmp-gmpopenh264-1.1.zip"); + do_check_eq(gmpAddon.hashFunction, "sha256"); + do_check_eq(gmpAddon.hashValue, "1118b90d6f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee"); + do_check_eq(gmpAddon.size, 42); + do_check_eq(gmpAddon.version, "1.1"); + do_check_true(gmpAddon.isValid); + do_check_false(gmpAddon.isInstalled); + installManager.uninit(); +}); + +/** + * Tests that checking for multiple addons work correctly. + * Also tests that invalid addons work correctly. + */ +add_task(function* test_checkForAddons_multipleAddonNoUpdatesSomeInvalid() { + let responseXML = + "<?xml version=\"1.0\"?>" + + "<updates>" + + " <addons>" + + // valid openh264 + " <addon id=\"gmp-gmpopenh264\"" + + " URL=\"http://127.0.0.1:8011/gmp-gmpopenh264-1.1.zip\"" + + " hashFunction=\"sha256\"" + + " hashValue=\"1118b90d6f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee\"" + + " version=\"1.1\"/>" + + // valid not openh264 + " <addon id=\"NOT-gmp-gmpopenh264\"" + + " URL=\"http://127.0.0.1:8011/NOT-gmp-gmpopenh264-1.1.zip\"" + + " hashFunction=\"sha512\"" + + " hashValue=\"141592656f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee\"" + + " version=\"9.1\"/>" + + // noid + " <addon notid=\"NOT-gmp-gmpopenh264\"" + + " URL=\"http://127.0.0.1:8011/NOT-gmp-gmpopenh264-1.1.zip\"" + + " hashFunction=\"sha512\"" + + " hashValue=\"141592656f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee\"" + + " version=\"9.1\"/>" + + // no URL + " <addon id=\"NOT-gmp-gmpopenh264\"" + + " notURL=\"http://127.0.0.1:8011/NOT-gmp-gmpopenh264-1.1.zip\"" + + " hashFunction=\"sha512\"" + + " hashValue=\"141592656f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee\"" + + " version=\"9.1\"/>" + + // no hash function + " <addon id=\"NOT-gmp-gmpopenh264\"" + + " URL=\"http://127.0.0.1:8011/NOT-gmp-gmpopenh264-1.1.zip\"" + + " nothashFunction=\"sha512\"" + + " hashValue=\"141592656f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee\"" + + " version=\"9.1\"/>" + + // no hash function + " <addon id=\"NOT-gmp-gmpopenh264\"" + + " URL=\"http://127.0.0.1:8011/NOT-gmp-gmpopenh264-1.1.zip\"" + + " hashFunction=\"sha512\"" + + " nothashValue=\"141592656f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee\"" + + " version=\"9.1\"/>" + + // not version + " <addon id=\"NOT-gmp-gmpopenh264\"" + + " URL=\"http://127.0.0.1:8011/NOT-gmp-gmpopenh264-1.1.zip\"" + + " hashFunction=\"sha512\"" + + " hashValue=\"141592656f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee\"" + + " notversion=\"9.1\"/>" + + " </addons>" + + "</updates>" + overrideXHR(200, responseXML); + let installManager = new GMPInstallManager(); + let res = yield installManager.checkForAddons(); + do_check_eq(res.gmpAddons.length, 7); + let gmpAddon = res.gmpAddons[0]; + do_check_eq(gmpAddon.id, "gmp-gmpopenh264"); + do_check_eq(gmpAddon.URL, "http://127.0.0.1:8011/gmp-gmpopenh264-1.1.zip"); + do_check_eq(gmpAddon.hashFunction, "sha256"); + do_check_eq(gmpAddon.hashValue, "1118b90d6f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee"); + do_check_eq(gmpAddon.version, "1.1"); + do_check_true(gmpAddon.isValid); + do_check_false(gmpAddon.isInstalled); + + gmpAddon = res.gmpAddons[1]; + do_check_eq(gmpAddon.id, "NOT-gmp-gmpopenh264"); + do_check_eq(gmpAddon.URL, "http://127.0.0.1:8011/NOT-gmp-gmpopenh264-1.1.zip"); + do_check_eq(gmpAddon.hashFunction, "sha512"); + do_check_eq(gmpAddon.hashValue, "141592656f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee"); + do_check_eq(gmpAddon.version, "9.1"); + do_check_true(gmpAddon.isValid); + do_check_false(gmpAddon.isInstalled); + + for (let i = 2; i < res.gmpAddons.length; i++) { + do_check_false(res.gmpAddons[i].isValid); + do_check_false(res.gmpAddons[i].isInstalled); + } + installManager.uninit(); +}); + +/** + * Tests that checking for addons when there are also updates available + * works as expected. + */ +add_task(function* test_checkForAddons_updatesWithAddons() { + let responseXML = + "<?xml version=\"1.0\"?>" + + " <updates>" + + " <update type=\"minor\" displayVersion=\"33.0a1\" appVersion=\"33.0a1\" platformVersion=\"33.0a1\" buildID=\"20140628030201\">" + + " <patch type=\"complete\" URL=\"http://ftp.mozilla.org/pub/mozilla.org/firefox/nightly/2014/06/2014-06-28-03-02-01-mozilla-central/firefox-33.0a1.en-US.mac.complete.mar\" hashFunction=\"sha512\" hashValue=\"f3f90d71dff03ae81def80e64bba3e4569da99c9e15269f731c2b167c4fc30b3aed9f5fee81c19614120230ca333e73a5e7def1b8e45d03135b2069c26736219\" size=\"85249896\"/>" + + " </update>" + + " <addons>" + + " <addon id=\"gmp-gmpopenh264\"" + + " URL=\"http://127.0.0.1:8011/gmp-gmpopenh264-1.1.zip\"" + + " hashFunction=\"sha256\"" + + " hashValue=\"1118b90d6f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee\"" + + " version=\"1.1\"/>" + + " </addons>" + + "</updates>" + overrideXHR(200, responseXML); + let installManager = new GMPInstallManager(); + let res = yield installManager.checkForAddons(); + do_check_eq(res.gmpAddons.length, 1); + let gmpAddon = res.gmpAddons[0]; + do_check_eq(gmpAddon.id, "gmp-gmpopenh264"); + do_check_eq(gmpAddon.URL, "http://127.0.0.1:8011/gmp-gmpopenh264-1.1.zip"); + do_check_eq(gmpAddon.hashFunction, "sha256"); + do_check_eq(gmpAddon.hashValue, "1118b90d6f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee"); + do_check_eq(gmpAddon.version, "1.1"); + do_check_true(gmpAddon.isValid); + do_check_false(gmpAddon.isInstalled); + installManager.uninit(); +}); + +/** + * Tests that installing found addons works as expected + */ +function* test_checkForAddons_installAddon(id, includeSize, wantInstallReject) { + do_print("Running installAddon for id: " + id + + ", includeSize: " + includeSize + + " and wantInstallReject: " + wantInstallReject); + let httpServer = new HttpServer(); + let dir = FileUtils.getDir("TmpD", [], true); + httpServer.registerDirectory("/", dir); + httpServer.start(-1); + let testserverPort = httpServer.identity.primaryPort; + let zipFileName = "test_" + id + "_GMP.zip"; + + let zipURL = URL_HOST + ":" + testserverPort + "/" + zipFileName; + do_print("zipURL: " + zipURL); + + let data = "e~=0.5772156649"; + let zipFile = createNewZipFile(zipFileName, data); + let hashFunc = "sha256"; + let expectedDigest = yield computeHash(hashFunc, zipFile.path); + let fileSize = zipFile.fileSize; + if (wantInstallReject) { + fileSize = 1; + } + + let responseXML = + "<?xml version=\"1.0\"?>" + + "<updates>" + + " <addons>" + + " <addon id=\"" + id + "-gmp-gmpopenh264\"" + + " URL=\"" + zipURL + "\"" + + " hashFunction=\"" + hashFunc + "\"" + + " hashValue=\"" + expectedDigest + "\"" + + (includeSize ? " size=\"" + fileSize + "\"" : "") + + " version=\"1.1\"/>" + + " </addons>" + + "</updates>" + + overrideXHR(200, responseXML); + let installManager = new GMPInstallManager(); + let res = yield installManager.checkForAddons(); + do_check_eq(res.gmpAddons.length, 1); + let gmpAddon = res.gmpAddons[0]; + do_check_false(gmpAddon.isInstalled); + + try { + let extractedPaths = yield installManager.installAddon(gmpAddon); + if (wantInstallReject) { + do_check_true(false); // installAddon() should have thrown. + } + do_check_eq(extractedPaths.length, 1); + let extractedPath = extractedPaths[0]; + + do_print("Extracted path: " + extractedPath); + + let extractedFile = Cc["@mozilla.org/file/local;1"]. + createInstance(Ci.nsIFile); + extractedFile.initWithPath(extractedPath); + do_check_true(extractedFile.exists()); + let readData = readStringFromFile(extractedFile); + do_check_eq(readData, data); + + // Make sure the prefs are set correctly + do_check_true(!!GMPScope.GMPPrefs.get( + GMPScope.GMPPrefs.KEY_PLUGIN_LAST_UPDATE, "", gmpAddon.id)); + do_check_eq(GMPScope.GMPPrefs.get(GMPScope.GMPPrefs.KEY_PLUGIN_VERSION, "", + gmpAddon.id), + "1.1"); + do_check_eq(GMPScope.GMPPrefs.get(GMPScope.GMPPrefs.KEY_PLUGIN_ABI, "", + gmpAddon.id), + UpdateUtils.ABI); + // Make sure it reports as being installed + do_check_true(gmpAddon.isInstalled); + + // Cleanup + extractedFile.parent.remove(true); + zipFile.remove(false); + httpServer.stop(function() {}); + installManager.uninit(); + } catch (ex) { + zipFile.remove(false); + if (!wantInstallReject) { + do_throw("install update should not reject " + ex.message); + } + } +} + +add_task(test_checkForAddons_installAddon.bind(null, "1", true, false)); +add_task(test_checkForAddons_installAddon.bind(null, "2", false, false)); +add_task(test_checkForAddons_installAddon.bind(null, "3", true, true)); + +/** + * Tests simpleCheckAndInstall when autoupdate is disabled for a GMP + */ +add_task(function* test_simpleCheckAndInstall_autoUpdateDisabled() { + GMPScope.GMPPrefs.set(GMPScope.GMPPrefs.KEY_PLUGIN_AUTOUPDATE, false, GMPScope.OPEN_H264_ID); + let responseXML = + "<?xml version=\"1.0\"?>" + + "<updates>" + + " <addons>" + + // valid openh264 + " <addon id=\"gmp-gmpopenh264\"" + + " URL=\"http://127.0.0.1:8011/gmp-gmpopenh264-1.1.zip\"" + + " hashFunction=\"sha256\"" + + " hashValue=\"1118b90d6f645eefc2b99af17bae396636ace1e33d079c88de715177584e2aee\"" + + " version=\"1.1\"/>" + + " </addons>" + + "</updates>" + + overrideXHR(200, responseXML); + let installManager = new GMPInstallManager(); + let result = yield installManager.simpleCheckAndInstall(); + do_check_eq(result.status, "nothing-new-to-install"); + Preferences.reset(GMPScope.GMPPrefs.KEY_UPDATE_LAST_CHECK); + GMPScope.GMPPrefs.set(GMPScope.GMPPrefs.KEY_PLUGIN_AUTOUPDATE, true, GMPScope.OPEN_H264_ID); +}); + +/** + * Tests simpleCheckAndInstall nothing to install + */ +add_task(function* test_simpleCheckAndInstall_nothingToInstall() { + let responseXML = + "<?xml version=\"1.0\"?>" + + "<updates>" + + "</updates>" + + overrideXHR(200, responseXML); + let installManager = new GMPInstallManager(); + let result = yield installManager.simpleCheckAndInstall(); + do_check_eq(result.status, "nothing-new-to-install"); +}); + +/** + * Tests simpleCheckAndInstall too frequent + */ +add_task(function* test_simpleCheckAndInstall_tooFrequent() { + let responseXML = + "<?xml version=\"1.0\"?>" + + "<updates>" + + "</updates>" + + overrideXHR(200, responseXML); + let installManager = new GMPInstallManager(); + let result = yield installManager.simpleCheckAndInstall(); + do_check_eq(result.status, "too-frequent-no-check"); +}); + +/** + * Tests that installing addons when there is no server works as expected + */ +add_test(function test_installAddon_noServer() { + let dir = FileUtils.getDir("TmpD", [], true); + let zipFileName = "test_GMP.zip"; + let zipURL = URL_HOST + ":0/" + zipFileName; + + let data = "e~=0.5772156649"; + let zipFile = createNewZipFile(zipFileName, data); + + let responseXML = + "<?xml version=\"1.0\"?>" + + "<updates>" + + " <addons>" + + " <addon id=\"gmp-gmpopenh264\"" + + " URL=\"" + zipURL + "\"" + + " hashFunction=\"sha256\"" + + " hashValue=\"11221cbda000347b054028b527a60e578f919cb10f322ef8077d3491c6fcb474\"" + + " version=\"1.1\"/>" + + " </addons>" + + "</updates>" + + overrideXHR(200, responseXML); + let installManager = new GMPInstallManager(); + let checkPromise = installManager.checkForAddons(); + checkPromise.then(res => { + do_check_eq(res.gmpAddons.length, 1); + let gmpAddon = res.gmpAddons[0]; + + GMPInstallManager.overrideLeaveDownloadedZip = true; + let installPromise = installManager.installAddon(gmpAddon); + installPromise.then(extractedPaths => { + do_throw("No server for install should reject"); + }, err => { + do_check_true(!!err); + installManager.uninit(); + run_next_test(); + }); + }, () => { + do_throw("check should not reject for install no server"); + }); +}); + +/** + * Returns the read stream into a string + */ +function readStringFromInputStream(inputStream) { + let sis = Cc["@mozilla.org/scriptableinputstream;1"]. + createInstance(Ci.nsIScriptableInputStream); + sis.init(inputStream); + let text = sis.read(sis.available()); + sis.close(); + return text; +} + +/** + * Reads a string of text from a file. + * This function only works with ASCII text. + */ +function readStringFromFile(file) { + if (!file.exists()) { + do_print("readStringFromFile - file doesn't exist: " + file.path); + return null; + } + let fis = Cc["@mozilla.org/network/file-input-stream;1"]. + createInstance(Ci.nsIFileInputStream); + fis.init(file, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0); + return readStringFromInputStream(fis); +} + +/** + * Bare bones XMLHttpRequest implementation for testing onprogress, onerror, + * and onload nsIDomEventListener handleEvent. + */ +function makeHandler(aVal) { + if (typeof aVal == "function") + return { handleEvent: aVal }; + return aVal; +} +/** + * Constructs a mock xhr which is used for testing different aspects + * of responses. + */ +function xhr(inputStatus, inputResponse, options) { + this.inputStatus = inputStatus; + this.inputResponse = inputResponse; + this.status = 0; + this.responseXML = null; + this._aborted = false; + this._onabort = null; + this._onprogress = null; + this._onerror = null; + this._onload = null; + this._onloadend = null; + this._ontimeout = null; + this._url = null; + this._method = null; + this._timeout = 0; + this._notified = false; + this._options = options || {}; +} +xhr.prototype = { + overrideMimeType: function(aMimetype) { }, + setRequestHeader: function(aHeader, aValue) { }, + status: null, + channel: { set notificationCallbacks(aVal) { } }, + open: function(aMethod, aUrl) { + this.channel.originalURI = Services.io.newURI(aUrl, null, null); + this._method = aMethod; this._url = aUrl; + }, + abort: function() { + this._dropRequest = true; + this._notify(["abort", "loadend"]); + }, + responseXML: null, + responseText: null, + send: function(aBody) { + do_execute_soon(function() { + try { + if (this._options.dropRequest) { + if (this._timeout > 0 && this._options.timeout) { + this._notify(["timeout", "loadend"]); + } + return; + } + this.status = this.inputStatus; + this.responseText = this.inputResponse; + try { + let parser = Cc["@mozilla.org/xmlextras/domparser;1"]. + createInstance(Ci.nsIDOMParser); + this.responseXML = parser.parseFromString(this.inputResponse, + "application/xml"); + } catch (e) { + this.responseXML = null; + } + if (this.inputStatus === 200) { + this._notify(["load", "loadend"]); + } else { + this._notify(["error", "loadend"]); + } + } catch (ex) { + do_throw(ex); + } + }.bind(this)); + }, + set onabort(aValue) { this._onabort = makeHandler(aValue); }, + get onabort() { return this._onabort; }, + set onprogress(aValue) { this._onprogress = makeHandler(aValue); }, + get onprogress() { return this._onprogress; }, + set onerror(aValue) { this._onerror = makeHandler(aValue); }, + get onerror() { return this._onerror; }, + set onload(aValue) { this._onload = makeHandler(aValue); }, + get onload() { return this._onload; }, + set onloadend(aValue) { this._onloadend = makeHandler(aValue); }, + get onloadend() { return this._onloadend; }, + set ontimeout(aValue) { this._ontimeout = makeHandler(aValue); }, + get ontimeout() { return this._ontimeout; }, + set timeout(aValue) { this._timeout = aValue; }, + _notify: function(events) { + if (this._notified) { + return; + } + this._notified = true; + for (let item of events) { + let k = "on" + item; + if (this[k]) { + do_print("Notifying " + item); + let e = { + target: this, + type: item, + }; + this[k](e); + } else { + do_print("Notifying " + item + ", but there are no listeners"); + } + } + }, + addEventListener: function(aEvent, aValue, aCapturing) { + eval("this._on" + aEvent + " = aValue"); + }, + flags: Ci.nsIClassInfo.SINGLETON, + getScriptableHelper: () => null, + getInterfaces: function(aCount) { + let interfaces = [Ci.nsISupports]; + aCount.value = interfaces.length; + return interfaces; + }, + classDescription: "XMLHttpRequest", + contractID: "@mozilla.org/xmlextras/xmlhttprequest;1", + classID: Components.ID("{c9b37f43-4278-4304-a5e0-600991ab08cb}"), + createInstance: function(aOuter, aIID) { + if (aOuter == null) + return this.QueryInterface(aIID); + throw Cr.NS_ERROR_NO_AGGREGATION; + }, + QueryInterface: function(aIID) { + if (aIID.equals(Ci.nsIClassInfo) || + aIID.equals(Ci.nsISupports)) + return this; + throw Cr.NS_ERROR_NO_INTERFACE; + }, + get wrappedJSObject() { return this; } +}; + +/** + * Helper used to overrideXHR requests (no matter to what URL) with the + * specified status and response. + * @param status The status you want to get back when an XHR request is made + * @param response The response you want to get back when an XHR request is made + */ +function overrideXHR(status, response, options) { + overrideXHR.myxhr = new xhr(status, response, options); + ProductAddonCheckerScope.CreateXHR = function() { + return overrideXHR.myxhr; + }; + return overrideXHR.myxhr; +} + +/** + * Creates a new zip file containing a file with the specified data + * @param zipName The name of the zip file + * @param data The data to go inside the zip for the filename entry1.info + */ +function createNewZipFile(zipName, data) { + // Create a zip file which will be used for extracting + let stream = Cc["@mozilla.org/io/string-input-stream;1"]. + createInstance(Ci.nsIStringInputStream); + stream.setData(data, data.length); + let zipWriter = Cc["@mozilla.org/zipwriter;1"]. + createInstance(Components.interfaces.nsIZipWriter); + let zipFile = FileUtils.getFile("TmpD", [zipName]); + if (zipFile.exists()) { + zipFile.remove(false); + } + // From prio.h + const PR_RDWR = 0x04; + const PR_CREATE_FILE = 0x08; + const PR_TRUNCATE = 0x20; + zipWriter.open(zipFile, PR_RDWR | PR_CREATE_FILE | PR_TRUNCATE); + zipWriter.addEntryStream("entry1.info", Date.now(), + Ci.nsIZipWriter.COMPRESSION_BEST, stream, false); + zipWriter.close(); + stream.close(); + do_print("zip file created on disk at: " + zipFile.path); + return zipFile; +} diff --git a/toolkit/modules/tests/xpcshell/test_Http.js b/toolkit/modules/tests/xpcshell/test_Http.js new file mode 100644 index 0000000000..3dfd769b71 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_Http.js @@ -0,0 +1,257 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Components.utils.import("resource://gre/modules/Http.jsm"); +Components.utils.import("resource://testing-common/httpd.js"); + +const BinaryInputStream = Components.Constructor("@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", "setInputStream"); + +var server; + +const kDefaultServerPort = 9000; +const kSuccessPath = "/success"; +const kBaseUrl = "http://localhost:" + kDefaultServerPort; +const kSuccessUrl = kBaseUrl + kSuccessPath; + +const kPostPath = "/post"; +const kPostUrl = kBaseUrl + kPostPath; +const kPostDataSent = [["foo", "bar"], ["complex", "!*()@"]]; +const kPostDataReceived = "foo=bar&complex=%21%2A%28%29%40"; +const kPostMimeTypeReceived = "application/x-www-form-urlencoded; charset=utf-8"; + +const kJsonPostPath = "/json_post"; +const kJsonPostUrl = kBaseUrl + kJsonPostPath; +const kJsonPostData = JSON.stringify(kPostDataSent); +const kJsonPostMimeType = "application/json"; + +const kPutPath = "/put"; +const kPutUrl = kBaseUrl + kPutPath; +const kPutDataSent = [["P", "NP"]]; +const kPutDataReceived = "P=NP"; + +const kGetPath = "/get"; +const kGetUrl = kBaseUrl + kGetPath; + +function successResult(aRequest, aResponse) { + aResponse.setStatusLine(null, 200, "OK"); + aResponse.setHeader("Content-Type", "application/json"); + aResponse.write("Success!"); +} + +function getDataChecker(aExpectedMethod, aExpectedData, aExpectedMimeType = null) { + return function(aRequest, aResponse) { + let body = new BinaryInputStream(aRequest.bodyInputStream); + let bytes = []; + let avail; + while ((avail = body.available()) > 0) + Array.prototype.push.apply(bytes, body.readByteArray(avail)); + + do_check_eq(aRequest.method, aExpectedMethod); + + // Checking if the Content-Type is as expected. + if (aExpectedMimeType) { + let contentType = aRequest.getHeader("Content-Type"); + do_check_eq(contentType, aExpectedMimeType); + } + + var data = String.fromCharCode.apply(null, bytes); + + do_check_eq(data, aExpectedData); + + aResponse.setStatusLine(null, 200, "OK"); + aResponse.setHeader("Content-Type", "application/json"); + aResponse.write("Success!"); + } +} + +add_test(function test_successCallback() { + do_test_pending(); + let options = { + onLoad: function(aResponse) { + do_check_eq(aResponse, "Success!"); + do_test_finished(); + run_next_test(); + }, + onError: function(e) { + do_check_true(false); + do_test_finished(); + run_next_test(); + } + } + httpRequest(kSuccessUrl, options); +}); + +add_test(function test_errorCallback() { + do_test_pending(); + let options = { + onSuccess: function(aResponse) { + do_check_true(false); + do_test_finished(); + run_next_test(); + }, + onError: function(e, aResponse) { + do_check_eq(e, "404 - Not Found"); + do_test_finished(); + run_next_test(); + } + } + httpRequest(kBaseUrl + "/failure", options); +}); + +add_test(function test_PostData() { + do_test_pending(); + let options = { + onLoad: function(aResponse) { + do_check_eq(aResponse, "Success!"); + do_test_finished(); + run_next_test(); + }, + onError: function(e) { + do_check_true(false); + do_test_finished(); + run_next_test(); + }, + postData: kPostDataSent + } + httpRequest(kPostUrl, options); +}); + +add_test(function test_PutData() { + do_test_pending(); + let options = { + method: "PUT", + onLoad: function(aResponse) { + do_check_eq(aResponse, "Success!"); + do_test_finished(); + run_next_test(); + }, + onError: function(e) { + do_check_true(false); + do_test_finished(); + run_next_test(); + }, + postData: kPutDataSent + } + httpRequest(kPutUrl, options); +}); + +add_test(function test_GetData() { + do_test_pending(); + let options = { + onLoad: function(aResponse) { + do_check_eq(aResponse, "Success!"); + do_test_finished(); + run_next_test(); + }, + onError: function(e) { + do_check_true(false); + do_test_finished(); + run_next_test(); + }, + postData: null + } + httpRequest(kGetUrl, options); +}); + +add_test(function test_OptionalParameters() { + let options = { + onLoad: null, + onError: null, + logger: null + }; + // Just make sure that nothing throws when doing this (i.e. httpRequest + // doesn't try to access null options). + httpRequest(kGetUrl, options); + run_next_test(); +}); + +/** + * Makes sure that httpRequest API allows setting a custom Content-Type header + * for POST requests when data is a string. + */ +add_test(function test_CustomContentTypeOnPost() { + do_test_pending(); + + // Preparing the request parameters. + let options = { + onLoad: function(aResponse) { + do_check_eq(aResponse, "Success!"); + do_test_finished(); + run_next_test(); + }, + onError: function(e) { + do_check_true(false); + do_test_finished(); + run_next_test(); + }, + postData: kJsonPostData, + // Setting a custom Content-Type header. + headers: [['Content-Type', "application/json"]] + } + + // Firing the request. + httpRequest(kJsonPostUrl, options); +}); + +/** + * Ensures that the httpRequest API provides a way to override the response + * MIME type. + */ +add_test(function test_OverrideMimeType() { + do_test_pending(); + + // Preparing the request parameters. + const kMimeType = 'text/xml; charset=UTF-8'; + let options = { + onLoad: function(aResponse, xhr) { + do_check_eq(aResponse, "Success!"); + + // Set the expected MIME-type. + let reportedMimeType = xhr.getResponseHeader("Content-Type"); + do_check_neq(reportedMimeType, kMimeType); + + // responseXML should not be not null if overriding mime type succeeded. + do_check_true(xhr.responseXML != null); + + do_test_finished(); + run_next_test(); + }, + onError: function(e) { + do_check_true(false); + do_test_finished(); + run_next_test(); + } + }; + + // Firing the request. + let xhr = httpRequest(kGetUrl, options); + + // Override the response MIME type. + xhr.overrideMimeType(kMimeType); +}); + +function run_test() { + // Set up a mock HTTP server to serve a success page. + server = new HttpServer(); + server.registerPathHandler(kSuccessPath, successResult); + server.registerPathHandler(kPostPath, + getDataChecker("POST", kPostDataReceived, + kPostMimeTypeReceived)); + server.registerPathHandler(kPutPath, + getDataChecker("PUT", kPutDataReceived)); + server.registerPathHandler(kGetPath, getDataChecker("GET", "")); + server.registerPathHandler(kJsonPostPath, + getDataChecker("POST", kJsonPostData, + kJsonPostMimeType)); + + server.start(kDefaultServerPort); + + run_next_test(); + + // Teardown. + do_register_cleanup(function() { + server.stop(function() { }); + }); +} + diff --git a/toolkit/modules/tests/xpcshell/test_Integration.js b/toolkit/modules/tests/xpcshell/test_Integration.js new file mode 100644 index 0000000000..808e2d34fb --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_Integration.js @@ -0,0 +1,238 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests the Integration.jsm module. + */ + +"use strict"; + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/Integration.jsm", this); +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/Task.jsm", this); + +const TestIntegration = { + value: "value", + + get valueFromThis() { + return this.value; + }, + + get property() { + return this._property; + }, + + set property(value) { + this._property = value; + }, + + method(argument) { + this.methodArgument = argument; + return "method" + argument; + }, + + asyncMethod: Task.async(function* (argument) { + this.asyncMethodArgument = argument; + return "asyncMethod" + argument; + }), +}; + +let overrideFn = base => ({ + value: "overridden-value", + + get property() { + return "overridden-" + base.__lookupGetter__("property").call(this); + }, + + set property(value) { + base.__lookupSetter__("property").call(this, "overridden-" + value); + }, + + method() { + return "overridden-" + base.method.apply(this, arguments); + }, + + asyncMethod: Task.async(function* () { + return "overridden-" + (yield base.asyncMethod.apply(this, arguments)); + }), +}); + +let superOverrideFn = base => ({ + __proto__: base, + + value: "overridden-value", + + get property() { + return "overridden-" + super.property; + }, + + set property(value) { + super.property = "overridden-" + value; + }, + + method() { + return "overridden-" + super.method(...arguments); + }, + + asyncMethod: Task.async(function* () { + // We cannot use the "super" keyword in methods defined using "Task.async". + return "overridden-" + (yield base.asyncMethod.apply(this, arguments)); + }), +}); + +/** + * Fails the test if the results of method invocations on the combined object + * don't match the expected results based on how many overrides are registered. + * + * @param combined + * The combined object based on the TestIntegration root. + * @param overridesCount + * Zero if the root object is not overridden, or a higher value to test + * the presence of one or more integration overrides. + */ +function* assertCombinedResults(combined, overridesCount) { + let expectedValue = overridesCount > 0 ? "overridden-value" : "value"; + let prefix = "overridden-".repeat(overridesCount); + + Assert.equal(combined.value, expectedValue); + Assert.equal(combined.valueFromThis, expectedValue); + + combined.property = "property"; + Assert.equal(combined.property, prefix.repeat(2) + "property"); + + combined.methodArgument = ""; + Assert.equal(combined.method("-argument"), prefix + "method-argument"); + Assert.equal(combined.methodArgument, "-argument"); + + combined.asyncMethodArgument = ""; + Assert.equal(yield combined.asyncMethod("-argument"), + prefix + "asyncMethod-argument"); + Assert.equal(combined.asyncMethodArgument, "-argument"); +} + +/** + * Fails the test if the results of method invocations on the combined object + * for the "testModule" integration point don't match the expected results based + * on how many overrides are registered. + * + * @param overridesCount + * Zero if the root object is not overridden, or a higher value to test + * the presence of one or more integration overrides. + */ +function* assertCurrentCombinedResults(overridesCount) { + let combined = Integration.testModule.getCombined(TestIntegration); + yield assertCombinedResults(combined, overridesCount); +} + +/** + * Checks the initial state with no integration override functions registered. + */ +add_task(function* test_base() { + yield assertCurrentCombinedResults(0); +}); + +/** + * Registers and unregisters an integration override function. + */ +add_task(function* test_override() { + Integration.testModule.register(overrideFn); + yield assertCurrentCombinedResults(1); + + // Registering the same function more than once has no effect. + Integration.testModule.register(overrideFn); + yield assertCurrentCombinedResults(1); + + Integration.testModule.unregister(overrideFn); + yield assertCurrentCombinedResults(0); +}); + +/** + * Registers and unregisters more than one integration override function, of + * which one uses the prototype and the "super" keyword to access the base. + */ +add_task(function* test_override_super_multiple() { + Integration.testModule.register(overrideFn); + Integration.testModule.register(superOverrideFn); + yield assertCurrentCombinedResults(2); + + Integration.testModule.unregister(overrideFn); + yield assertCurrentCombinedResults(1); + + Integration.testModule.unregister(superOverrideFn); + yield assertCurrentCombinedResults(0); +}); + +/** + * Registers an integration override function that throws an exception, and + * ensures that this does not block other functions from being registered. + */ +add_task(function* test_override_error() { + let errorOverrideFn = base => { throw "Expected error." }; + + Integration.testModule.register(errorOverrideFn); + Integration.testModule.register(overrideFn); + yield assertCurrentCombinedResults(1); + + Integration.testModule.unregister(errorOverrideFn); + Integration.testModule.unregister(overrideFn); + yield assertCurrentCombinedResults(0); +}); + +/** + * Checks that state saved using the "this" reference is preserved as a shallow + * copy when registering new integration override functions. + */ +add_task(function* test_state_preserved() { + let valueObject = { toString: () => "toString" }; + + let combined = Integration.testModule.getCombined(TestIntegration); + combined.property = valueObject; + Assert.ok(combined.property === valueObject); + + Integration.testModule.register(overrideFn); + combined = Integration.testModule.getCombined(TestIntegration); + Assert.equal(combined.property, "overridden-toString"); + + Integration.testModule.unregister(overrideFn); + combined = Integration.testModule.getCombined(TestIntegration); + Assert.ok(combined.property === valueObject); +}); + +/** + * Checks that the combined integration objects cannot be used with XPCOM. + * + * This is limited by the fact that interfaces with the "[function]" annotation, + * for example nsIObserver, do not call the QueryInterface implementation. + */ +add_task(function* test_xpcom_throws() { + let combined = Integration.testModule.getCombined(TestIntegration); + + // This calls QueryInterface because it looks for nsISupportsWeakReference. + Assert.throws(() => Services.obs.addObserver(combined, "test-topic", true), + "NS_NOINTERFACE"); +}); + +/** + * Checks that getters defined by defineModuleGetter are able to retrieve the + * latest version of the combined integration object. + */ +add_task(function* test_defineModuleGetter() { + let objectForGetters = {}; + + // Test with and without the optional "symbol" parameter. + Integration.testModule.defineModuleGetter(objectForGetters, + "TestIntegration", "resource://testing-common/TestIntegration.jsm"); + Integration.testModule.defineModuleGetter(objectForGetters, + "integration", "resource://testing-common/TestIntegration.jsm", + "TestIntegration"); + + Integration.testModule.register(overrideFn); + yield assertCombinedResults(objectForGetters.integration, 1); + yield assertCombinedResults(objectForGetters.TestIntegration, 1); + + Integration.testModule.unregister(overrideFn); + yield assertCombinedResults(objectForGetters.integration, 0); + yield assertCombinedResults(objectForGetters.TestIntegration, 0); +}); diff --git a/toolkit/modules/tests/xpcshell/test_JSONFile.js b/toolkit/modules/tests/xpcshell/test_JSONFile.js new file mode 100644 index 0000000000..77e8c55b92 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_JSONFile.js @@ -0,0 +1,242 @@ +/** + * Tests the JSONFile object. + */ + +"use strict"; + +// Globals + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths", + "resource://gre/modules/DownloadPaths.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "JSONFile", + "resource://gre/modules/JSONFile.jsm"); + +let gFileCounter = Math.floor(Math.random() * 1000000); + +/** + * Returns a reference to a temporary file, that is guaranteed not to exist, and + * to have never been created before. + * + * @param aLeafName + * Suggested leaf name for the file to be created. + * + * @return nsIFile pointing to a non-existent file in a temporary directory. + * + * @note It is not enough to delete the file if it exists, or to delete the file + * after calling nsIFile.createUnique, because on Windows the delete + * operation in the file system may still be pending, preventing a new + * file with the same name to be created. + */ +function getTempFile(aLeafName) +{ + // Prepend a serial number to the extension in the suggested leaf name. + let [base, ext] = DownloadPaths.splitBaseNameAndExtension(aLeafName); + let leafName = base + "-" + gFileCounter + ext; + gFileCounter++; + + // Get a file reference under the temporary directory for this test file. + let file = FileUtils.getFile("TmpD", [leafName]); + do_check_false(file.exists()); + + do_register_cleanup(function () { + if (file.exists()) { + file.remove(false); + } + }); + + return file; +} + +const TEST_STORE_FILE_NAME = "test-store.json"; + +const TEST_DATA = { + number: 123, + string: "test", + object: { + prop1: 1, + prop2: 2, + }, +}; + +// Tests + +add_task(function* test_save_reload() +{ + let storeForSave = new JSONFile({ + path: getTempFile(TEST_STORE_FILE_NAME).path, + }); + + yield storeForSave.load(); + + do_check_true(storeForSave.dataReady); + do_check_matches(storeForSave.data, {}); + + Object.assign(storeForSave.data, TEST_DATA); + + yield new Promise((resolve) => { + let save = storeForSave._save.bind(storeForSave); + storeForSave._save = () => { + save(); + resolve(); + }; + storeForSave.saveSoon(); + }); + + let storeForLoad = new JSONFile({ + path: storeForSave.path, + }); + + yield storeForLoad.load(); + + Assert.deepEqual(storeForLoad.data, TEST_DATA); +}); + +add_task(function* test_load_sync() +{ + let storeForSave = new JSONFile({ + path: getTempFile(TEST_STORE_FILE_NAME).path + }); + yield storeForSave.load(); + Object.assign(storeForSave.data, TEST_DATA); + yield storeForSave._save(); + + let storeForLoad = new JSONFile({ + path: storeForSave.path, + }); + storeForLoad.ensureDataReady(); + + Assert.deepEqual(storeForLoad.data, TEST_DATA); +}); + +add_task(function* test_load_with_dataPostProcessor() +{ + let storeForSave = new JSONFile({ + path: getTempFile(TEST_STORE_FILE_NAME).path + }); + yield storeForSave.load(); + Object.assign(storeForSave.data, TEST_DATA); + yield storeForSave._save(); + + let random = Math.random(); + let storeForLoad = new JSONFile({ + path: storeForSave.path, + dataPostProcessor: (data) => { + Assert.deepEqual(data, TEST_DATA); + + data.test = random; + return data; + }, + }); + + yield storeForLoad.load(); + + do_check_eq(storeForLoad.data.test, random); +}); + +add_task(function* test_load_with_dataPostProcessor_fails() +{ + let store = new JSONFile({ + path: getTempFile(TEST_STORE_FILE_NAME).path, + dataPostProcessor: () => { + throw new Error("dataPostProcessor fails."); + }, + }); + + yield Assert.rejects(store.load(), /dataPostProcessor fails\./); + + do_check_false(store.dataReady); +}); + +add_task(function* test_load_sync_with_dataPostProcessor_fails() +{ + let store = new JSONFile({ + path: getTempFile(TEST_STORE_FILE_NAME).path, + dataPostProcessor: () => { + throw new Error("dataPostProcessor fails."); + }, + }); + + Assert.throws(() => store.ensureDataReady(), /dataPostProcessor fails\./); + + do_check_false(store.dataReady); +}); + +/** + * Loads data from a string in a predefined format. The purpose of this test is + * to verify that the JSON format used in previous versions can be loaded. + */ +add_task(function* test_load_string_predefined() +{ + let store = new JSONFile({ + path: getTempFile(TEST_STORE_FILE_NAME).path, + }); + + let string = + "{\"number\":123,\"string\":\"test\",\"object\":{\"prop1\":1,\"prop2\":2}}"; + + yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string), + { tmpPath: store.path + ".tmp" }); + + yield store.load(); + + Assert.deepEqual(store.data, TEST_DATA); +}); + +/** + * Loads data from a malformed JSON string. + */ +add_task(function* test_load_string_malformed() +{ + let store = new JSONFile({ + path: getTempFile(TEST_STORE_FILE_NAME).path, + }); + + let string = "{\"number\":123,\"string\":\"test\",\"object\":{\"prop1\":1,"; + + yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string), + { tmpPath: store.path + ".tmp" }); + + yield store.load(); + + // A backup file should have been created. + do_check_true(yield OS.File.exists(store.path + ".corrupt")); + yield OS.File.remove(store.path + ".corrupt"); + + // The store should be ready to accept new data. + do_check_true(store.dataReady); + do_check_matches(store.data, {}); +}); + +/** + * Loads data from a malformed JSON string, using the synchronous initialization + * path. + */ +add_task(function* test_load_string_malformed_sync() +{ + let store = new JSONFile({ + path: getTempFile(TEST_STORE_FILE_NAME).path, + }); + + let string = "{\"number\":123,\"string\":\"test\",\"object\":{\"prop1\":1,"; + + yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string), + { tmpPath: store.path + ".tmp" }); + + store.ensureDataReady(); + + // A backup file should have been created. + do_check_true(yield OS.File.exists(store.path + ".corrupt")); + yield OS.File.remove(store.path + ".corrupt"); + + // The store should be ready to accept new data. + do_check_true(store.dataReady); + do_check_matches(store.data, {}); +}); diff --git a/toolkit/modules/tests/xpcshell/test_Log.js b/toolkit/modules/tests/xpcshell/test_Log.js new file mode 100644 index 0000000000..429bbcc503 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_Log.js @@ -0,0 +1,592 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-disable block-spacing */ + +var {utils: Cu} = Components; + +Cu.import("resource://gre/modules/NetUtil.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); + +Cu.import("resource://gre/modules/Promise.jsm") +Cu.import("resource://gre/modules/Log.jsm"); + +var testFormatter = { + format: function format(message) { + return message.loggerName + "\t" + + message.levelDesc + "\t" + + message.message; + } +}; + +function MockAppender(formatter) { + Log.Appender.call(this, formatter); + this.messages = []; +} +MockAppender.prototype = { + __proto__: Log.Appender.prototype, + + doAppend: function DApp_doAppend(message) { + this.messages.push(message); + } +}; + +function run_test() { + run_next_test(); +} + +add_task(function test_Logger() { + let log = Log.repository.getLogger("test.logger"); + let appender = new MockAppender(new Log.BasicFormatter()); + + log.level = Log.Level.Debug; + appender.level = Log.Level.Info; + log.addAppender(appender); + log.info("info test"); + log.debug("this should be logged but not appended."); + + do_check_eq(appender.messages.length, 1); + + let msgRe = /\d+\ttest.logger\t\INFO\tinfo test/; + do_check_true(msgRe.test(appender.messages[0])); +}); + +add_task(function test_Logger_parent() { + // Check whether parenting is correct + let grandparentLog = Log.repository.getLogger("grandparent"); + let childLog = Log.repository.getLogger("grandparent.parent.child"); + do_check_eq(childLog.parent.name, "grandparent"); + + let parentLog = Log.repository.getLogger("grandparent.parent"); + do_check_eq(childLog.parent.name, "grandparent.parent"); + + // Check that appends are exactly in scope + let gpAppender = new MockAppender(new Log.BasicFormatter()); + gpAppender.level = Log.Level.Info; + grandparentLog.addAppender(gpAppender); + childLog.info("child info test"); + Log.repository.rootLogger.info("this shouldn't show up in gpAppender"); + + do_check_eq(gpAppender.messages.length, 1); + do_check_true(gpAppender.messages[0].indexOf("child info test") > 0); +}); + +add_test(function test_LoggerWithMessagePrefix() { + let log = Log.repository.getLogger("test.logger.prefix"); + let appender = new MockAppender(new Log.MessageOnlyFormatter()); + log.addAppender(appender); + + let prefixed = Log.repository.getLoggerWithMessagePrefix( + "test.logger.prefix", "prefix: "); + + log.warn("no prefix"); + prefixed.warn("with prefix"); + + Assert.equal(appender.messages.length, 2, "2 messages were logged."); + Assert.deepEqual(appender.messages, [ + "no prefix", + "prefix: with prefix", + ], "Prefix logger works."); + + run_next_test(); +}); + +/* + * A utility method for checking object equivalence. + * Fields with a reqular expression value in expected will be tested + * against the corresponding value in actual. Otherwise objects + * are expected to have the same keys and equal values. + */ +function checkObjects(expected, actual) { + do_check_true(expected instanceof Object); + do_check_true(actual instanceof Object); + for (let key in expected) { + do_check_neq(actual[key], undefined); + if (expected[key] instanceof RegExp) { + do_check_true(expected[key].test(actual[key].toString())); + } else if (expected[key] instanceof Object) { + checkObjects(expected[key], actual[key]); + } else { + do_check_eq(expected[key], actual[key]); + } + } + + for (let key in actual) { + do_check_neq(expected[key], undefined); + } +} + +add_task(function test_StructuredLogCommands() { + let appender = new MockAppender(new Log.StructuredFormatter()); + let logger = Log.repository.getLogger("test.StructuredOutput"); + logger.addAppender(appender); + logger.level = Log.Level.Info; + + logger.logStructured("test_message", {_message: "message string one"}); + logger.logStructured("test_message", {_message: "message string two", + _level: "ERROR", + source_file: "test_Log.js"}); + logger.logStructured("test_message"); + logger.logStructured("test_message", {source_file: "test_Log.js", + message_position: 4}); + + let messageOne = {"_time": /\d+/, + "_namespace": "test.StructuredOutput", + "_level": "INFO", + "_message": "message string one", + "action": "test_message"}; + + let messageTwo = {"_time": /\d+/, + "_namespace": "test.StructuredOutput", + "_level": "ERROR", + "_message": "message string two", + "action": "test_message", + "source_file": "test_Log.js"}; + + let messageThree = {"_time": /\d+/, + "_namespace": "test.StructuredOutput", + "_level": "INFO", + "action": "test_message"}; + + let messageFour = {"_time": /\d+/, + "_namespace": "test.StructuredOutput", + "_level": "INFO", + "action": "test_message", + "source_file": "test_Log.js", + "message_position": 4}; + + checkObjects(messageOne, JSON.parse(appender.messages[0])); + checkObjects(messageTwo, JSON.parse(appender.messages[1])); + checkObjects(messageThree, JSON.parse(appender.messages[2])); + checkObjects(messageFour, JSON.parse(appender.messages[3])); + + let errored = false; + try { + logger.logStructured("", {_message: "invalid message"}); + } catch (e) { + errored = true; + do_check_eq(e, "An action is required when logging a structured message."); + } finally { + do_check_true(errored); + } + + errored = false; + try { + logger.logStructured("message_action", "invalid params"); + } catch (e) { + errored = true; + do_check_eq(e, "The params argument is required to be an object."); + } finally { + do_check_true(errored); + } + + // Logging with unstructured interface should produce the same messages + // as the structured interface for these cases. + appender = new MockAppender(new Log.StructuredFormatter()); + logger = Log.repository.getLogger("test.StructuredOutput1"); + messageOne._namespace = "test.StructuredOutput1"; + messageTwo._namespace = "test.StructuredOutput1"; + logger.addAppender(appender); + logger.level = Log.Level.All; + logger.info("message string one", {action: "test_message"}); + logger.error("message string two", {action: "test_message", + source_file: "test_Log.js"}); + + checkObjects(messageOne, JSON.parse(appender.messages[0])); + checkObjects(messageTwo, JSON.parse(appender.messages[1])); +}); + +add_task(function test_StorageStreamAppender() { + let appender = new Log.StorageStreamAppender(testFormatter); + do_check_eq(appender.getInputStream(), null); + + // Log to the storage stream and verify the log was written and can be + // read back. + let logger = Log.repository.getLogger("test.StorageStreamAppender"); + logger.addAppender(appender); + logger.info("OHAI"); + let inputStream = appender.getInputStream(); + let data = NetUtil.readInputStreamToString(inputStream, + inputStream.available()); + do_check_eq(data, "test.StorageStreamAppender\tINFO\tOHAI\n"); + + // We can read it again even. + let sndInputStream = appender.getInputStream(); + let sameData = NetUtil.readInputStreamToString(sndInputStream, + sndInputStream.available()); + do_check_eq(data, sameData); + + // Reset the appender and log some more. + appender.reset(); + do_check_eq(appender.getInputStream(), null); + logger.debug("wut?!?"); + inputStream = appender.getInputStream(); + data = NetUtil.readInputStreamToString(inputStream, + inputStream.available()); + do_check_eq(data, "test.StorageStreamAppender\tDEBUG\twut?!?\n"); +}); + +function fileContents(path) { + let decoder = new TextDecoder(); + return OS.File.read(path).then(array => { + return decoder.decode(array); + }); +} + +add_task(function* test_FileAppender() { + // This directory does not exist yet + let dir = OS.Path.join(do_get_profile().path, "test_Log"); + do_check_false(yield OS.File.exists(dir)); + let path = OS.Path.join(dir, "test_FileAppender"); + let appender = new Log.FileAppender(path, testFormatter); + let logger = Log.repository.getLogger("test.FileAppender"); + logger.addAppender(appender); + + // Logging to a file that can't be created won't do harm. + do_check_false(yield OS.File.exists(path)); + logger.info("OHAI!"); + + yield OS.File.makeDir(dir); + logger.info("OHAI"); + yield appender._lastWritePromise; + + do_check_eq((yield fileContents(path)), + "test.FileAppender\tINFO\tOHAI\n"); + + logger.info("OHAI"); + yield appender._lastWritePromise; + + do_check_eq((yield fileContents(path)), + "test.FileAppender\tINFO\tOHAI\n" + + "test.FileAppender\tINFO\tOHAI\n"); + + // Reset the appender and log some more. + yield appender.reset(); + do_check_false(yield OS.File.exists(path)); + + logger.debug("O RLY?!?"); + yield appender._lastWritePromise; + do_check_eq((yield fileContents(path)), + "test.FileAppender\tDEBUG\tO RLY?!?\n"); + + yield appender.reset(); + logger.debug("1"); + logger.info("2"); + logger.info("3"); + logger.info("4"); + logger.info("5"); + // Waiting on only the last promise should account for all of these. + yield appender._lastWritePromise; + + // Messages ought to be logged in order. + do_check_eq((yield fileContents(path)), + "test.FileAppender\tDEBUG\t1\n" + + "test.FileAppender\tINFO\t2\n" + + "test.FileAppender\tINFO\t3\n" + + "test.FileAppender\tINFO\t4\n" + + "test.FileAppender\tINFO\t5\n"); +}); + +add_task(function* test_BoundedFileAppender() { + let dir = OS.Path.join(do_get_profile().path, "test_Log"); + + if (!(yield OS.File.exists(dir))) { + yield OS.File.makeDir(dir); + } + + let path = OS.Path.join(dir, "test_BoundedFileAppender"); + // This appender will hold about two lines at a time. + let appender = new Log.BoundedFileAppender(path, testFormatter, 40); + let logger = Log.repository.getLogger("test.BoundedFileAppender"); + logger.addAppender(appender); + + logger.info("ONE"); + logger.info("TWO"); + yield appender._lastWritePromise; + + do_check_eq((yield fileContents(path)), + "test.BoundedFileAppender\tINFO\tONE\n" + + "test.BoundedFileAppender\tINFO\tTWO\n"); + + logger.info("THREE"); + logger.info("FOUR"); + + do_check_neq(appender._removeFilePromise, undefined); + yield appender._removeFilePromise; + yield appender._lastWritePromise; + + do_check_eq((yield fileContents(path)), + "test.BoundedFileAppender\tINFO\tTHREE\n" + + "test.BoundedFileAppender\tINFO\tFOUR\n"); + + yield appender.reset(); + logger.info("ONE"); + logger.info("TWO"); + logger.info("THREE"); + logger.info("FOUR"); + + do_check_neq(appender._removeFilePromise, undefined); + yield appender._removeFilePromise; + yield appender._lastWritePromise; + + do_check_eq((yield fileContents(path)), + "test.BoundedFileAppender\tINFO\tTHREE\n" + + "test.BoundedFileAppender\tINFO\tFOUR\n"); + +}); + +/* + * Test parameter formatting. + */ +add_task(function* log_message_with_params() { + let formatter = new Log.BasicFormatter(); + + function formatMessage(text, params) { + let full = formatter.format(new Log.LogMessage("test.logger", Log.Level.Warn, text, params)); + return full.split("\t")[3]; + } + + // Strings are substituted directly. + do_check_eq(formatMessage("String is ${foo}", {foo: "bar"}), + "String is bar"); + + // Numbers are substituted. + do_check_eq(formatMessage("Number is ${number}", {number: 47}), + "Number is 47") + + // The entire params object is JSON-formatted and substituted. + do_check_eq(formatMessage("Object is ${}", {foo: "bar"}), + 'Object is {"foo":"bar"}'); + + // An object nested inside params is JSON-formatted and substituted. + do_check_eq(formatMessage("Sub object is ${sub}", {sub: {foo: "bar"}}), + 'Sub object is {"foo":"bar"}'); + + // The substitution field is missing from params. Leave the placeholder behind + // to make the mistake obvious. + do_check_eq(formatMessage("Missing object is ${missing}", {}), + 'Missing object is ${missing}'); + + // Make sure we don't treat the parameter name 'false' as a falsey value. + do_check_eq(formatMessage("False is ${false}", {false: true}), + 'False is true'); + + // If an object has a .toJSON method, the formatter uses it. + let ob = function() {}; + ob.toJSON = function() {return {sneaky: "value"}}; + do_check_eq(formatMessage("JSON is ${sub}", {sub: ob}), + 'JSON is {"sneaky":"value"}'); + + // Fall back to .toSource() if JSON.stringify() fails on an object. + ob = function() {}; + ob.toJSON = function() {throw "oh noes JSON"}; + do_check_eq(formatMessage("Fail is ${sub}", {sub: ob}), + 'Fail is (function () {})'); + + // Fall back to .toString if both .toJSON and .toSource fail. + ob.toSource = function() {throw "oh noes SOURCE"}; + do_check_eq(formatMessage("Fail is ${sub}", {sub: ob}), + 'Fail is function () {}'); + + // Fall back to '[object]' if .toJSON, .toSource and .toString fail. + ob.toString = function() {throw "oh noes STRING"}; + do_check_eq(formatMessage("Fail is ${sub}", {sub: ob}), + 'Fail is [object]'); + + // If params are passed but there are no substitution in the text + // we JSON format and append the entire parameters object. + do_check_eq(formatMessage("Text with no subs", {a: "b", c: "d"}), + 'Text with no subs: {"a":"b","c":"d"}'); + + // If we substitute one parameter but not the other, + // we ignore any params that aren't substituted. + do_check_eq(formatMessage("Text with partial sub ${a}", {a: "b", c: "d"}), + 'Text with partial sub b'); + + // We don't format internal fields stored in params. + do_check_eq(formatMessage("Params with _ ${}", {a: "b", _c: "d", _level:20, _message:"froo", + _time:123456, _namespace:"here.there"}), + 'Params with _ {"a":"b","_c":"d"}'); + + // Don't print an empty params holder if all params are internal. + do_check_eq(formatMessage("All params internal", {_level:20, _message:"froo", + _time:123456, _namespace:"here.there"}), + 'All params internal'); + + // Format params with null and undefined values. + do_check_eq(formatMessage("Null ${n} undefined ${u}", {n: null, u: undefined}), + 'Null null undefined undefined'); + + // Format params with number, bool, and Object/String type. + do_check_eq(formatMessage("number ${n} boolean ${b} boxed Boolean ${bx} String ${s}", + {n: 45, b: false, bx: new Boolean(true), s: new String("whatevs")}), + 'number 45 boolean false boxed Boolean true String whatevs'); + + /* + * Check that errors get special formatting if they're formatted directly as + * a named param or they're the only param, but not if they're a field in a + * larger structure. + */ + let err = Components.Exception("test exception", Components.results.NS_ERROR_FAILURE); + let str = formatMessage("Exception is ${}", err); + do_check_true(str.includes('Exception is [Exception... "test exception"')); + do_check_true(str.includes("(NS_ERROR_FAILURE)")); + str = formatMessage("Exception is", err); + do_check_true(str.includes('Exception is: [Exception... "test exception"')); + str = formatMessage("Exception is ${error}", {error: err}); + do_check_true(str.includes('Exception is [Exception... "test exception"')); + str = formatMessage("Exception is", {_error: err}); + do_print(str); + // Exceptions buried inside objects are formatted badly. + do_check_true(str.includes('Exception is: {"_error":{}')); + // If the message text is null, the message contains only the formatted params object. + str = formatMessage(null, err); + do_check_true(str.startsWith('[Exception... "test exception"')); + // If the text is null and 'params' is a String object, the message is exactly that string. + str = formatMessage(null, new String("String in place of params")); + do_check_eq(str, "String in place of params"); + + // We use object.valueOf() internally; make sure a broken valueOf() method + // doesn't cause the logger to fail. + let vOf = {a: 1, valueOf: function() {throw "oh noes valueOf"}}; + do_check_eq(formatMessage("Broken valueOf ${}", vOf), + 'Broken valueOf ({a:1, valueOf:(function () {throw "oh noes valueOf"})})'); + + // Test edge cases of bad data to formatter: + // If 'params' is not an object, format it as a basic type. + do_check_eq(formatMessage("non-object no subst", 1), + 'non-object no subst: 1'); + do_check_eq(formatMessage("non-object all subst ${}", 2), + 'non-object all subst 2'); + do_check_eq(formatMessage("false no subst", false), + 'false no subst: false'); + do_check_eq(formatMessage("null no subst", null), + 'null no subst: null'); + // If 'params' is undefined and there are no substitutions expected, + // the message should still be output. + do_check_eq(formatMessage("undefined no subst", undefined), + 'undefined no subst'); + // If 'params' is not an object, no named substitutions can succeed; + // therefore we leave the placeholder and append the formatted params. + do_check_eq(formatMessage("non-object named subst ${junk} space", 3), + 'non-object named subst ${junk} space: 3'); + // If there are no params, we leave behind the placeholders in the text. + do_check_eq(formatMessage("no params ${missing}", undefined), + 'no params ${missing}'); + // If params doesn't contain any of the tags requested in the text, + // we leave them all behind and append the formatted params. + do_check_eq(formatMessage("object missing tag ${missing} space", {mising: "not here"}), + 'object missing tag ${missing} space: {"mising":"not here"}'); + // If we are given null text and no params, the resulting formatted message is empty. + do_check_eq(formatMessage(null), ''); +}); + +/* + * If we call a log function with a non-string object in place of the text + * argument, and no parameters, treat that the same as logging empty text + * with the object argument as parameters. This makes the log useful when the + * caller does "catch(err) {logger.error(err)}" + */ +add_task(function* test_log_err_only() { + let log = Log.repository.getLogger("error.only"); + let mockFormatter = { format: msg => msg }; + let appender = new MockAppender(mockFormatter); + log.addAppender(appender); + + /* + * Check that log.error(err) is treated the same as + * log.error(null, err) by the logMessage constructor; the formatMessage() + * tests above ensure that the combination of null text and an error object + * is formatted correctly. + */ + try { + eval("javascript syntax error"); + } + catch (e) { + log.error(e); + msg = appender.messages.pop(); + do_check_eq(msg.message, null); + do_check_eq(msg.params, e); + } +}); + +/* + * Test logStructured() messages through basic formatter. + */ +add_task(function* test_structured_basic() { + let log = Log.repository.getLogger("test.logger"); + let appender = new MockAppender(new Log.BasicFormatter()); + + log.level = Log.Level.Info; + appender.level = Log.Level.Info; + log.addAppender(appender); + + // A structured entry with no _message is treated the same as log./level/(null, params) + // except the 'action' field is added to the object. + log.logStructured("action", {data: "structure"}); + do_check_eq(appender.messages.length, 1); + do_check_true(appender.messages[0].includes('{"data":"structure","action":"action"}')); + + // A structured entry with _message and substitution is treated the same as + // log./level/(null, params). + log.logStructured("action", {_message: "Structured sub ${data}", data: "structure"}); + do_check_eq(appender.messages.length, 2); + do_print(appender.messages[1]); + do_check_true(appender.messages[1].includes('Structured sub structure')); +}); + +/* + * Test that all the basic logger methods pass the message and params through to the appender. + */ +add_task(function* log_message_with_params() { + let log = Log.repository.getLogger("error.logger"); + let mockFormatter = { format: msg => msg }; + let appender = new MockAppender(mockFormatter); + log.addAppender(appender); + + let testParams = {a:1, b:2}; + log.fatal("Test fatal", testParams); + log.error("Test error", testParams); + log.warn("Test warn", testParams); + log.info("Test info", testParams); + log.config("Test config", testParams); + log.debug("Test debug", testParams); + log.trace("Test trace", testParams); + do_check_eq(appender.messages.length, 7); + for (let msg of appender.messages) { + do_check_true(msg.params === testParams); + do_check_true(msg.message.startsWith("Test ")); + } +}); + +/* + * Check that we format JS Errors reasonably. + */ +add_task(function* format_errors() { + let pFormat = new Log.ParameterFormatter(); + + // Test that subclasses of Error are recognized as errors. + err = new ReferenceError("Ref Error", "ERROR_FILE", 28); + str = pFormat.format(err); + do_check_true(str.includes("ReferenceError")); + do_check_true(str.includes("ERROR_FILE:28")); + do_check_true(str.includes("Ref Error")); + + // Test that JS-generated Errors are recognized and formatted. + try { + yield Promise.resolve(); // Scrambles the stack + eval("javascript syntax error"); + } + catch (e) { + str = pFormat.format(e); + do_check_true(str.includes("SyntaxError: missing ;")); + // Make sure we identified it as an Error and formatted the error location as + // lineNumber:columnNumber. + do_check_true(str.includes(":1:11)")); + // Make sure that we use human-readable stack traces + // Check that the error doesn't contain any reference to "Promise.jsm" or "Task.jsm" + do_check_false(str.includes("Promise.jsm")); + do_check_false(str.includes("Task.jsm")); + do_check_true(str.includes("format_errors")); + } +}); diff --git a/toolkit/modules/tests/xpcshell/test_Log_stackTrace.js b/toolkit/modules/tests/xpcshell/test_Log_stackTrace.js new file mode 100644 index 0000000000..6e53db058c --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_Log_stackTrace.js @@ -0,0 +1,30 @@ +print("Define some functions in well defined line positions for the test"); +function foo(v) { return bar(v + 1); } // line 2 +function bar(v) { return baz(v + 1); } // line 3 +function baz(v) { throw new Error(v + 1); } // line 4 + +print("Make sure lazy constructor calling/assignment works"); +Components.utils.import("resource://gre/modules/Log.jsm"); + +function run_test() { + print("Make sure functions, arguments, files are pretty printed in the trace"); + let trace = ""; + try { + foo(0); + } + catch (ex) { + trace = Log.stackTrace(ex); + } + print(`Got trace: ${trace}`); + do_check_neq(trace, ""); + + let bazPos = trace.indexOf("baz@test_Log_stackTrace.js:4"); + let barPos = trace.indexOf("bar@test_Log_stackTrace.js:3"); + let fooPos = trace.indexOf("foo@test_Log_stackTrace.js:2"); + print(`String positions: ${bazPos} ${barPos} ${fooPos}`); + + print("Make sure the desired messages show up"); + do_check_true(bazPos >= 0); + do_check_true(barPos > bazPos); + do_check_true(fooPos > barPos); +} diff --git a/toolkit/modules/tests/xpcshell/test_MatchGlobs.js b/toolkit/modules/tests/xpcshell/test_MatchGlobs.js new file mode 100644 index 0000000000..5dcfd19cba --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_MatchGlobs.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Components.utils.import("resource://gre/modules/MatchPattern.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +function test(url, pattern) { + let uri = Services.io.newURI(url, null, null); + let m = new MatchGlobs(pattern); + return m.matches(uri.spec); +} + +function pass({url, pattern}) { + ok(test(url, pattern), `Expected match: ${JSON.stringify(pattern)}, ${url}`); +} + +function fail({url, pattern}) { + ok(!test(url, pattern), `Expected no match: ${JSON.stringify(pattern)}, ${url}`); +} + +function run_test() { + let moz = "http://mozilla.org"; + + pass({url: moz, pattern: ["*"]}); + pass({url: moz, pattern: ["http://*"]}), + pass({url: moz, pattern: ["*mozilla*"]}); + pass({url: moz, pattern: ["*example*", "*mozilla*"]}); + + pass({url: moz, pattern: ["*://*"]}); + pass({url: "https://mozilla.org", pattern: ["*://*"]}); + + // Documentation example + pass({url: "http://www.example.com/foo/bar", pattern: ["http://???.example.com/foo/*"]}); + pass({url: "http://the.example.com/foo/", pattern: ["http://???.example.com/foo/*"]}); + fail({url: "http://my.example.com/foo/bar", pattern: ["http://???.example.com/foo/*"]}); + fail({url: "http://example.com/foo/", pattern: ["http://???.example.com/foo/*"]}); + fail({url: "http://www.example.com/foo", pattern: ["http://???.example.com/foo/*"]}); + + // Matches path + let path = moz + "/abc/def"; + pass({url: path, pattern: ["*def"]}); + pass({url: path, pattern: ["*c/d*"]}); + pass({url: path, pattern: ["*org/abc*"]}); + fail({url: path + "/", pattern: ["*def"]}); + + // Trailing slash + pass({url: moz, pattern: ["*.org/"]}); + fail({url: moz, pattern: ["*.org"]}); + + // Wrong TLD + fail({url: moz, pattern: ["www*.m*.com/"]}); + // Case sensitive + fail({url: moz, pattern: ["*.ORG/"]}); + + fail({url: moz, pattern: []}); +} diff --git a/toolkit/modules/tests/xpcshell/test_MatchPattern.js b/toolkit/modules/tests/xpcshell/test_MatchPattern.js new file mode 100644 index 0000000000..5830383610 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_MatchPattern.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Components.utils.import("resource://gre/modules/MatchPattern.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +function test(url, pattern) +{ + let uri = Services.io.newURI(url, null, null); + let m = new MatchPattern(pattern); + return m.matches(uri); +} + +function pass({url, pattern}) +{ + do_check_true(test(url, pattern), `Expected match: ${JSON.stringify(pattern)}, ${url}`); +} + +function fail({url, pattern}) +{ + do_check_false(test(url, pattern), `Expected no match: ${JSON.stringify(pattern)}, ${url}`); +} + +function run_test() +{ + // Invalid pattern. + fail({url: "http://mozilla.org", pattern: ""}); + + // Pattern must include trailing slash. + fail({url: "http://mozilla.org", pattern: "http://mozilla.org"}); + + // Protocol not allowed. + fail({url: "http://mozilla.org", pattern: "gopher://wuarchive.wustl.edu/"}); + + pass({url: "http://mozilla.org", pattern: "http://mozilla.org/"}); + pass({url: "http://mozilla.org/", pattern: "http://mozilla.org/"}); + + pass({url: "http://mozilla.org/", pattern: "*://mozilla.org/"}); + pass({url: "https://mozilla.org/", pattern: "*://mozilla.org/"}); + fail({url: "file://mozilla.org/", pattern: "*://mozilla.org/"}); + fail({url: "ftp://mozilla.org/", pattern: "*://mozilla.org/"}); + + fail({url: "http://mozilla.com", pattern: "http://*mozilla.com*/"}); + fail({url: "http://mozilla.com", pattern: "http://mozilla.*/"}); + fail({url: "http://mozilla.com", pattern: "http:/mozilla.com/"}); + + pass({url: "http://google.com", pattern: "http://*.google.com/"}); + pass({url: "http://docs.google.com", pattern: "http://*.google.com/"}); + + pass({url: "http://mozilla.org:8080", pattern: "http://mozilla.org/"}); + pass({url: "http://mozilla.org:8080", pattern: "*://mozilla.org/"}); + fail({url: "http://mozilla.org:8080", pattern: "http://mozilla.org:8080/"}); + + // Now try with * in the path. + pass({url: "http://mozilla.org", pattern: "http://mozilla.org/*"}); + pass({url: "http://mozilla.org/", pattern: "http://mozilla.org/*"}); + + pass({url: "http://mozilla.org/", pattern: "*://mozilla.org/*"}); + pass({url: "https://mozilla.org/", pattern: "*://mozilla.org/*"}); + fail({url: "file://mozilla.org/", pattern: "*://mozilla.org/*"}); + fail({url: "http://mozilla.com", pattern: "http://mozilla.*/*"}); + + pass({url: "http://google.com", pattern: "http://*.google.com/*"}); + pass({url: "http://docs.google.com", pattern: "http://*.google.com/*"}); + + // Check path stuff. + fail({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/"}); + pass({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*"}); + pass({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/a*f"}); + pass({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/a*"}); + pass({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*f"}); + fail({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*e"}); + fail({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*c"}); + + fail({url: "http:///a.html", pattern: "http:///a.html"}); + pass({url: "file:///foo", pattern: "file:///foo*"}); + pass({url: "file:///foo/bar.html", pattern: "file:///foo*"}); + + pass({url: "http://mozilla.org/a", pattern: "<all_urls>"}); + pass({url: "https://mozilla.org/a", pattern: "<all_urls>"}); + pass({url: "ftp://mozilla.org/a", pattern: "<all_urls>"}); + pass({url: "file:///a", pattern: "<all_urls>"}); + fail({url: "gopher://wuarchive.wustl.edu/a", pattern: "<all_urls>"}); + + // Multiple patterns. + pass({url: "http://mozilla.org", pattern: ["http://mozilla.org/"]}); + pass({url: "http://mozilla.org", pattern: ["http://mozilla.org/", "http://mozilla.com/"]}); + pass({url: "http://mozilla.com", pattern: ["http://mozilla.org/", "http://mozilla.com/"]}); + fail({url: "http://mozilla.biz", pattern: ["http://mozilla.org/", "http://mozilla.com/"]}); + + // Match url with fragments. + pass({url: "http://mozilla.org/base#some-fragment", pattern: "http://mozilla.org/base"}); +} diff --git a/toolkit/modules/tests/xpcshell/test_MatchURLFilters.js b/toolkit/modules/tests/xpcshell/test_MatchURLFilters.js new file mode 100644 index 0000000000..52e03a6cc1 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_MatchURLFilters.js @@ -0,0 +1,396 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Components.utils.import("resource://gre/modules/MatchPattern.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +function createTestFilter({url, filters}) { + let m = new MatchURLFilters(filters); + return m.matches(url); +} + +function expectPass({url, filters}) { + ok(createTestFilter({url, filters}), + `Expected match: ${JSON.stringify(filters)}, ${url}`); +} + +function expectFail({url, filters}) { + ok(!createTestFilter({url, filters}), + `Expected no match: ${JSON.stringify(filters)}, ${url}`); +} + +function expectThrow({url, filters, exceptionMessageContains}) { + let logData = {filters, url}; + + Assert.throws( + () => { + createTestFilter({url, filters}); + }, + exceptionMessageContains, + `Check received exception for expected message: ${JSON.stringify(logData)}` + ); +} + +add_task(function* test_match_url_filters() { + const shouldPass = true; + const shouldFail = true; + const shouldThrow = true; + + var testCases = [ + // Empty, undefined and null filters. + {shouldThrow, exceptionMessageContains: "filters array should not be empty", + filters: [], url: "http://mozilla.org", }, + {shouldThrow, exceptionMessageContains: "filters should be an array", + filters: undefined, url: "http://mozilla.org"}, + {shouldThrow, exceptionMessageContains: "filters should be an array", + filters: null, url: "http://mozilla.org"}, + + // Wrong formats (in a real webextension this will be blocked by the schema validation). + {shouldThrow, exceptionMessageContains: "filters should be an array", filters: {}, + url: "http://mozilla.org"}, + {shouldThrow, exceptionMessageContains: "filters should be an array", + filters: {nonExistentCriteria: true}, url: "http://mozilla.org", }, + {shouldPass, filters: [{nonExistentCriteria: true}], url: "http://mozilla.org"}, + + // Schemes filter over various url schemes. + {shouldPass, filters: [{schemes: ["http"]}], url: "http://mozilla.org"}, + {shouldPass, filters: [{schemes: ["https"]}], url: "https://mozilla.org"}, + {shouldPass, filters: [{schemes: ["ftp"]}], url: "ftp://fake/ftp/url"}, + {shouldPass, filters: [{schemes: ["about"]}], url: "about:blank"}, + {shouldPass, filters: [{schemes: ["data"]}], url: "data:,testDataURL"}, + {shouldFail, filters: [{schemes: ["http"]}], url: "ftp://fake/ftp/url"}, + + // Multiple schemes: pass when at least one scheme matches. + {shouldPass, filters: [{schemes: ["https", "about"]}], url: "https://mozilla.org"}, + {shouldPass, filters: [{schemes: ["about", "https"]}], url: "https://mozilla.org"}, + {shouldFail, filters: [{schemes: ["about", "http"]}], url: "https://mozilla.org"}, + + // Port filter: standard (implicit) ports. + {shouldPass, filters: [{ports: [443]}], url: "https://mozilla.org"}, + {shouldPass, filters: [{ports: [80]}], url: "http://mozilla.org"}, + {shouldPass, filters: [{ports: [21]}], url: "ftp://ftp.mozilla.org"}, + + // Port filter: schemes without a default port. + {shouldFail, filters: [{ports: [-1]}], url: "about:blank"}, + {shouldFail, filters: [{ports: [-1]}], url: "data:,testDataURL"}, + + {shouldFail, filters: [{ports: [[1, 65535]]}], url: "about:blank"}, + {shouldFail, filters: [{ports: [[1, 65535]]}], url: "data:,testDataURL"}, + + // Host filters (hostEquals, hostContains, hostPrefix, hostSuffix): schemes with an host. + {shouldFail, filters: [{hostEquals: ""}], url: "https://mozilla.org"}, + {shouldPass, filters: [{hostEquals: null}], url: "https://mozilla.org"}, + {shouldPass, filters: [{hostEquals: "mozilla.org"}], url: "https://mozilla.org"}, + {shouldFail, filters: [{hostEquals: "mozilla.com"}], url: "https://mozilla.org"}, + // NOTE: trying at least once another valid protocol. + {shouldPass, filters: [{hostEquals: "mozilla.org"}], url: "ftp://mozilla.org"}, + {shouldFail, filters: [{hostEquals: "mozilla.com"}], url: "ftp://mozilla.org"}, + {shouldPass, filters: [{hostEquals: "mozilla.org"}], url: "https://mozilla.org:8888"}, + + {shouldPass, filters: [{hostContains: "moz"}], url: "https://mozilla.org"}, + // NOTE: an implicit '.' char is inserted into the host. + {shouldPass, filters: [{hostContains: ".moz"}], url: "https://mozilla.org"}, + {shouldFail, filters: [{hostContains: "com"}], url: "https://mozilla.org"}, + {shouldPass, filters: [{hostContains: ""}], url: "https://mozilla.org"}, + {shouldPass, filters: [{hostContains: null}], url: "https://mozilla.org"}, + + {shouldPass, filters: [{hostPrefix: "moz"}], url: "https://mozilla.org"}, + {shouldFail, filters: [{hostPrefix: "org"}], url: "https://mozilla.org"}, + {shouldPass, filters: [{hostPrefix: ""}], url: "https://mozilla.org"}, + {shouldPass, filters: [{hostPrefix: null}], url: "https://mozilla.org"}, + + {shouldPass, filters: [{hostSuffix: ".org"}], url: "https://mozilla.org"}, + {shouldFail, filters: [{hostSuffix: "moz"}], url: "https://mozilla.org"}, + {shouldPass, filters: [{hostSuffix: ""}], url: "https://mozilla.org"}, + {shouldPass, filters: [{hostSuffix: null}], url: "https://mozilla.org"}, + {shouldPass, filters: [{hostSuffix: "lla.org"}], url: "https://mozilla.org:8888"}, + + // hostEquals: urls without an host. + // TODO: should we explicitly cover hostContains, hostPrefix, hostSuffix for + // these sub-cases? + {shouldFail, filters: [{hostEquals: "blank"}], url: "about:blank"}, + {shouldFail, filters: [{hostEquals: "blank"}], url: "about://blank"}, + {shouldFail, filters: [{hostEquals: "testDataURL"}], url: "data:,testDataURL"}, + {shouldPass, filters: [{hostEquals: ""}], url: "about:blank"}, + {shouldPass, filters: [{hostEquals: ""}], url: "about://blank"}, + {shouldPass, filters: [{hostEquals: ""}], url: "data:,testDataURL"}, + + // Path filters (pathEquals, pathContains, pathPrefix, pathSuffix). + {shouldFail, filters: [{pathEquals: ""}], url: "https://mozilla.org/test/path"}, + {shouldPass, filters: [{pathEquals: null}], url: "https://mozilla.org/test/path"}, + {shouldPass, filters: [{pathEquals: "/test/path"}], url: "https://mozilla.org/test/path"}, + {shouldFail, filters: [{pathEquals: "/wrong/path"}], url: "https://mozilla.org/test/path"}, + {shouldPass, filters: [{pathEquals: "/test/path"}], url: "https://mozilla.org:8888/test/path"}, + // NOTE: trying at least once another valid protocol + {shouldPass, filters: [{pathEquals: "/test/path"}], url: "ftp://mozilla.org/test/path"}, + {shouldFail, filters: [{pathEquals: "/wrong/path"}], url: "ftp://mozilla.org/test/path"}, + + {shouldPass, filters: [{pathContains: "st/"}], url: "https://mozilla.org/test/path"}, + {shouldPass, filters: [{pathContains: "/test"}], url: "https://mozilla.org/test/path"}, + {shouldFail, filters: [{pathContains: "org"}], url: "https://mozilla.org/test/path"}, + {shouldPass, filters: [{pathContains: ""}], url: "https://mozilla.org/test/path"}, + {shouldPass, filters: [{pathContains: null}], url: "https://mozilla.org/test/path"}, + {shouldFail, filters: [{pathContains: "param"}], url: "https://mozilla.org:8888/test/path?param=1"}, + {shouldFail, filters: [{pathContains: "ref"}], url: "https://mozilla.org:8888/test/path#ref"}, + {shouldPass, filters: [{pathContains: "st/pa"}], url: "https://mozilla.org:8888/test/path"}, + + {shouldPass, filters: [{pathPrefix: "/te"}], url: "https://mozilla.org/test/path"}, + {shouldFail, filters: [{pathPrefix: "org/"}], url: "https://mozilla.org/test/path"}, + {shouldPass, filters: [{pathPrefix: ""}], url: "https://mozilla.org/test/path"}, + {shouldPass, filters: [{pathPrefix: null}], url: "https://mozilla.org/test/path"}, + + {shouldPass, filters: [{pathSuffix: "/path"}], url: "https://mozilla.org/test/path"}, + {shouldFail, filters: [{pathSuffix: "th/"}], url: "https://mozilla.org/test/path"}, + {shouldPass, filters: [{pathSuffix: ""}], url: "https://mozilla.org/test/path"}, + {shouldPass, filters: [{pathSuffix: null}], url: "https://mozilla.org/test/path"}, + {shouldFail, filters: [{pathSuffix: "p=1"}], url: "https://mozilla.org:8888/test/path?p=1"}, + {shouldFail, filters: [{pathSuffix: "ref"}], url: "https://mozilla.org:8888/test/path#ref"}, + + // Query filters (queryEquals, queryContains, queryPrefix, querySuffix). + {shouldFail, filters: [{queryEquals: ""}], url: "https://mozilla.org/?param=val"}, + {shouldPass, filters: [{queryEquals: null}], url: "https://mozilla.org/?param=val"}, + {shouldPass, filters: [{queryEquals: "param=val"}], url: "https://mozilla.org/?param=val"}, + {shouldFail, filters: [{queryEquals: "?param=val"}], url: "https://mozilla.org/?param=val"}, + {shouldFail, filters: [{queryEquals: "/path?param=val"}], url: "https://mozilla.org/path?param=val"}, + + // NOTE: about scheme urls cannot be matched by query. + {shouldFail, filters: [{queryEquals: "param=val"}], url: "about:blank?param=val"}, + {shouldFail, filters: [{queryEquals: "param"}], url: "ftp://mozilla.org?param=val"}, + + {shouldPass, filters: [{queryContains: "ram"}], url: "https://mozilla.org/?param=val"}, + {shouldPass, filters: [{queryContains: "=val"}], url: "https://mozilla.org/?param=val"}, + {shouldFail, filters: [{queryContains: "?param"}], url: "https://mozilla.org/?param=val"}, + {shouldFail, filters: [{queryContains: "path"}], url: "https://mozilla.org/path/?p=v#ref"}, + {shouldPass, filters: [{queryContains: ""}], url: "https://mozilla.org/?param=val"}, + {shouldPass, filters: [{queryContains: null}], url: "https://mozilla.org/?param=val"}, + + {shouldPass, filters: [{queryPrefix: "param"}], url: "https://mozilla.org/?param=val"}, + {shouldFail, filters: [{queryPrefix: "p="}], url: "https://mozilla.org/?param=val"}, + {shouldFail, filters: [{queryPrefix: "path"}], url: "https://mozilla.org/path?param=val"}, + {shouldPass, filters: [{queryPrefix: ""}], url: "https://mozilla.org/?param=val"}, + {shouldPass, filters: [{queryPrefix: null}], url: "https://mozilla.org/?param=val"}, + + {shouldPass, filters: [{querySuffix: "=val"}], url: "https://mozilla.org/?param=val"}, + {shouldFail, filters: [{querySuffix: "=wrong"}], url: "https://mozilla.org/?param=val"}, + {shouldPass, filters: [{querySuffix: ""}], url: "https://mozilla.org/?param=val"}, + {shouldPass, filters: [{querySuffix: null}], url: "https://mozilla.org/?param=val"}, + + // URL filters (urlEquals, urlContains, urlPrefix, urlSuffix). + {shouldFail, filters: [{urlEquals: ""}], url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlEquals: null}], url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlEquals: "https://mozilla.org/?p=v#ref"}], + url: "https://mozilla.org/?p=v#ref"}, + {shouldFail, filters: [{urlEquals: "https://mozilla.org/?p=v#ref2"}], + url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlEquals: "about:blank?p=v#ref"}], url: "about:blank?p=v#ref"}, + {shouldPass, filters: [{urlEquals: "ftp://mozilla.org?p=v#ref"}], + url: "ftp://mozilla.org?p=v#ref"}, + + {shouldPass, filters: [{urlContains: "org/?p"}], url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlContains: "=v#ref"}], url: "https://mozilla.org/?p=v#ref"}, + {shouldFail, filters: [{urlContains: "ftp"}], url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlContains: ""}], url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlContains: null}], url: "https://mozilla.org/?p=v#ref"}, + + {shouldPass, filters: [{urlPrefix: "http"}], url: "https://mozilla.org/?p=v#ref"}, + {shouldFail, filters: [{urlPrefix: "moz"}], url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlPrefix: ""}], url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlPrefix: null}], url: "https://mozilla.org/?p=v#ref"}, + + {shouldPass, filters: [{urlSuffix: "#ref"}], url: "https://mozilla.org/?p=v#ref"}, + {shouldFail, filters: [{urlSuffix: "=wrong"}], url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlSuffix: ""}], url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlSuffix: null}], url: "https://mozilla.org/?p=v#ref"}, + + // More url filters: urlMatches. + {shouldPass, filters: [{urlMatches: ".*://mozilla"}], url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlMatches: ".*://mozilla"}], url: "ftp://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlMatches: ".*://.*/\?p"}], url: "ftp://mozilla.org/?p=v#ref"}, + // NOTE: urlMatches should not match the url without the ref. + {shouldFail, filters: [{urlMatches: "v#ref$"}], url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{urlMatches: "^ftp"}], url: "ftp://mozilla.org/?p=v#ref"}, + + // More url filters: originAndPathMatches. + {shouldPass, filters: [{originAndPathMatches: ".*://mozilla"}], + url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{originAndPathMatches: ".*://mozilla"}], + url: "ftp://mozilla.org/?p=v#ref"}, + // NOTE: urlMatches should not match the url without the query and the ref. + {shouldFail, filters: [{originAndPathMatches: ".*://.*/\?p"}], + url: "ftp://mozilla.org/?p=v#ref"}, + {shouldFail, filters: [{originAndPathMatches: "v#ref$"}], + url: "https://mozilla.org/?p=v#ref"}, + {shouldPass, filters: [{originAndPathMatches: "^ftp"}], + url: "ftp://mozilla.org/?p=v#ref"}, + + // Filter with all criteria: all matches, none matches, some matches. + + // All matches. + {shouldPass, filters: [ + { + schemes: ["https", "http"], + ports: [443, 80], + hostEquals: "www.mozilla.org", + hostContains: ".moz", + hostPrefix: "www", + hostSuffix: "org", + pathEquals: "/sub/path", + pathContains: "b/p", + pathPrefix: "/sub", + pathSuffix: "/path", + queryEquals: "p=v", + queryContains: "1=", + queryPrefix: "p1", + querySuffix: "=v", + urlEquals: "https://www.mozilla.org/sub/path?p1=v#ref", + urlContains: "org/sub", + urlPrefix: "https://moz", + urlSuffix: "#ref", + urlMatches: "v#ref$", + originAndPathMatches: ".*://moz.*/" + }, + ], url: "https://www.mozilla.org/sub/path?p1=v#ref"}, + // None matches. + {shouldFail, filters: [ + { + schemes: ["http"], + ports: [80], + hostEquals: "mozilla.com", + hostContains: "www.moz", + hostPrefix: "www", + hostSuffix: "com", + pathEquals: "/wrong/path", + pathContains: "g/p", + pathPrefix: "/wrong", + pathSuffix: "/wrong", + queryEquals: "p2=v", + queryContains: "2=", + queryPrefix: "p2", + querySuffix: "=value", + urlEquals: "http://mozilla.com/sub/path?p1=v#ref", + urlContains: "com/sub", + urlPrefix: "http://moz", + urlSuffix: "#ref2", + urlMatches: "value#ref2$", + originAndPathMatches: ".*://moz.*com/" + }, + ], url: "https://mozilla.org/sub/path?p1=v#ref"}, + // Some matches + {shouldFail, filters: [ + { + schemes: ["https"], + ports: [80], + hostEquals: "mozilla.com", + hostContains: "www.moz", + hostPrefix: "www", + hostSuffix: "com", + pathEquals: "/wrong/path", + pathContains: "g/p", + pathPrefix: "/wrong", + pathSuffix: "/wrong", + queryEquals: "p2=v", + queryContains: "2=", + queryPrefix: "p2", + querySuffix: "=value", + urlEquals: "http://mozilla.com/sub/path?p1=v#ref", + urlContains: "com/sub", + urlPrefix: "http://moz", + urlSuffix: "#ref2", + urlMatches: "value#ref2$", + originAndPathMatches: ".*://moz.*com/" + }, + ], url: "https://mozilla.org/sub/path?p1=v#ref"}, + + // Filter with multiple filters: all matches, some matches, none matches. + + // All matches. + {shouldPass, filters: [ + {schemes: ["https", "http"]}, + {ports: [443, 80]}, + {hostEquals: "www.mozilla.org"}, + {hostContains: ".moz"}, + {hostPrefix: "www"}, + {hostSuffix: "org"}, + {pathEquals: "/sub/path"}, + {pathContains: "b/p"}, + {pathPrefix: "/sub"}, + {pathSuffix: "/path"}, + {queryEquals: "p=v"}, + {queryContains: "1="}, + {queryPrefix: "p1"}, + {querySuffix: "=v"}, + {urlEquals: "https://www.mozilla.org/sub/path?p1=v#ref"}, + {urlContains: "org/sub"}, + {urlPrefix: "https://moz"}, + {urlSuffix: "#ref"}, + {urlMatches: "v#ref$"}, + {originAndPathMatches: ".*://moz.*/"}, + ], url: "https://www.mozilla.org/sub/path?p1=v#ref"}, + + // None matches. + {shouldFail, filters: [ + {schemes: ["http"]}, + {ports: [80]}, + {hostEquals: "mozilla.com"}, + {hostContains: "www.moz"}, + {hostPrefix: "www"}, + {hostSuffix: "com"}, + {pathEquals: "/wrong/path"}, + {pathContains: "g/p"}, + {pathPrefix: "/wrong"}, + {pathSuffix: "/wrong"}, + {queryEquals: "p2=v"}, + {queryContains: "2="}, + {queryPrefix: "p2"}, + {querySuffix: "=value"}, + {urlEquals: "http://mozilla.com/sub/path?p1=v#ref"}, + {urlContains: "com/sub"}, + {urlPrefix: "http://moz"}, + {urlSuffix: "#ref2"}, + {urlMatches: "value#ref2$"}, + {originAndPathMatches: ".*://moz.*com/"}, + ], url: "https://mozilla.org/sub/path?p1=v#ref"}, + + // Some matches. + {shouldPass, filters: [ + {schemes: ["https"]}, + {ports: [80]}, + {hostEquals: "mozilla.com"}, + {hostContains: "www.moz"}, + {hostPrefix: "www"}, + {hostSuffix: "com"}, + {pathEquals: "/wrong/path"}, + {pathContains: "g/p"}, + {pathPrefix: "/wrong"}, + {pathSuffix: "/wrong"}, + {queryEquals: "p2=v"}, + {queryContains: "2="}, + {queryPrefix: "p2"}, + {querySuffix: "=value"}, + {urlEquals: "http://mozilla.com/sub/path?p1=v#ref"}, + {urlContains: "com/sub"}, + {urlPrefix: "http://moz"}, + {urlSuffix: "#ref2"}, + {urlMatches: "value#ref2$"}, + {originAndPathMatches: ".*://moz.*com/"}, + ], url: "https://mozilla.org/sub/path?p1=v#ref"}, + ]; + + // Run all the the testCases defined above. + for (let currentTest of testCases) { + let { + exceptionMessageContains, + url, filters, + } = currentTest; + + if (currentTest.shouldThrow) { + expectThrow({url, filters, exceptionMessageContains}) + } else if (currentTest.shouldFail) { + expectFail({url, filters}); + } else { + expectPass({url, filters}); + } + } +}); diff --git a/toolkit/modules/tests/xpcshell/test_NewTabUtils.js b/toolkit/modules/tests/xpcshell/test_NewTabUtils.js new file mode 100644 index 0000000000..8cdb635501 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_NewTabUtils.js @@ -0,0 +1,378 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// See also browser/base/content/test/newtab/. + +var { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; +Cu.import("resource://gre/modules/NewTabUtils.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +const PREF_NEWTAB_ENHANCED = "browser.newtabpage.enhanced"; + +function run_test() { + Services.prefs.setBoolPref(PREF_NEWTAB_ENHANCED, true); + run_next_test(); +} + +add_task(function* validCacheMidPopulation() { + let expectedLinks = makeLinks(0, 3, 1); + + let provider = new TestProvider(done => done(expectedLinks)); + provider.maxNumLinks = expectedLinks.length; + + NewTabUtils.initWithoutProviders(); + NewTabUtils.links.addProvider(provider); + let promise = new Promise(resolve => NewTabUtils.links.populateCache(resolve)); + + // isTopSiteGivenProvider() and getProviderLinks() should still return results + // even when cache is empty or being populated. + do_check_false(NewTabUtils.isTopSiteGivenProvider("example1.com", provider)); + do_check_links(NewTabUtils.getProviderLinks(provider), []); + + yield promise; + + // Once the cache is populated, we get the expected results + do_check_true(NewTabUtils.isTopSiteGivenProvider("example1.com", provider)); + do_check_links(NewTabUtils.getProviderLinks(provider), expectedLinks); + NewTabUtils.links.removeProvider(provider); +}); + +add_task(function* notifyLinkDelete() { + let expectedLinks = makeLinks(0, 3, 1); + + let provider = new TestProvider(done => done(expectedLinks)); + provider.maxNumLinks = expectedLinks.length; + + NewTabUtils.initWithoutProviders(); + NewTabUtils.links.addProvider(provider); + yield new Promise(resolve => NewTabUtils.links.populateCache(resolve)); + + do_check_links(NewTabUtils.links.getLinks(), expectedLinks); + + // Remove a link. + let removedLink = expectedLinks[2]; + provider.notifyLinkChanged(removedLink, 2, true); + let links = NewTabUtils.links._providers.get(provider); + + // Check that sortedLinks is correctly updated. + do_check_links(NewTabUtils.links.getLinks(), expectedLinks.slice(0, 2)); + + // Check that linkMap is accurately updated. + do_check_eq(links.linkMap.size, 2); + do_check_true(links.linkMap.get(expectedLinks[0].url)); + do_check_true(links.linkMap.get(expectedLinks[1].url)); + do_check_false(links.linkMap.get(removedLink.url)); + + // Check that siteMap is correctly updated. + do_check_eq(links.siteMap.size, 2); + do_check_true(links.siteMap.has(NewTabUtils.extractSite(expectedLinks[0].url))); + do_check_true(links.siteMap.has(NewTabUtils.extractSite(expectedLinks[1].url))); + do_check_false(links.siteMap.has(NewTabUtils.extractSite(removedLink.url))); + + NewTabUtils.links.removeProvider(provider); +}); + +add_task(function* populatePromise() { + let count = 0; + let expectedLinks = makeLinks(0, 10, 2); + + let getLinksFcn = Task.async(function* (callback) { + // Should not be calling getLinksFcn twice + count++; + do_check_eq(count, 1); + yield Promise.resolve(); + callback(expectedLinks); + }); + + let provider = new TestProvider(getLinksFcn); + + NewTabUtils.initWithoutProviders(); + NewTabUtils.links.addProvider(provider); + + NewTabUtils.links.populateProviderCache(provider, () => {}); + NewTabUtils.links.populateProviderCache(provider, () => { + do_check_links(NewTabUtils.links.getLinks(), expectedLinks); + NewTabUtils.links.removeProvider(provider); + }); +}); + +add_task(function* isTopSiteGivenProvider() { + let expectedLinks = makeLinks(0, 10, 2); + + // The lowest 2 frecencies have the same base domain. + expectedLinks[expectedLinks.length - 2].url = expectedLinks[expectedLinks.length - 1].url + "Test"; + + let provider = new TestProvider(done => done(expectedLinks)); + provider.maxNumLinks = expectedLinks.length; + + NewTabUtils.initWithoutProviders(); + NewTabUtils.links.addProvider(provider); + yield new Promise(resolve => NewTabUtils.links.populateCache(resolve)); + + do_check_eq(NewTabUtils.isTopSiteGivenProvider("example2.com", provider), true); + do_check_eq(NewTabUtils.isTopSiteGivenProvider("example1.com", provider), false); + + // Push out frecency 2 because the maxNumLinks is reached when adding frecency 3 + let newLink = makeLink(3); + provider.notifyLinkChanged(newLink); + + // There is still a frecent url with example2 domain, so it's still frecent. + do_check_eq(NewTabUtils.isTopSiteGivenProvider("example3.com", provider), true); + do_check_eq(NewTabUtils.isTopSiteGivenProvider("example2.com", provider), true); + + // Push out frecency 3 + newLink = makeLink(5); + provider.notifyLinkChanged(newLink); + + // Push out frecency 4 + newLink = makeLink(9); + provider.notifyLinkChanged(newLink); + + // Our count reached 0 for the example2.com domain so it's no longer a frecent site. + do_check_eq(NewTabUtils.isTopSiteGivenProvider("example5.com", provider), true); + do_check_eq(NewTabUtils.isTopSiteGivenProvider("example2.com", provider), false); + + NewTabUtils.links.removeProvider(provider); +}); + +add_task(function* multipleProviders() { + // Make each provider generate NewTabUtils.links.maxNumLinks links to check + // that no more than maxNumLinks are actually returned in the merged list. + let evenLinks = makeLinks(0, 2 * NewTabUtils.links.maxNumLinks, 2); + let evenProvider = new TestProvider(done => done(evenLinks)); + let oddLinks = makeLinks(0, 2 * NewTabUtils.links.maxNumLinks - 1, 2); + let oddProvider = new TestProvider(done => done(oddLinks)); + + NewTabUtils.initWithoutProviders(); + NewTabUtils.links.addProvider(evenProvider); + NewTabUtils.links.addProvider(oddProvider); + + yield new Promise(resolve => NewTabUtils.links.populateCache(resolve)); + + let links = NewTabUtils.links.getLinks(); + let expectedLinks = makeLinks(NewTabUtils.links.maxNumLinks, + 2 * NewTabUtils.links.maxNumLinks, + 1); + do_check_eq(links.length, NewTabUtils.links.maxNumLinks); + do_check_links(links, expectedLinks); + + NewTabUtils.links.removeProvider(evenProvider); + NewTabUtils.links.removeProvider(oddProvider); +}); + +add_task(function* changeLinks() { + let expectedLinks = makeLinks(0, 20, 2); + let provider = new TestProvider(done => done(expectedLinks)); + + NewTabUtils.initWithoutProviders(); + NewTabUtils.links.addProvider(provider); + + yield new Promise(resolve => NewTabUtils.links.populateCache(resolve)); + + do_check_links(NewTabUtils.links.getLinks(), expectedLinks); + + // Notify of a new link. + let newLink = makeLink(19); + expectedLinks.splice(1, 0, newLink); + provider.notifyLinkChanged(newLink); + do_check_links(NewTabUtils.links.getLinks(), expectedLinks); + + // Notify of a link that's changed sort criteria. + newLink.frecency = 17; + expectedLinks.splice(1, 1); + expectedLinks.splice(2, 0, newLink); + provider.notifyLinkChanged({ + url: newLink.url, + frecency: 17, + }); + do_check_links(NewTabUtils.links.getLinks(), expectedLinks); + + // Notify of a link that's changed title. + newLink.title = "My frecency is now 17"; + provider.notifyLinkChanged({ + url: newLink.url, + title: newLink.title, + }); + do_check_links(NewTabUtils.links.getLinks(), expectedLinks); + + // Notify of a new link again, but this time make it overflow maxNumLinks. + provider.maxNumLinks = expectedLinks.length; + newLink = makeLink(21); + expectedLinks.unshift(newLink); + expectedLinks.pop(); + do_check_eq(expectedLinks.length, provider.maxNumLinks); // Sanity check. + provider.notifyLinkChanged(newLink); + do_check_links(NewTabUtils.links.getLinks(), expectedLinks); + + // Notify of many links changed. + expectedLinks = makeLinks(0, 3, 1); + provider.notifyManyLinksChanged(); + + // Since _populateProviderCache() is async, we must wait until the provider's + // populate promise has been resolved. + yield NewTabUtils.links._providers.get(provider).populatePromise; + + // NewTabUtils.links will now repopulate its cache + do_check_links(NewTabUtils.links.getLinks(), expectedLinks); + + NewTabUtils.links.removeProvider(provider); +}); + +add_task(function* oneProviderAlreadyCached() { + let links1 = makeLinks(0, 10, 1); + let provider1 = new TestProvider(done => done(links1)); + + NewTabUtils.initWithoutProviders(); + NewTabUtils.links.addProvider(provider1); + + yield new Promise(resolve => NewTabUtils.links.populateCache(resolve)); + do_check_links(NewTabUtils.links.getLinks(), links1); + + let links2 = makeLinks(10, 20, 1); + let provider2 = new TestProvider(done => done(links2)); + NewTabUtils.links.addProvider(provider2); + + yield new Promise(resolve => NewTabUtils.links.populateCache(resolve)); + do_check_links(NewTabUtils.links.getLinks(), links2.concat(links1)); + + NewTabUtils.links.removeProvider(provider1); + NewTabUtils.links.removeProvider(provider2); +}); + +add_task(function* newLowRankedLink() { + // Init a provider with 10 links and make its maximum number also 10. + let links = makeLinks(0, 10, 1); + let provider = new TestProvider(done => done(links)); + provider.maxNumLinks = links.length; + + NewTabUtils.initWithoutProviders(); + NewTabUtils.links.addProvider(provider); + + yield new Promise(resolve => NewTabUtils.links.populateCache(resolve)); + do_check_links(NewTabUtils.links.getLinks(), links); + + // Notify of a new link that's low-ranked enough not to make the list. + let newLink = makeLink(0); + provider.notifyLinkChanged(newLink); + do_check_links(NewTabUtils.links.getLinks(), links); + + // Notify about the new link's title change. + provider.notifyLinkChanged({ + url: newLink.url, + title: "a new title", + }); + do_check_links(NewTabUtils.links.getLinks(), links); + + NewTabUtils.links.removeProvider(provider); +}); + +add_task(function* extractSite() { + // All these should extract to the same site + [ "mozilla.org", + "m.mozilla.org", + "mobile.mozilla.org", + "www.mozilla.org", + "www3.mozilla.org", + ].forEach(host => { + let url = "http://" + host; + do_check_eq(NewTabUtils.extractSite(url), "mozilla.org", "extracted same " + host); + }); + + // All these should extract to the same subdomain + [ "bugzilla.mozilla.org", + "www.bugzilla.mozilla.org", + ].forEach(host => { + let url = "http://" + host; + do_check_eq(NewTabUtils.extractSite(url), "bugzilla.mozilla.org", "extracted eTLD+2 " + host); + }); + + // All these should not extract to the same site + [ "bugzilla.mozilla.org", + "bug123.bugzilla.mozilla.org", + "too.many.levels.bugzilla.mozilla.org", + "m2.mozilla.org", + "mobile30.mozilla.org", + "ww.mozilla.org", + "ww2.mozilla.org", + "wwwww.mozilla.org", + "wwwww50.mozilla.org", + "wwws.mozilla.org", + "secure.mozilla.org", + "secure10.mozilla.org", + "many.levels.deep.mozilla.org", + "just.check.in", + "192.168.0.1", + "localhost", + ].forEach(host => { + let url = "http://" + host; + do_check_neq(NewTabUtils.extractSite(url), "mozilla.org", "extracted diff " + host); + }); + + // All these should not extract to the same site + [ "about:blank", + "file:///Users/user/file", + "chrome://browser/something", + "ftp://ftp.mozilla.org/", + ].forEach(url => { + do_check_neq(NewTabUtils.extractSite(url), "mozilla.org", "extracted diff url " + url); + }); +}); + +function TestProvider(getLinksFn) { + this.getLinks = getLinksFn; + this._observers = new Set(); +} + +TestProvider.prototype = { + addObserver: function (observer) { + this._observers.add(observer); + }, + notifyLinkChanged: function (link, index=-1, deleted=false) { + this._notifyObservers("onLinkChanged", link, index, deleted); + }, + notifyManyLinksChanged: function () { + this._notifyObservers("onManyLinksChanged"); + }, + _notifyObservers: function () { + let observerMethodName = arguments[0]; + let args = Array.prototype.slice.call(arguments, 1); + args.unshift(this); + for (let obs of this._observers) { + if (obs[observerMethodName]) + obs[observerMethodName].apply(NewTabUtils.links, args); + } + }, +}; + +function do_check_links(actualLinks, expectedLinks) { + do_check_true(Array.isArray(actualLinks)); + do_check_eq(actualLinks.length, expectedLinks.length); + for (let i = 0; i < expectedLinks.length; i++) { + let expected = expectedLinks[i]; + let actual = actualLinks[i]; + do_check_eq(actual.url, expected.url); + do_check_eq(actual.title, expected.title); + do_check_eq(actual.frecency, expected.frecency); + do_check_eq(actual.lastVisitDate, expected.lastVisitDate); + } +} + +function makeLinks(frecRangeStart, frecRangeEnd, step) { + let links = []; + // Remember, links are ordered by frecency descending. + for (let i = frecRangeEnd; i > frecRangeStart; i -= step) { + links.push(makeLink(i)); + } + return links; +} + +function makeLink(frecency) { + return { + url: "http://example" + frecency + ".com/", + title: "My frecency is " + frecency, + frecency: frecency, + lastVisitDate: 0, + }; +} diff --git a/toolkit/modules/tests/xpcshell/test_ObjectUtils.js b/toolkit/modules/tests/xpcshell/test_ObjectUtils.js new file mode 100644 index 0000000000..9aef3e9079 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_ObjectUtils.js @@ -0,0 +1,96 @@ +Components.utils.import("resource://gre/modules/ObjectUtils.jsm"); + +function run_test() { + run_next_test(); +} + +add_task(function* test_deepEqual() { + let deepEqual = ObjectUtils.deepEqual.bind(ObjectUtils); + // CommonJS 7.2 + Assert.ok(deepEqual(new Date(2000, 3, 14), new Date(2000, 3, 14)), "deepEqual date"); + Assert.ok(deepEqual(new Date(NaN), new Date(NaN)), "deepEqual invalid dates"); + + Assert.ok(!deepEqual(new Date(), new Date(2000, 3, 14)), "deepEqual date"); + + // 7.3 + Assert.ok(deepEqual(/a/, /a/)); + Assert.ok(deepEqual(/a/g, /a/g)); + Assert.ok(deepEqual(/a/i, /a/i)); + Assert.ok(deepEqual(/a/m, /a/m)); + Assert.ok(deepEqual(/a/igm, /a/igm)); + Assert.ok(!deepEqual(/ab/, /a/)); + Assert.ok(!deepEqual(/a/g, /a/)); + Assert.ok(!deepEqual(/a/i, /a/)); + Assert.ok(!deepEqual(/a/m, /a/)); + Assert.ok(!deepEqual(/a/igm, /a/im)); + + let re1 = /a/; + re1.lastIndex = 3; + Assert.ok(!deepEqual(re1, /a/)); + + // 7.4 + Assert.ok(deepEqual(4, "4"), "deepEqual == check"); + Assert.ok(deepEqual(true, 1), "deepEqual == check"); + Assert.ok(!deepEqual(4, "5"), "deepEqual == check"); + + // 7.5 + // having the same number of owned properties && the same set of keys + Assert.ok(deepEqual({a: 4}, {a: 4})); + Assert.ok(deepEqual({a: 4, b: "2"}, {a: 4, b: "2"})); + Assert.ok(deepEqual([4], ["4"])); + Assert.ok(!deepEqual({a: 4}, {a: 4, b: true})); + Assert.ok(deepEqual(["a"], {0: "a"})); + + let a1 = [1, 2, 3]; + let a2 = [1, 2, 3]; + a1.a = "test"; + a1.b = true; + a2.b = true; + a2.a = "test"; + Assert.ok(!deepEqual(Object.keys(a1), Object.keys(a2))); + Assert.ok(deepEqual(a1, a2)); + + let nbRoot = { + toString: function() { return this.first + " " + this.last; } + }; + + function nameBuilder(first, last) { + this.first = first; + this.last = last; + return this; + } + nameBuilder.prototype = nbRoot; + + function nameBuilder2(first, last) { + this.first = first; + this.last = last; + return this; + } + nameBuilder2.prototype = nbRoot; + + let nb1 = new nameBuilder("Ryan", "Dahl"); + let nb2 = new nameBuilder2("Ryan", "Dahl"); + + Assert.ok(deepEqual(nb1, nb2)); + + nameBuilder2.prototype = Object; + nb2 = new nameBuilder2("Ryan", "Dahl"); + Assert.ok(!deepEqual(nb1, nb2)); + + // String literal + object + Assert.ok(!deepEqual("a", {})); + + // Make sure deepEqual doesn't loop forever on circular refs + + let b = {}; + b.b = b; + + let c = {}; + c.b = c; + + try { + Assert.ok(!deepEqual(b, c)); + } catch (e) { + Assert.ok(true, "Didn't recurse infinitely."); + } +}); diff --git a/toolkit/modules/tests/xpcshell/test_ObjectUtils_strict.js b/toolkit/modules/tests/xpcshell/test_ObjectUtils_strict.js new file mode 100644 index 0000000000..44572e6001 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_ObjectUtils_strict.js @@ -0,0 +1,29 @@ +"use strict"; + +var {ObjectUtils} = Components.utils.import("resource://gre/modules/ObjectUtils.jsm", {}); +var {PromiseTestUtils} = Components.utils.import("resource://testing-common/PromiseTestUtils.jsm", {}); + +add_task(function* test_strict() { + let loose = { a: 1 }; + let strict = ObjectUtils.strict(loose); + + loose.a; // Should not throw. + loose.b || undefined; // Should not throw. + + strict.a; // Should not throw. + PromiseTestUtils.expectUncaughtRejection(/No such property: "b"/); + Assert.throws(() => strict.b, /No such property: "b"/); + "b" in strict; // Should not throw. + strict.b = 2; + strict.b; // Should not throw. + + PromiseTestUtils.expectUncaughtRejection(/No such property: "c"/); + Assert.throws(() => strict.c, /No such property: "c"/); + "c" in strict; // Should not throw. + loose.c = 3; + strict.c; // Should not throw. +}); + +function run_test() { + run_next_test(); +} diff --git a/toolkit/modules/tests/xpcshell/test_PermissionsUtils.js b/toolkit/modules/tests/xpcshell/test_PermissionsUtils.js new file mode 100644 index 0000000000..3982ce0151 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_PermissionsUtils.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +// Tests that PerrmissionsUtils.jsm works as expected, including: +// * PermissionsUtils.importfromPrefs() +// <ROOT>.[whitelist|blacklist].add preferences are emptied when +// converted into permissions on startup. + + +const PREF_ROOT = "testpermissions."; +const TEST_PERM = "test-permission"; + +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/PermissionsUtils.jsm"); + +function run_test() { + test_importfromPrefs(); +} + + +function test_importfromPrefs() { + // Create own preferences to test + Services.prefs.setCharPref(PREF_ROOT + "whitelist.add.EMPTY", ""); + Services.prefs.setCharPref(PREF_ROOT + "whitelist.add.EMPTY2", ","); + Services.prefs.setCharPref(PREF_ROOT + "whitelist.add.TEST", "http://whitelist.example.com"); + Services.prefs.setCharPref(PREF_ROOT + "whitelist.add.TEST2", "https://whitelist2-1.example.com,http://whitelist2-2.example.com:8080,about:home"); + Services.prefs.setCharPref(PREF_ROOT + "whitelist.add.TEST3", "whitelist3-1.example.com,about:config"); // legacy style - host only + Services.prefs.setCharPref(PREF_ROOT + "blacklist.add.EMPTY", ""); + Services.prefs.setCharPref(PREF_ROOT + "blacklist.add.TEST", "http://blacklist.example.com,"); + Services.prefs.setCharPref(PREF_ROOT + "blacklist.add.TEST2", ",https://blacklist2-1.example.com,http://blacklist2-2.example.com:8080,about:mozilla"); + Services.prefs.setCharPref(PREF_ROOT + "blacklist.add.TEST3", "blacklist3-1.example.com,about:preferences"); // legacy style - host only + + // Check they are unknown in the permission manager prior to importing. + let whitelisted = ["http://whitelist.example.com", + "https://whitelist2-1.example.com", + "http://whitelist2-2.example.com:8080", + "http://whitelist3-1.example.com", + "https://whitelist3-1.example.com", + "about:config", + "about:home"]; + let blacklisted = ["http://blacklist.example.com", + "https://blacklist2-1.example.com", + "http://blacklist2-2.example.com:8080", + "http://blacklist3-1.example.com", + "https://blacklist3-1.example.com", + "about:preferences", + "about:mozilla"]; + let untouched = ["https://whitelist.example.com", + "https://blacklist.example.com", + "http://whitelist2-1.example.com", + "http://blacklist2-1.example.com", + "https://whitelist2-2.example.com:8080", + "https://blacklist2-2.example.com:8080"]; + let unknown = whitelisted.concat(blacklisted).concat(untouched); + for (let url of unknown) { + let uri = Services.io.newURI(url, null, null); + do_check_eq(Services.perms.testPermission(uri, TEST_PERM), Services.perms.UNKNOWN_ACTION); + } + + // Import them + PermissionsUtils.importFromPrefs(PREF_ROOT, TEST_PERM); + + // Get list of preferences to check + let preferences = Services.prefs.getChildList(PREF_ROOT, {}); + + // Check preferences were emptied + for (let pref of preferences) { + do_check_eq(Services.prefs.getCharPref(pref), ""); + } + + // Check they were imported into the permissions manager + for (let url of whitelisted) { + let uri = Services.io.newURI(url, null, null); + do_check_eq(Services.perms.testPermission(uri, TEST_PERM), Services.perms.ALLOW_ACTION); + } + for (let url of blacklisted) { + let uri = Services.io.newURI(url, null, null); + do_check_eq(Services.perms.testPermission(uri, TEST_PERM), Services.perms.DENY_ACTION); + } + for (let url of untouched) { + let uri = Services.io.newURI(url, null, null); + do_check_eq(Services.perms.testPermission(uri, TEST_PERM), Services.perms.UNKNOWN_ACTION); + } +} diff --git a/toolkit/modules/tests/xpcshell/test_Preferences.js b/toolkit/modules/tests/xpcshell/test_Preferences.js new file mode 100644 index 0000000000..ef430909fd --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_Preferences.js @@ -0,0 +1,378 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu, manager: Cm} = Components; + +Cu.import("resource://gre/modules/Preferences.jsm"); + +function run_test() { + run_next_test(); +} + +add_test(function test_set_get_pref() { + Preferences.set("test_set_get_pref.integer", 1); + do_check_eq(Preferences.get("test_set_get_pref.integer"), 1); + + Preferences.set("test_set_get_pref.string", "foo"); + do_check_eq(Preferences.get("test_set_get_pref.string"), "foo"); + + Preferences.set("test_set_get_pref.boolean", true); + do_check_eq(Preferences.get("test_set_get_pref.boolean"), true); + + // Clean up. + Preferences.resetBranch("test_set_get_pref."); + + run_next_test(); +}); + +add_test(function test_set_get_branch_pref() { + let prefs = new Preferences("test_set_get_branch_pref."); + + prefs.set("something", 1); + do_check_eq(prefs.get("something"), 1); + do_check_false(Preferences.has("something")); + + // Clean up. + prefs.reset("something"); + + run_next_test(); +}); + +add_test(function test_set_get_multiple_prefs() { + Preferences.set({ "test_set_get_multiple_prefs.integer": 1, + "test_set_get_multiple_prefs.string": "foo", + "test_set_get_multiple_prefs.boolean": true }); + + let [i, s, b] = Preferences.get(["test_set_get_multiple_prefs.integer", + "test_set_get_multiple_prefs.string", + "test_set_get_multiple_prefs.boolean"]); + + do_check_eq(i, 1); + do_check_eq(s, "foo"); + do_check_eq(b, true); + + // Clean up. + Preferences.resetBranch("test_set_get_multiple_prefs."); + + run_next_test(); +}); + +add_test(function test_get_multiple_prefs_with_default_value() { + Preferences.set({ "test_get_multiple_prefs_with_default_value.a": 1, + "test_get_multiple_prefs_with_default_value.b": 2 }); + + let [a, b, c] = Preferences.get(["test_get_multiple_prefs_with_default_value.a", + "test_get_multiple_prefs_with_default_value.b", + "test_get_multiple_prefs_with_default_value.c"], + 0); + + do_check_eq(a, 1); + do_check_eq(b, 2); + do_check_eq(c, 0); + + // Clean up. + Preferences.resetBranch("test_get_multiple_prefs_with_default_value."); + + run_next_test(); +}); + +add_test(function test_set_get_unicode_pref() { + Preferences.set("test_set_get_unicode_pref", String.fromCharCode(960)); + do_check_eq(Preferences.get("test_set_get_unicode_pref"), String.fromCharCode(960)); + + // Clean up. + Preferences.reset("test_set_get_unicode_pref"); + + run_next_test(); +}); + +add_test(function test_set_null_pref() { + try { + Preferences.set("test_set_null_pref", null); + // We expect this to throw, so the test is designed to fail if it doesn't. + do_check_true(false); + } + catch (ex) {} + + run_next_test(); +}); + +add_test(function test_set_undefined_pref() { + try { + Preferences.set("test_set_undefined_pref"); + // We expect this to throw, so the test is designed to fail if it doesn't. + do_check_true(false); + } + catch (ex) {} + + run_next_test(); +}); + +add_test(function test_set_unsupported_pref() { + try { + Preferences.set("test_set_unsupported_pref", new Array()); + // We expect this to throw, so the test is designed to fail if it doesn't. + do_check_true(false); + } + catch (ex) {} + + run_next_test(); +}); + +// Make sure that we can get a string pref that we didn't set ourselves +// (i.e. that the way we get a string pref using getComplexValue doesn't +// hork us getting a string pref that wasn't set using setComplexValue). +add_test(function test_get_string_pref() { + let svc = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefService). + getBranch(""); + svc.setCharPref("test_get_string_pref", "a normal string"); + do_check_eq(Preferences.get("test_get_string_pref"), "a normal string"); + + // Clean up. + Preferences.reset("test_get_string_pref"); + + run_next_test(); +}); + +add_test(function test_get_localized_string_pref() { + let svc = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefService). + getBranch(""); + let prefName = "test_get_localized_string_pref"; + let localizedString = Cc["@mozilla.org/pref-localizedstring;1"] + .createInstance(Ci.nsIPrefLocalizedString); + localizedString.data = "a localized string"; + svc.setComplexValue(prefName, Ci.nsIPrefLocalizedString, localizedString); + do_check_eq(Preferences.get(prefName, null, Ci.nsIPrefLocalizedString), + "a localized string"); + + // Clean up. + Preferences.reset(prefName); + + run_next_test(); +}); + +add_test(function test_set_get_number_pref() { + Preferences.set("test_set_get_number_pref", 5); + do_check_eq(Preferences.get("test_set_get_number_pref"), 5); + + // Non-integer values get converted to integers. + Preferences.set("test_set_get_number_pref", 3.14159); + do_check_eq(Preferences.get("test_set_get_number_pref"), 3); + + // Values outside the range -(2^31-1) to 2^31-1 overflow. + try { + Preferences.set("test_set_get_number_pref", Math.pow(2, 31)); + // We expect this to throw, so the test is designed to fail if it doesn't. + do_check_true(false); + } + catch (ex) {} + + // Clean up. + Preferences.reset("test_set_get_number_pref"); + + run_next_test(); +}); + +add_test(function test_reset_pref() { + Preferences.set("test_reset_pref", 1); + Preferences.reset("test_reset_pref"); + do_check_eq(Preferences.get("test_reset_pref"), undefined); + + run_next_test(); +}); + +add_test(function test_reset_pref_branch() { + Preferences.set("test_reset_pref_branch.foo", 1); + Preferences.set("test_reset_pref_branch.bar", 2); + Preferences.resetBranch("test_reset_pref_branch."); + do_check_eq(Preferences.get("test_reset_pref_branch.foo"), undefined); + do_check_eq(Preferences.get("test_reset_pref_branch.bar"), undefined); + + run_next_test(); +}); + +// Make sure the module doesn't throw an exception when asked to reset +// a nonexistent pref. +add_test(function test_reset_nonexistent_pref() { + Preferences.reset("test_reset_nonexistent_pref"); + + run_next_test(); +}); + +// Make sure the module doesn't throw an exception when asked to reset +// a nonexistent pref branch. +add_test(function test_reset_nonexistent_pref_branch() { + Preferences.resetBranch("test_reset_nonexistent_pref_branch."); + + run_next_test(); +}); + +add_test(function test_observe_prefs_function() { + let observed = false; + let observer = function() { observed = !observed }; + + Preferences.observe("test_observe_prefs_function", observer); + Preferences.set("test_observe_prefs_function", "something"); + do_check_true(observed); + + Preferences.ignore("test_observe_prefs_function", observer); + Preferences.set("test_observe_prefs_function", "something else"); + do_check_true(observed); + + // Clean up. + Preferences.reset("test_observe_prefs_function"); + + run_next_test(); +}); + +add_test(function test_observe_prefs_object() { + let observer = { + observed: false, + observe: function() { + this.observed = !this.observed; + } + }; + + Preferences.observe("test_observe_prefs_object", observer.observe, observer); + Preferences.set("test_observe_prefs_object", "something"); + do_check_true(observer.observed); + + Preferences.ignore("test_observe_prefs_object", observer.observe, observer); + Preferences.set("test_observe_prefs_object", "something else"); + do_check_true(observer.observed); + + // Clean up. + Preferences.reset("test_observe_prefs_object"); + + run_next_test(); +}); + +add_test(function test_observe_prefs_nsIObserver() { + let observer = { + observed: false, + observe: function(subject, topic, data) { + this.observed = !this.observed; + do_check_true(subject instanceof Ci.nsIPrefBranch); + do_check_eq(topic, "nsPref:changed"); + do_check_eq(data, "test_observe_prefs_nsIObserver"); + } + }; + + Preferences.observe("test_observe_prefs_nsIObserver", observer); + Preferences.set("test_observe_prefs_nsIObserver", "something"); + do_check_true(observer.observed); + + Preferences.ignore("test_observe_prefs_nsIObserver", observer); + Preferences.set("test_observe_prefs_nsIObserver", "something else"); + do_check_true(observer.observed); + + // Clean up. + Preferences.reset("test_observe_prefs_nsIObserver"); + + run_next_test(); +}); + +/* +add_test(function test_observe_exact_pref() { + let observed = false; + let observer = function() { observed = !observed }; + + Preferences.observe("test_observe_exact_pref", observer); + Preferences.set("test_observe_exact_pref.sub-pref", "something"); + do_check_false(observed); + + // Clean up. + Preferences.ignore("test_observe_exact_pref", observer); + Preferences.reset("test_observe_exact_pref.sub-pref"); + + run_next_test(); +}); +*/ + +add_test(function test_observe_value_of_set_pref() { + let observer = function(newVal) { do_check_eq(newVal, "something") }; + + Preferences.observe("test_observe_value_of_set_pref", observer); + Preferences.set("test_observe_value_of_set_pref", "something"); + + // Clean up. + Preferences.ignore("test_observe_value_of_set_pref", observer); + Preferences.reset("test_observe_value_of_set_pref"); + + run_next_test(); +}); + +add_test(function test_observe_value_of_reset_pref() { + let observer = function(newVal) { do_check_true(typeof newVal == "undefined") }; + + Preferences.set("test_observe_value_of_reset_pref", "something"); + Preferences.observe("test_observe_value_of_reset_pref", observer); + Preferences.reset("test_observe_value_of_reset_pref"); + + // Clean up. + Preferences.ignore("test_observe_value_of_reset_pref", observer); + + run_next_test(); +}); + +add_test(function test_has_pref() { + do_check_false(Preferences.has("test_has_pref")); + Preferences.set("test_has_pref", "foo"); + do_check_true(Preferences.has("test_has_pref")); + + Preferences.set("test_has_pref.foo", "foo"); + Preferences.set("test_has_pref.bar", "bar"); + let [hasFoo, hasBar, hasBaz] = Preferences.has(["test_has_pref.foo", + "test_has_pref.bar", + "test_has_pref.baz"]); + do_check_true(hasFoo); + do_check_true(hasBar); + do_check_false(hasBaz); + + // Clean up. + Preferences.resetBranch("test_has_pref"); + + run_next_test(); +}); + +add_test(function test_isSet_pref() { + // Use a pref that we know has a default value but no user-set value. + // This feels dangerous; perhaps we should create some other default prefs + // that we can use for testing. + do_check_false(Preferences.isSet("toolkit.defaultChromeURI")); + Preferences.set("toolkit.defaultChromeURI", "foo"); + do_check_true(Preferences.isSet("toolkit.defaultChromeURI")); + + // Clean up. + Preferences.reset("toolkit.defaultChromeURI"); + + run_next_test(); +}); + +/* +add_test(function test_lock_prefs() { + // Use a pref that we know has a default value. + // This feels dangerous; perhaps we should create some other default prefs + // that we can use for testing. + do_check_false(Preferences.locked("toolkit.defaultChromeURI")); + Preferences.lock("toolkit.defaultChromeURI"); + do_check_true(Preferences.locked("toolkit.defaultChromeURI")); + Preferences.unlock("toolkit.defaultChromeURI"); + do_check_false(Preferences.locked("toolkit.defaultChromeURI")); + + let val = Preferences.get("toolkit.defaultChromeURI"); + Preferences.set("toolkit.defaultChromeURI", "test_lock_prefs"); + do_check_eq(Preferences.get("toolkit.defaultChromeURI"), "test_lock_prefs"); + Preferences.lock("toolkit.defaultChromeURI"); + do_check_eq(Preferences.get("toolkit.defaultChromeURI"), val); + Preferences.unlock("toolkit.defaultChromeURI"); + do_check_eq(Preferences.get("toolkit.defaultChromeURI"), "test_lock_prefs"); + + // Clean up. + Preferences.reset("toolkit.defaultChromeURI"); + + run_next_test(); +}); +*/ diff --git a/toolkit/modules/tests/xpcshell/test_Promise.js b/toolkit/modules/tests/xpcshell/test_Promise.js new file mode 100644 index 0000000000..6c72206922 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_Promise.js @@ -0,0 +1,1105 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +Components.utils.import("resource://gre/modules/Promise.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Task.jsm"); +Components.utils.import("resource://testing-common/PromiseTestUtils.jsm"); + +// Prevent test failures due to the unhandled rejections in this test file. +PromiseTestUtils.disableUncaughtRejectionObserverForSelfTest(); + +// Test runner + +var run_promise_tests = function run_promise_tests(tests, cb) { + let loop = function loop(index) { + if (index >= tests.length) { + if (cb) { + cb.call(); + } + return; + } + do_print("Launching test " + (index + 1) + "/" + tests.length); + let test = tests[index]; + // Execute from an empty stack + let next = function next() { + do_print("Test " + (index + 1) + "/" + tests.length + " complete"); + do_execute_soon(function() { + loop(index + 1); + }); + }; + let result = test(); + result.then(next, next); + }; + return loop(0); +}; + +var make_promise_test = function(test) { + return function runtest() { + do_print("Test starting: " + test.name); + try { + let result = test(); + if (result && "promise" in result) { + result = result.promise; + } + if (!result || !("then" in result)) { + let exn; + try { + do_throw("Test " + test.name + " did not return a promise: " + result); + } catch (x) { + exn = x; + } + return Promise.reject(exn); + } + // The test returns a promise + result = result.then( + // Test complete + function onResolve() { + do_print("Test complete: " + test.name); + }, + // The test failed with an unexpected error + function onReject(err) { + let detail; + if (err && typeof err == "object" && "stack" in err) { + detail = err.stack; + } else { + detail = "(no stack)"; + } + do_throw("Test " + test.name + " rejected with the following reason: " + + err + detail); + }); + return result; + } catch (x) { + // The test failed because of an error outside of a promise + do_throw("Error in body of test " + test.name + ": " + x + " at " + x.stack); + return Promise.reject(); + } + }; +}; + +// Tests + +var tests = []; + +// Utility function to observe an failures in a promise +// This function is useful if the promise itself is +// not returned. +var observe_failures = function observe_failures(promise) { + promise.catch(function onReject(reason) { + test.do_throw("Observed failure in test " + test + ": " + reason); + }); +}; + +// Test that all observers are notified +tests.push(make_promise_test( + function notification(test) { + // The size of the test + const SIZE = 10; + const RESULT = "this is an arbitrary value"; + + // Number of observers that yet need to be notified + let expected = SIZE; + + // |true| once an observer has been notified + let notified = []; + + // The promise observed + let source = Promise.defer(); + let result = Promise.defer(); + + let install_observer = function install_observer(i) { + observe_failures(source.promise.then( + function onSuccess(value) { + do_check_true(!notified[i], "Ensuring that observer is notified at most once"); + notified[i] = true; + + do_check_eq(value, RESULT, "Ensuring that the observed value is correct"); + if (--expected == 0) { + result.resolve(); + } + })); + }; + + // Install a number of observers before resolving + let i; + for (i = 0; i < SIZE/2; ++i) { + install_observer(i); + } + + source.resolve(RESULT); + + // Install remaining observers + for (;i < SIZE; ++i) { + install_observer(i); + } + + return result; + })); + +// Test that observers get the correct "this" value in strict mode. +tests.push( + make_promise_test(function handlers_this_value(test) { + return Promise.resolve().then( + function onResolve() { + // Since this file is in strict mode, the correct value is "undefined". + do_check_eq(this, undefined); + throw "reject"; + } + ).then( + null, + function onReject() { + // Since this file is in strict mode, the correct value is "undefined". + do_check_eq(this, undefined); + } + ); + })); + +// Test that observers registered on a pending promise are notified in order. +tests.push( + make_promise_test(function then_returns_before_callbacks(test) { + let deferred = Promise.defer(); + let promise = deferred.promise; + + let order = 0; + + promise.then( + function onResolve() { + do_check_eq(order, 0); + order++; + } + ); + + promise.then( + function onResolve() { + do_check_eq(order, 1); + order++; + } + ); + + let newPromise = promise.then( + function onResolve() { + do_check_eq(order, 2); + } + ); + + deferred.resolve(); + + // This test finishes after the last handler succeeds. + return newPromise; + })); + +// Test that observers registered on a resolved promise are notified in order. +tests.push( + make_promise_test(function then_returns_before_callbacks(test) { + let promise = Promise.resolve(); + + let order = 0; + + promise.then( + function onResolve() { + do_check_eq(order, 0); + order++; + } + ); + + promise.then( + function onResolve() { + do_check_eq(order, 1); + order++; + } + ); + + // This test finishes after the last handler succeeds. + return promise.then( + function onResolve() { + do_check_eq(order, 2); + } + ); + })); + +// Test that all observers are notified at most once, even if source +// is resolved/rejected several times +tests.push(make_promise_test( + function notification_once(test) { + // The size of the test + const SIZE = 10; + const RESULT = "this is an arbitrary value"; + + // Number of observers that yet need to be notified + let expected = SIZE; + + // |true| once an observer has been notified + let notified = []; + + // The promise observed + let observed = Promise.defer(); + let result = Promise.defer(); + + let install_observer = function install_observer(i) { + observe_failures(observed.promise.then( + function onSuccess(value) { + do_check_true(!notified[i], "Ensuring that observer is notified at most once"); + notified[i] = true; + + do_check_eq(value, RESULT, "Ensuring that the observed value is correct"); + if (--expected == 0) { + result.resolve(); + } + })); + }; + + // Install a number of observers before resolving + let i; + for (i = 0; i < SIZE/2; ++i) { + install_observer(i); + } + + observed.resolve(RESULT); + + // Install remaining observers + for (;i < SIZE; ++i) { + install_observer(i); + } + + // Resolve some more + for (i = 0; i < 10; ++i) { + observed.resolve(RESULT); + observed.reject(); + } + + return result; + })); + +// Test that throwing an exception from a onResolve listener +// does not prevent other observers from receiving the notification +// of success. +tests.push( + make_promise_test(function exceptions_do_not_stop_notifications(test) { + let source = Promise.defer(); + + let exception_thrown = false; + let exception_content = new Error("Boom!"); + + let observer_1 = source.promise.then( + function onResolve() { + exception_thrown = true; + throw exception_content; + }); + + let observer_2 = source.promise.then( + function onResolve() { + do_check_true(exception_thrown, "Second observer called after first observer has thrown"); + } + ); + + let result = observer_1.then( + function onResolve() { + do_throw("observer_1 should not have resolved"); + }, + function onReject(reason) { + do_check_true(reason == exception_content, "Obtained correct rejection"); + } + ); + + source.resolve(); + return result; + } +)); + +// Test that, once a promise is resolved, further resolve/reject +// are ignored. +tests.push( + make_promise_test(function subsequent_resolves_are_ignored(test) { + let deferred = Promise.defer(); + deferred.resolve(1); + deferred.resolve(2); + deferred.reject(3); + + let result = deferred.promise.then( + function onResolve(value) { + do_check_eq(value, 1, "Resolution chose the first value"); + }, + function onReject(reason) { + do_throw("Obtained a rejection while the promise was already resolved"); + } + ); + + return result; + })); + +// Test that, once a promise is rejected, further resolve/reject +// are ignored. +tests.push( + make_promise_test(function subsequent_rejects_are_ignored(test) { + let deferred = Promise.defer(); + deferred.reject(1); + deferred.reject(2); + deferred.resolve(3); + + let result = deferred.promise.then( + function onResolve() { + do_throw("Obtained a resolution while the promise was already rejected"); + }, + function onReject(reason) { + do_check_eq(reason, 1, "Rejection chose the first value"); + } + ); + + return result; + })); + +// Test that returning normally from a rejection recovers from the error +// and that listeners are informed of a success. +tests.push( + make_promise_test(function recovery(test) { + let boom = new Error("Boom!"); + let deferred = Promise.defer(); + const RESULT = "An arbitrary value"; + + let promise = deferred.promise.then( + function onResolve() { + do_throw("A rejected promise should not resolve"); + }, + function onReject(reason) { + do_check_true(reason == boom, "Promise was rejected with the correct error"); + return RESULT; + } + ); + + promise = promise.then( + function onResolve(value) { + do_check_eq(value, RESULT, "Promise was recovered with the correct value"); + } + ); + + deferred.reject(boom); + return promise; + })); + +// Test that returning a resolved promise from a onReject causes a resolution +// (recovering from the error) and that returning a rejected promise +// from a onResolve listener causes a rejection (raising an error). +tests.push( + make_promise_test(function recovery_with_promise(test) { + let boom = new Error("Arbitrary error"); + let deferred = Promise.defer(); + const RESULT = "An arbitrary value"; + const boom2 = new Error("Another arbitrary error"); + + // return a resolved promise from a onReject listener + let promise = deferred.promise.then( + function onResolve() { + do_throw("A rejected promise should not resolve"); + }, + function onReject(reason) { + do_check_true(reason == boom, "Promise was rejected with the correct error"); + return Promise.resolve(RESULT); + } + ); + + // return a rejected promise from a onResolve listener + promise = promise.then( + function onResolve(value) { + do_check_eq(value, RESULT, "Promise was recovered with the correct value"); + return Promise.reject(boom2); + } + ); + + promise = promise.catch( + function onReject(reason) { + do_check_eq(reason, boom2, "Rejection was propagated with the correct " + + "reason, through a promise"); + } + ); + + deferred.reject(boom); + return promise; + })); + +// Test that we can resolve with promises of promises +tests.push( + make_promise_test(function test_propagation(test) { + const RESULT = "Yet another arbitrary value"; + let d1 = Promise.defer(); + let d2 = Promise.defer(); + let d3 = Promise.defer(); + + d3.resolve(d2.promise); + d2.resolve(d1.promise); + d1.resolve(RESULT); + + return d3.promise.then( + function onSuccess(value) { + do_check_eq(value, RESULT, "Resolution with a promise eventually yielded " + + " the correct result"); + } + ); + })); + +// Test sequences of |then| and |catch| +tests.push( + make_promise_test(function test_chaining(test) { + let error_1 = new Error("Error 1"); + let error_2 = new Error("Error 2"); + let result_1 = "First result"; + let result_2 = "Second result"; + let result_3 = "Third result"; + + let source = Promise.defer(); + + let promise = source.promise.then().then(); + + source.resolve(result_1); + + // Check that result_1 is correctly propagated + promise = promise.then( + function onSuccess(result) { + do_check_eq(result, result_1, "Result was propagated correctly through " + + " several applications of |then|"); + return result_2; + } + ); + + // Check that returning from the promise produces a resolution + promise = promise.catch( + function onReject() { + do_throw("Incorrect rejection"); + } + ); + + // ... and that the check did not alter the value + promise = promise.then( + function onResolve(value) { + do_check_eq(value, result_2, "Result was propagated correctly once again"); + } + ); + + // Now the same kind of tests for rejections + promise = promise.then( + function onResolve() { + throw error_1; + } + ); + + promise = promise.then( + function onResolve() { + do_throw("Incorrect resolution: the exception should have caused a rejection"); + } + ); + + promise = promise.catch( + function onReject(reason) { + do_check_true(reason == error_1, "Reason was propagated correctly"); + throw error_2; + } + ); + + promise = promise.catch( + function onReject(reason) { + do_check_true(reason == error_2, "Throwing an error altered the reason " + + "as expected"); + return result_3; + } + ); + + promise = promise.then( + function onResolve(result) { + do_check_eq(result, result_3, "Error was correctly recovered"); + } + ); + + return promise; + })); + +// Test that resolving with a rejected promise actually rejects +tests.push( + make_promise_test(function resolve_to_rejected(test) { + let source = Promise.defer(); + let error = new Error("Boom"); + + let promise = source.promise.then( + function onResolve() { + do_throw("Incorrect call to onResolve listener"); + }, + function onReject(reason) { + do_check_eq(reason, error, "Rejection lead to the expected reason"); + } + ); + + source.resolve(Promise.reject(error)); + + return promise; + })); + +// Test that Promise.resolve resolves as expected +tests.push( + make_promise_test(function test_resolve(test) { + const RESULT = "arbitrary value"; + let p1 = Promise.resolve(RESULT); + let p2 = Promise.resolve(p1); + do_check_eq(p1, p2, "Promise.resolve used on a promise just returns the promise"); + + return p1.then( + function onResolve(result) { + do_check_eq(result, RESULT, "Promise.resolve propagated the correct result"); + } + ); + })); + +// Test that Promise.resolve throws when its argument is an async function. +tests.push( + make_promise_test(function test_promise_resolve_throws_with_async_function(test) { + Assert.throws(() => Promise.resolve(Task.async(function* () {})), + /Cannot resolve a promise with an async function/); + return Promise.resolve(); + })); + +// Test that the code after "then" is always executed before the callbacks +tests.push( + make_promise_test(function then_returns_before_callbacks(test) { + let promise = Promise.resolve(); + + let thenExecuted = false; + + promise = promise.then( + function onResolve() { + thenExecuted = true; + } + ); + + do_check_false(thenExecuted); + + return promise; + })); + +// Test that chaining promises does not generate long stack traces +tests.push( + make_promise_test(function chaining_short_stack(test) { + let source = Promise.defer(); + let promise = source.promise; + + const NUM_ITERATIONS = 100; + + for (let i = 0; i < NUM_ITERATIONS; i++) { + promise = promise.then( + function onResolve(result) { + return result + "."; + } + ); + } + + promise = promise.then( + function onResolve(result) { + // Check that the execution went as expected. + let expectedString = new Array(1 + NUM_ITERATIONS).join("."); + do_check_true(result == expectedString); + + // Check that we didn't generate one or more stack frames per iteration. + let stackFrameCount = 0; + let stackFrame = Components.stack; + while (stackFrame) { + stackFrameCount++; + stackFrame = stackFrame.caller; + } + + do_check_true(stackFrameCount < NUM_ITERATIONS); + } + ); + + source.resolve(""); + + return promise; + })); + +// Test that the values of the promise return by Promise.all() are kept in the +// given order even if the given promises are resolved in arbitrary order +tests.push( + make_promise_test(function all_resolve(test) { + let d1 = Promise.defer(); + let d2 = Promise.defer(); + let d3 = Promise.defer(); + + d3.resolve(4); + d2.resolve(2); + do_execute_soon(() => d1.resolve(1)); + + let promises = [d1.promise, d2.promise, 3, d3.promise]; + + return Promise.all(promises).then( + function onResolve([val1, val2, val3, val4]) { + do_check_eq(val1, 1); + do_check_eq(val2, 2); + do_check_eq(val3, 3); + do_check_eq(val4, 4); + } + ); + })); + +// Test that rejecting one of the promises passed to Promise.all() +// rejects the promise return by Promise.all() +tests.push( + make_promise_test(function all_reject(test) { + let error = new Error("Boom"); + + let d1 = Promise.defer(); + let d2 = Promise.defer(); + let d3 = Promise.defer(); + + d3.resolve(3); + d2.resolve(2); + do_execute_soon(() => d1.reject(error)); + + let promises = [d1.promise, d2.promise, d3.promise]; + + return Promise.all(promises).then( + function onResolve() { + do_throw("Incorrect call to onResolve listener"); + }, + function onReject(reason) { + do_check_eq(reason, error, "Rejection lead to the expected reason"); + } + ); + })); + +// Test that passing only values (not promises) to Promise.all() +// forwards them all as resolution values. +tests.push( + make_promise_test(function all_resolve_no_promises(test) { + try { + Promise.all(null); + do_check_true(false, "all() should only accept iterables"); + } catch (e) { + do_check_true(true, "all() fails when first the arg is not an iterable"); + } + + let p1 = Promise.all([]).then( + function onResolve(val) { + do_check_true(Array.isArray(val) && val.length == 0); + } + ); + + let p2 = Promise.all([1, 2, 3]).then( + function onResolve([val1, val2, val3]) { + do_check_eq(val1, 1); + do_check_eq(val2, 2); + do_check_eq(val3, 3); + } + ); + + return Promise.all([p1, p2]); + })); + +// Test that Promise.all() handles non-array iterables +tests.push( + make_promise_test(function all_iterable(test) { + function* iterable() { + yield 1; + yield 2; + yield 3; + } + + return Promise.all(iterable()).then( + function onResolve([val1, val2, val3]) { + do_check_eq(val1, 1); + do_check_eq(val2, 2); + do_check_eq(val3, 3); + }, + function onReject() { + do_throw("all() unexpectedly rejected"); + } + ); + })); + +// Test that throwing from the iterable passed to Promise.all() rejects the +// promise returned by Promise.all() +tests.push( + make_promise_test(function all_iterable_throws(test) { + function* iterable() { + throw 1; + } + + return Promise.all(iterable()).then( + function onResolve() { + do_throw("all() unexpectedly resolved"); + }, + function onReject(reason) { + do_check_eq(reason, 1, "all() rejects when the iterator throws"); + } + ); + })); + +// Test that Promise.race() resolves with the first available resolution value +tests.push( + make_promise_test(function race_resolve(test) { + let p1 = Promise.resolve(1); + let p2 = Promise.resolve().then(() => 2); + + return Promise.race([p1, p2]).then( + function onResolve(value) { + do_check_eq(value, 1); + } + ); + })); + +// Test that passing only values (not promises) to Promise.race() works +tests.push( + make_promise_test(function race_resolve_no_promises(test) { + try { + Promise.race(null); + do_check_true(false, "race() should only accept iterables"); + } catch (e) { + do_check_true(true, "race() fails when first the arg is not an iterable"); + } + + return Promise.race([1, 2, 3]).then( + function onResolve(value) { + do_check_eq(value, 1); + } + ); + })); + +// Test that Promise.race() never resolves when passed an empty iterable +tests.push( + make_promise_test(function race_resolve_never(test) { + return new Promise(resolve => { + Promise.race([]).then( + function onResolve() { + do_throw("race() unexpectedly resolved"); + }, + function onReject() { + do_throw("race() unexpectedly rejected"); + } + ); + + // Approximate "never" so we don't have to solve the halting problem. + do_timeout(200, resolve); + }); + })); + +// Test that Promise.race() handles non-array iterables. +tests.push( + make_promise_test(function race_iterable(test) { + function* iterable() { + yield 1; + yield 2; + yield 3; + } + + return Promise.race(iterable()).then( + function onResolve(value) { + do_check_eq(value, 1); + }, + function onReject() { + do_throw("race() unexpectedly rejected"); + } + ); + })); + +// Test that throwing from the iterable passed to Promise.race() rejects the +// promise returned by Promise.race() +tests.push( + make_promise_test(function race_iterable_throws(test) { + function* iterable() { + throw 1; + } + + return Promise.race(iterable()).then( + function onResolve() { + do_throw("race() unexpectedly resolved"); + }, + function onReject(reason) { + do_check_eq(reason, 1, "race() rejects when the iterator throws"); + } + ); + })); + +// Test that rejecting one of the promises passed to Promise.race() rejects the +// promise returned by Promise.race() +tests.push( + make_promise_test(function race_reject(test) { + let p1 = Promise.reject(1); + let p2 = Promise.resolve(2); + let p3 = Promise.resolve(3); + + return Promise.race([p1, p2, p3]).then( + function onResolve() { + do_throw("race() unexpectedly resolved"); + }, + function onReject(reason) { + do_check_eq(reason, 1, "race() rejects when given a rejected promise"); + } + ); + })); + +// Test behavior of the Promise constructor. +tests.push( + make_promise_test(function test_constructor(test) { + try { + new Promise(null); + do_check_true(false, "Constructor should fail when not passed a function"); + } catch (e) { + do_check_true(true, "Constructor fails when not passed a function"); + } + + let executorRan = false; + let promise = new Promise( + function executor(resolve, reject) { + executorRan = true; + do_check_eq(this, undefined); + do_check_eq(typeof resolve, "function", + "resolve function should be passed to the executor"); + do_check_eq(typeof reject, "function", + "reject function should be passed to the executor"); + } + ); + do_check_instanceof(promise, Promise); + do_check_true(executorRan, "Executor should execute synchronously"); + + // resolve a promise from the executor + let resolvePromise = new Promise( + function executor(resolve) { + resolve(1); + } + ).then( + function onResolve(value) { + do_check_eq(value, 1, "Executor resolved with correct value"); + }, + function onReject() { + do_throw("Executor unexpectedly rejected"); + } + ); + + // reject a promise from the executor + let rejectPromise = new Promise( + function executor(_, reject) { + reject(1); + } + ).then( + function onResolve() { + do_throw("Executor unexpectedly resolved"); + }, + function onReject(reason) { + do_check_eq(reason, 1, "Executor rejected with correct value"); + } + ); + + // throw from the executor, causing a rejection + let throwPromise = new Promise( + function executor() { + throw 1; + } + ).then( + function onResolve() { + do_throw("Throwing inside an executor should not resolve the promise"); + }, + function onReject(reason) { + do_check_eq(reason, 1, "Executor rejected with correct value"); + } + ); + + return Promise.all([resolvePromise, rejectPromise, throwPromise]); + })); + +// Test deadlock in Promise.jsm with nested event loops +// The scenario being tested is: +// promise_1.then({ +// do some work that will asynchronously signal done +// start an event loop waiting for the done signal +// } +// where the async work uses resolution of a second promise to +// trigger the "done" signal. While this would likely work in a +// naive implementation, our constant-stack implementation needs +// a special case to avoid deadlock. Note that this test is +// sensitive to the implementation-dependent order in which then() +// clauses for two different promises are executed, so it is +// possible for other implementations to pass this test and still +// have similar deadlocks. +tests.push( + make_promise_test(function promise_nested_eventloop_deadlock(test) { + // Set up a (long enough to be noticeable) timeout to + // exit the nested event loop and throw if the test run is hung + let shouldExitNestedEventLoop = false; + + function event_loop() { + let thr = Services.tm.mainThread; + while (!shouldExitNestedEventLoop) { + thr.processNextEvent(true); + } + } + + // I wish there was a way to cancel xpcshell do_timeout()s + do_timeout(2000, () => { + if (!shouldExitNestedEventLoop) { + shouldExitNestedEventLoop = true; + do_throw("Test timed out"); + } + }); + + let promise1 = Promise.resolve(1); + let promise2 = Promise.resolve(2); + + do_print("Setting wait for first promise"); + promise1.then(value => { + do_print("Starting event loop"); + event_loop(); + }, null); + + do_print("Setting wait for second promise"); + return promise2.catch(error => { return 3; }) + .then( + count => { + shouldExitNestedEventLoop = true; + }); + })); + +function wait_for_uncaught(aMustAppear, aTimeout = undefined) { + let remaining = new Set(); + for (let k of aMustAppear) { + remaining.add(k); + } + let deferred = Promise.defer(); + let print = do_print; + let execute_soon = do_execute_soon; + let observer = function({message, stack}) { + let data = message + stack; + print("Observing " + message + ", looking for " + aMustAppear.join(", ")); + for (let expected of remaining) { + if (data.indexOf(expected) != -1) { + print("I found " + expected); + remaining.delete(expected); + } + if (remaining.size == 0 && observer) { + Promise.Debugging.removeUncaughtErrorObserver(observer); + observer = null; + deferred.resolve(); + } + } + }; + Promise.Debugging.addUncaughtErrorObserver(observer); + if (aTimeout) { + do_timeout(aTimeout, function timeout() { + if (observer) { + Promise.Debugging.removeUncaughtErrorObserver(observer); + observer = null; + } + deferred.reject(new Error("Timeout")); + }); + } + return deferred.promise; +} + +// Test that uncaught errors are reported as uncaught +(function() { + let make_string_rejection = function make_string_rejection() { + let salt = (Math.random() * ( Math.pow(2, 24) - 1 )); + let string = "This is an uncaught rejection " + salt; + // Our error is not Error-like nor an nsIException, so the stack will + // include the closure doing the actual rejection. + return {mustFind: ["test_rejection_closure", string], error: string}; + }; + let make_num_rejection = function make_num_rejection() { + let salt = (Math.random() * ( Math.pow(2, 24) - 1 )); + // Our error is not Error-like nor an nsIException, so the stack will + // include the closure doing the actual rejection. + return {mustFind: ["test_rejection_closure", salt], error: salt}; + }; + let make_undefined_rejection = function make_undefined_rejection() { + // Our error is not Error-like nor an nsIException, so the stack will + // include the closure doing the actual rejection. + return {mustFind: ["test_rejection_closure"], error: undefined}; + }; + let make_error_rejection = function make_error_rejection() { + let salt = (Math.random() * ( Math.pow(2, 24) - 1 )); + let error = new Error("This is an uncaught error " + salt); + return { + mustFind: [error.message, error.fileName, error.lineNumber, error.stack], + error: error + }; + }; + let make_exception_rejection = function make_exception_rejection() { + let salt = (Math.random() * ( Math.pow(2, 24) - 1 )); + let exn = new Components.Exception("This is an uncaught exception " + salt, + Components.results.NS_ERROR_NOT_AVAILABLE); + return { + mustFind: [exn.message, exn.filename, exn.lineNumber, exn.location.toString()], + error: exn + }; + }; + for (let make_rejection of [make_string_rejection, + make_num_rejection, + make_undefined_rejection, + make_error_rejection, + make_exception_rejection]) { + let {mustFind, error} = make_rejection(); + let name = make_rejection.name; + tests.push(make_promise_test(function test_uncaught_is_reported() { + do_print("Testing with rejection " + name); + let promise = wait_for_uncaught(mustFind); + (function test_rejection_closure() { + // For the moment, we cannot be absolutely certain that a value is + // garbage-collected, even if it is not referenced anymore, due to + // the conservative stack-scanning algorithm. + // + // To be _almost_ certain that a value will be garbage-collected, we + // 1. isolate that value in an anonymous closure; + // 2. allocate 100 values instead of 1 (gc-ing a single value from + // these is sufficient for the test); + // 3. place everything in a loop, as the JIT typically reuses memory; + // 4. call all the GC methods we can. + // + // Unfortunately, we might still have intermittent failures, + // materialized as timeouts. + // + for (let i = 0; i < 100; ++i) { + Promise.reject(error); + } + })(); + do_print("Posted all rejections"); + Components.utils.forceGC(); + Components.utils.forceCC(); + Components.utils.forceShrinkingGC(); + return promise; + })); + } +})(); + + +// Test that caught errors are not reported as uncaught +tests.push( +make_promise_test(function test_caught_is_not_reported() { + let salt = (Math.random() * ( Math.pow(2, 24) - 1 )); + let promise = wait_for_uncaught([salt], 500); + (function() { + let uncaught = Promise.reject("This error, on the other hand, is caught " + salt); + uncaught.catch(function() { /* ignore rejection */ }); + uncaught = null; + })(); + // Isolate this in a function to increase likelihood that the gc will + // realise that |uncaught| has remained uncaught. + Components.utils.forceGC(); + + return promise.then(function onSuccess() { + throw new Error("This error was caught and should not have been reported"); + }, function onError() { + do_print("The caught error was not reported, all is fine"); + } + ); +})); + +// Bug 1033406 - Make sure Promise works even after freezing. +tests.push( + make_promise_test(function test_freezing_promise(test) { + var p = new Promise(function executor(resolve) { + do_execute_soon(resolve); + }); + Object.freeze(p); + return p; + }) +); + +function run_test() +{ + do_test_pending(); + run_promise_tests(tests, do_test_finished); +} diff --git a/toolkit/modules/tests/xpcshell/test_PromiseUtils.js b/toolkit/modules/tests/xpcshell/test_PromiseUtils.js new file mode 100644 index 0000000000..c3ab839e4b --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_PromiseUtils.js @@ -0,0 +1,105 @@ + /* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Components.utils.import("resource://gre/modules/PromiseUtils.jsm"); +Components.utils.import("resource://gre/modules/Timer.jsm"); +Components.utils.import("resource://testing-common/PromiseTestUtils.jsm"); + +// Tests for PromiseUtils.jsm +function run_test() { + run_next_test(); +} + +// Tests for PromiseUtils.defer() + +/* Tests for checking the resolve method of the Deferred object + * returned by PromiseUtils.defer() */ +add_task(function* test_resolve_string() { + let def = PromiseUtils.defer(); + let expected = "The promise is resolved " + Math.random(); + def.resolve(expected); + let result = yield def.promise; + Assert.equal(result, expected, "def.resolve() resolves the promise"); +}); + +/* Test for the case when undefined is passed to the resolve method + * of the Deferred object */ +add_task(function* test_resolve_undefined() { + let def = PromiseUtils.defer(); + def.resolve(); + let result = yield def.promise; + Assert.equal(result, undefined, "resolve works with undefined as well"); +}); + +/* Test when a pending promise is passed to the resolve method + * of the Deferred object */ +add_task(function* test_resolve_pending_promise() { + let def = PromiseUtils.defer(); + let expected = 100 + Math.random(); + let p = new Promise((resolve, reject) => { + setTimeout(() => resolve(expected), 100); + }); + def.resolve(p); + let result = yield def.promise; + Assert.equal(result, expected, "def.promise assumed the state of the passed promise"); +}); + +/* Test when a resovled promise is passed + * to the resolve method of the Deferred object */ +add_task(function* test_resolve_resolved_promise() { + let def = PromiseUtils.defer(); + let expected = "Yeah resolved" + Math.random(); + let p = new Promise((resolve, reject) => resolve(expected)); + def.resolve(p); + let result = yield def.promise; + Assert.equal(result, expected, "Resolved promise is passed to the resolve method"); +}); + +/* Test for the case when a rejected promise is + * passed to the resolve method */ +add_task(function* test_resolve_rejected_promise() { + let def = PromiseUtils.defer(); + let p = new Promise((resolve, reject) => reject(new Error("There its an rejection"))); + def.resolve(p); + yield Assert.rejects(def.promise, /There its an rejection/, "Settled rejection promise passed to the resolve method"); +}); + +/* Test for the checking the reject method of + * the Deferred object returned by PromiseUtils.defer() */ +add_task(function* test_reject_Error() { + let def = PromiseUtils.defer(); + def.reject(new Error("This one rejects")); + yield Assert.rejects(def.promise, /This one rejects/, "reject method with Error for rejection"); +}); + +/* Test for the case when a pending Promise is passed to + * the reject method of Deferred object */ +add_task(function* test_reject_pending_promise() { + let def = PromiseUtils.defer(); + let p = new Promise((resolve, reject) => { + setTimeout(() => resolve(100), 100); + }); + def.reject(p); + yield Assert.rejects(def.promise, Promise, "Rejection with a pending promise uses the passed promise itself as the reason of rejection"); +}); + +/* Test for the case when a resolved Promise + * is passed to the reject method */ +add_task(function* test_reject_resolved_promise() { + let def = PromiseUtils.defer(); + let p = new Promise((resolve, reject) => resolve("This resolved")); + def.reject(p); + yield Assert.rejects(def.promise, Promise, "Rejection with a resolved promise uses the passed promise itself as the reason of rejection"); +}); + +/* Test for the case when a rejected Promise is + * passed to the reject method */ +add_task(function* test_reject_resolved_promise() { + PromiseTestUtils.expectUncaughtRejection(/This one rejects/); + let def = PromiseUtils.defer(); + let p = new Promise((resolve, reject) => reject(new Error("This one rejects"))); + def.reject(p); + yield Assert.rejects(def.promise, Promise, "Rejection with a rejected promise uses the passed promise itself as the reason of rejection"); +}); diff --git a/toolkit/modules/tests/xpcshell/test_Services.js b/toolkit/modules/tests/xpcshell/test_Services.js new file mode 100644 index 0000000000..a50ecca3da --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_Services.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests the Services.jsm module. + */ + +// Globals + +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; +var Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +function checkService(service, interface) { + do_print("Checking that Services." + service + " is an " + interface); + do_check_true(service in Services); + do_check_true(Services[service] instanceof interface); +} + +// Tests + +function run_test() +{ + do_get_profile(); + + checkService("appShell", Ci.nsIAppShellService); + checkService("appinfo", Ci.nsIXULRuntime); + checkService("blocklist", Ci.nsIBlocklistService); + checkService("cache", Ci.nsICacheService); + checkService("cache2", Ci.nsICacheStorageService); + checkService("clipboard", Ci.nsIClipboard); + checkService("console", Ci.nsIConsoleService); + checkService("contentPrefs", Ci.nsIContentPrefService); + checkService("cookies", Ci.nsICookieManager2); + checkService("dirsvc", Ci.nsIDirectoryService); + checkService("dirsvc", Ci.nsIProperties); + checkService("DOMRequest", Ci.nsIDOMRequestService); + checkService("domStorageManager", Ci.nsIDOMStorageManager); + checkService("downloads", Ci.nsIDownloadManager); + checkService("droppedLinkHandler", Ci.nsIDroppedLinkHandler); + checkService("eTLD", Ci.nsIEffectiveTLDService); + checkService("focus", Ci.nsIFocusManager); + checkService("io", Ci.nsIIOService); + checkService("io", Ci.nsIIOService2); + checkService("locale", Ci.nsILocaleService); + checkService("logins", Ci.nsILoginManager); + checkService("obs", Ci.nsIObserverService); + checkService("perms", Ci.nsIPermissionManager); + checkService("prefs", Ci.nsIPrefBranch); + checkService("prefs", Ci.nsIPrefService); + checkService("prompt", Ci.nsIPromptService); + checkService("scriptSecurityManager", Ci.nsIScriptSecurityManager); + checkService("scriptloader", Ci.mozIJSSubScriptLoader); + checkService("startup", Ci.nsIAppStartup); + checkService("storage", Ci.mozIStorageService); + checkService("strings", Ci.nsIStringBundleService); + checkService("sysinfo", Ci.nsIPropertyBag2); + checkService("telemetry", Ci.nsITelemetry); + checkService("tm", Ci.nsIThreadManager); + checkService("uriFixup", Ci.nsIURIFixup); + checkService("urlFormatter", Ci.nsIURLFormatter); + checkService("vc", Ci.nsIVersionComparator); + checkService("wm", Ci.nsIWindowMediator); + checkService("ww", Ci.nsIWindowWatcher); + if ("nsIBrowserSearchService" in Ci) { + checkService("search", Ci.nsIBrowserSearchService); + } + if ("nsIAndroidBridge" in Ci) { + checkService("androidBridge", Ci.nsIAndroidBridge); + } + + // In xpcshell tests, the "@mozilla.org/xre/app-info;1" component implements + // only the nsIXULRuntime interface, but not nsIXULAppInfo. To test the + // service getter for the latter interface, load mock app-info. + let tmp = {}; + Cu.import("resource://testing-common/AppInfo.jsm", tmp); + tmp.updateAppInfo(); + + // We need to reload the module to update the lazy getter. + Cu.unload("resource://gre/modules/Services.jsm"); + Cu.import("resource://gre/modules/Services.jsm"); + + checkService("appinfo", Ci.nsIXULAppInfo); + + Cu.unload("resource://gre/modules/Services.jsm"); +} diff --git a/toolkit/modules/tests/xpcshell/test_UpdateUtils_updatechannel.js b/toolkit/modules/tests/xpcshell/test_UpdateUtils_updatechannel.js new file mode 100644 index 0000000000..75d7a19928 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_UpdateUtils_updatechannel.js @@ -0,0 +1,38 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +var { utils: Cu } = Components; + +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/UpdateUtils.jsm"); + +const PREF_APP_UPDATE_CHANNEL = "app.update.channel"; +const TEST_CHANNEL = "TestChannel"; +const PREF_PARTNER_A = "app.partner.test_partner_a"; +const TEST_PARTNER_A = "TestPartnerA"; +const PREF_PARTNER_B = "app.partner.test_partner_b"; +const TEST_PARTNER_B = "TestPartnerB"; + +add_task(function* test_updatechannel() { + let defaultPrefs = new Preferences({ defaultBranch: true }); + let currentChannel = defaultPrefs.get(PREF_APP_UPDATE_CHANNEL); + + do_check_eq(UpdateUtils.UpdateChannel, currentChannel); + do_check_eq(UpdateUtils.getUpdateChannel(true), currentChannel); + do_check_eq(UpdateUtils.getUpdateChannel(false), currentChannel); + + defaultPrefs.set(PREF_APP_UPDATE_CHANNEL, TEST_CHANNEL); + do_check_eq(UpdateUtils.UpdateChannel, TEST_CHANNEL); + do_check_eq(UpdateUtils.getUpdateChannel(true), TEST_CHANNEL); + do_check_eq(UpdateUtils.getUpdateChannel(false), TEST_CHANNEL); + + defaultPrefs.set(PREF_PARTNER_A, TEST_PARTNER_A); + defaultPrefs.set(PREF_PARTNER_B, TEST_PARTNER_B); + do_check_eq(UpdateUtils.UpdateChannel, + TEST_CHANNEL + "-cck-" + TEST_PARTNER_A + "-" + TEST_PARTNER_B); + do_check_eq(UpdateUtils.getUpdateChannel(true), + TEST_CHANNEL + "-cck-" + TEST_PARTNER_A + "-" + TEST_PARTNER_B); + do_check_eq(UpdateUtils.getUpdateChannel(false), TEST_CHANNEL); +}); diff --git a/toolkit/modules/tests/xpcshell/test_UpdateUtils_url.js b/toolkit/modules/tests/xpcshell/test_UpdateUtils_url.js new file mode 100644 index 0000000000..da5d868e37 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_UpdateUtils_url.js @@ -0,0 +1,292 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +var { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/UpdateUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://testing-common/AppInfo.jsm"); +Cu.import("resource://gre/modules/ctypes.jsm"); + +const PREF_APP_UPDATE_CHANNEL = "app.update.channel"; +const PREF_APP_PARTNER_BRANCH = "app.partner."; +const PREF_DISTRIBUTION_ID = "distribution.id"; +const PREF_DISTRIBUTION_VERSION = "distribution.version"; + +const URL_PREFIX = "http://localhost/"; + +const MSG_SHOULD_EQUAL = " should equal the expected value"; + +updateAppInfo(); +const gAppInfo = getAppInfo(); +const gDefaultPrefBranch = Services.prefs.getDefaultBranch(null); + +function setUpdateChannel(aChannel) { + gDefaultPrefBranch.setCharPref(PREF_APP_UPDATE_CHANNEL, aChannel); +} + +function getServicePack() { + // NOTE: This function is a helper function and not a test. Thus, + // it uses throw() instead of do_throw(). Any tests that use this function + // should catch exceptions thrown in this function and deal with them + // appropriately (usually by calling do_throw). + const BYTE = ctypes.uint8_t; + const WORD = ctypes.uint16_t; + const DWORD = ctypes.uint32_t; + const WCHAR = ctypes.char16_t; + const BOOL = ctypes.int; + + // This structure is described at: + // http://msdn.microsoft.com/en-us/library/ms724833%28v=vs.85%29.aspx + const SZCSDVERSIONLENGTH = 128; + const OSVERSIONINFOEXW = new ctypes.StructType('OSVERSIONINFOEXW', + [ + {dwOSVersionInfoSize: DWORD}, + {dwMajorVersion: DWORD}, + {dwMinorVersion: DWORD}, + {dwBuildNumber: DWORD}, + {dwPlatformId: DWORD}, + {szCSDVersion: ctypes.ArrayType(WCHAR, SZCSDVERSIONLENGTH)}, + {wServicePackMajor: WORD}, + {wServicePackMinor: WORD}, + {wSuiteMask: WORD}, + {wProductType: BYTE}, + {wReserved: BYTE} + ]); + + let kernel32 = ctypes.open("kernel32"); + try { + let GetVersionEx = kernel32.declare("GetVersionExW", + ctypes.default_abi, + BOOL, + OSVERSIONINFOEXW.ptr); + let winVer = OSVERSIONINFOEXW(); + winVer.dwOSVersionInfoSize = OSVERSIONINFOEXW.size; + + if (0 === GetVersionEx(winVer.address())) { + // Using "throw" instead of "do_throw" (see NOTE above) + throw ("Failure in GetVersionEx (returned 0)"); + } + + return winVer.wServicePackMajor + "." + winVer.wServicePackMinor; + } finally { + kernel32.close(); + } +} + +function getProcArchitecture() { + // NOTE: This function is a helper function and not a test. Thus, + // it uses throw() instead of do_throw(). Any tests that use this function + // should catch exceptions thrown in this function and deal with them + // appropriately (usually by calling do_throw). + const WORD = ctypes.uint16_t; + const DWORD = ctypes.uint32_t; + + // This structure is described at: + // http://msdn.microsoft.com/en-us/library/ms724958%28v=vs.85%29.aspx + const SYSTEM_INFO = new ctypes.StructType('SYSTEM_INFO', + [ + {wProcessorArchitecture: WORD}, + {wReserved: WORD}, + {dwPageSize: DWORD}, + {lpMinimumApplicationAddress: ctypes.voidptr_t}, + {lpMaximumApplicationAddress: ctypes.voidptr_t}, + {dwActiveProcessorMask: DWORD.ptr}, + {dwNumberOfProcessors: DWORD}, + {dwProcessorType: DWORD}, + {dwAllocationGranularity: DWORD}, + {wProcessorLevel: WORD}, + {wProcessorRevision: WORD} + ]); + + let kernel32 = ctypes.open("kernel32"); + try { + let GetNativeSystemInfo = kernel32.declare("GetNativeSystemInfo", + ctypes.default_abi, + ctypes.void_t, + SYSTEM_INFO.ptr); + let sysInfo = SYSTEM_INFO(); + // Default to unknown + sysInfo.wProcessorArchitecture = 0xffff; + + GetNativeSystemInfo(sysInfo.address()); + switch (sysInfo.wProcessorArchitecture) { + case 9: + return "x64"; + case 6: + return "IA64"; + case 0: + return "x86"; + default: + // Using "throw" instead of "do_throw" (see NOTE above) + throw ("Unknown architecture returned from GetNativeSystemInfo: " + sysInfo.wProcessorArchitecture); + } + } finally { + kernel32.close(); + } +} + +// Helper function for formatting a url and getting the result we're +// interested in +function getResult(url) { + url = UpdateUtils.formatUpdateURL(url); + return url.substr(URL_PREFIX.length).split("/")[0]; +} + +// url constructed with %PRODUCT% +add_task(function* test_product() { + let url = URL_PREFIX + "%PRODUCT%/"; + Assert.equal(getResult(url), gAppInfo.name, + "the url param for %PRODUCT%" + MSG_SHOULD_EQUAL); +}); + +// url constructed with %VERSION% +add_task(function* test_version() { + let url = URL_PREFIX + "%VERSION%/"; + Assert.equal(getResult(url), gAppInfo.version, + "the url param for %VERSION%" + MSG_SHOULD_EQUAL); +}); + +// url constructed with %BUILD_ID% +add_task(function* test_build_id() { + let url = URL_PREFIX + "%BUILD_ID%/"; + Assert.equal(getResult(url), gAppInfo.appBuildID, + "the url param for %BUILD_ID%" + MSG_SHOULD_EQUAL); +}); + +// url constructed with %BUILD_TARGET% +// XXX TODO - it might be nice if we tested the actual ABI +add_task(function* test_build_target() { + let url = URL_PREFIX + "%BUILD_TARGET%/"; + + let abi; + try { + abi = gAppInfo.XPCOMABI; + } catch (e) { + do_throw("nsIXULAppInfo:XPCOMABI not defined\n"); + } + + if (AppConstants.platform == "macosx") { + // Mac universal build should report a different ABI than either macppc + // or mactel. This is necessary since nsUpdateService.js will set the ABI to + // Universal-gcc3 for Mac universal builds. + let macutils = Cc["@mozilla.org/xpcom/mac-utils;1"]. + getService(Ci.nsIMacUtils); + + if (macutils.isUniversalBinary) { + abi += "-u-" + macutils.architecturesInBinary; + } + } else if (AppConstants.platform == "win") { + // Windows build should report the CPU architecture that it's running on. + abi += "-" + getProcArchitecture(); + } + + Assert.equal(getResult(url), gAppInfo.OS + "_" + abi, + "the url param for %BUILD_TARGET%" + MSG_SHOULD_EQUAL); +}); + +// url constructed with %LOCALE% +// Bug 488936 added the update.locale file that stores the update locale +add_task(function* test_locale() { + // The code that gets the locale accesses the profile which is only available + // after calling do_get_profile in xpcshell tests. This prevents an error from + // being logged. + do_get_profile(); + + let url = URL_PREFIX + "%LOCALE%/"; + Assert.equal(getResult(url), AppConstants.INSTALL_LOCALE, + "the url param for %LOCALE%" + MSG_SHOULD_EQUAL); +}); + +// url constructed with %CHANNEL% +add_task(function* test_channel() { + let url = URL_PREFIX + "%CHANNEL%/"; + setUpdateChannel("test_channel"); + Assert.equal(getResult(url), "test_channel", + "the url param for %CHANNEL%" + MSG_SHOULD_EQUAL); +}); + +// url constructed with %CHANNEL% with distribution partners +add_task(function* test_channel_distribution() { + let url = URL_PREFIX + "%CHANNEL%/"; + gDefaultPrefBranch.setCharPref(PREF_APP_PARTNER_BRANCH + "test_partner1", + "test_partner1"); + gDefaultPrefBranch.setCharPref(PREF_APP_PARTNER_BRANCH + "test_partner2", + "test_partner2"); + Assert.equal(getResult(url), + "test_channel-cck-test_partner1-test_partner2", + "the url param for %CHANNEL%" + MSG_SHOULD_EQUAL); +}); + +// url constructed with %PLATFORM_VERSION% +add_task(function* test_platform_version() { + let url = URL_PREFIX + "%PLATFORM_VERSION%/"; + Assert.equal(getResult(url), gAppInfo.platformVersion, + "the url param for %PLATFORM_VERSION%" + MSG_SHOULD_EQUAL); +}); + +// url constructed with %OS_VERSION% +add_task(function* test_os_version() { + let url = URL_PREFIX + "%OS_VERSION%/"; + let osVersion; + let sysInfo = Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2); + osVersion = sysInfo.getProperty("name") + " " + sysInfo.getProperty("version"); + + if (AppConstants.platform == "win") { + try { + let servicePack = getServicePack(); + osVersion += "." + servicePack; + } catch (e) { + do_throw("Failure obtaining service pack: " + e); + } + + if ("5.0" === sysInfo.getProperty("version")) { // Win2K + osVersion += " (unknown)"; + } else { + try { + osVersion += " (" + getProcArchitecture() + ")"; + } catch (e) { + do_throw("Failed to obtain processor architecture: " + e); + } + } + } + + if (osVersion) { + try { + osVersion += " (" + sysInfo.getProperty("secondaryLibrary") + ")"; + } catch (e) { + // Not all platforms have a secondary widget library, so an error is + // nothing to worry about. + } + osVersion = encodeURIComponent(osVersion); + } + + Assert.equal(getResult(url), osVersion, + "the url param for %OS_VERSION%" + MSG_SHOULD_EQUAL); +}); + +// url constructed with %DISTRIBUTION% +add_task(function* test_distribution() { + let url = URL_PREFIX + "%DISTRIBUTION%/"; + gDefaultPrefBranch.setCharPref(PREF_DISTRIBUTION_ID, "test_distro"); + Assert.equal(getResult(url), "test_distro", + "the url param for %DISTRIBUTION%" + MSG_SHOULD_EQUAL); +}); + +// url constructed with %DISTRIBUTION_VERSION% +add_task(function* test_distribution_version() { + let url = URL_PREFIX + "%DISTRIBUTION_VERSION%/"; + gDefaultPrefBranch.setCharPref(PREF_DISTRIBUTION_VERSION, "test_distro_version"); + Assert.equal(getResult(url), "test_distro_version", + "the url param for %DISTRIBUTION_VERSION%" + MSG_SHOULD_EQUAL); +}); + +add_task(function* test_custom() { + Services.prefs.setCharPref("app.update.custom", "custom"); + let url = URL_PREFIX + "%CUSTOM%/"; + Assert.equal(getResult(url), "custom", + "the url query string for %CUSTOM%" + MSG_SHOULD_EQUAL); +}); diff --git a/toolkit/modules/tests/xpcshell/test_ZipUtils.js b/toolkit/modules/tests/xpcshell/test_ZipUtils.js new file mode 100644 index 0000000000..71c6884d46 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_ZipUtils.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const ARCHIVE = "zips/zen.zip"; +const SUBDIR = "zen"; +const SYMLINK = "beyond_link"; +const ENTRIES = ["beyond.txt", SYMLINK, "waterwood.txt"]; + +Components.utils.import("resource://gre/modules/ZipUtils.jsm"); +Components.utils.import("resource://gre/modules/FileUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +const archive = do_get_file(ARCHIVE, false); +const dir = do_get_profile().clone(); +dir.append("test_ZipUtils"); + +function run_test() { + run_next_test(); +} + +function ensureExtracted(target) { + target.append(SUBDIR); + do_check_true(target.exists()); + + for (let i = 0; i < ENTRIES.length; i++) { + let entry = target.clone(); + entry.append(ENTRIES[i]); + do_print("ENTRY " + entry.path); + do_check_true(entry.exists()); + } +} + +function ensureHasSymlink(target) { + // Just bail out if running on Windows, since symlinks do not exists there. + if (Services.appinfo.OS === "WINNT") { + return; + } + + let entry = target.clone(); + entry.append(SYMLINK); + + do_print("ENTRY " + entry.path); + do_check_true(entry.exists()); + do_check_true(entry.isSymlink()); +} + +add_task(function test_extractFiles() { + let target = dir.clone(); + target.append("test_extractFiles"); + + try { + ZipUtils.extractFiles(archive, target); + } catch (e) { + do_throw("Failed to extract synchronously!"); + } + + ensureExtracted(target); + ensureHasSymlink(target); +}); + +add_task(function* test_extractFilesAsync() { + let target = dir.clone(); + target.append("test_extractFilesAsync"); + target.create(Components.interfaces.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY); + + yield ZipUtils.extractFilesAsync(archive, target).then( + function success() { + do_print("SUCCESS"); + ensureExtracted(target); + }, + function failure() { + do_print("FAILURE"); + do_throw("Failed to extract asynchronously!"); + } + ); +}); diff --git a/toolkit/modules/tests/xpcshell/test_client_id.js b/toolkit/modules/tests/xpcshell/test_client_id.js new file mode 100644 index 0000000000..10ef2a3eaf --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_client_id.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/ClientID.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); + +function run_test() { + do_get_profile(); + run_next_test(); +} + +add_task(function* () { + const drsPath = OS.Path.join(OS.Constants.Path.profileDir, "datareporting", "state.json"); + const fhrDir = OS.Path.join(OS.Constants.Path.profileDir, "healthreport"); + const fhrPath = OS.Path.join(fhrDir, "state.json"); + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const invalidIDs = [-1, 0.5, "INVALID-UUID", true, "", "3d1e1560-682a-4043-8cf2-aaaaaaaaaaaZ"]; + const PREF_CACHED_CLIENTID = "toolkit.telemetry.cachedClientID"; + + yield OS.File.makeDir(fhrDir); + + // Check that we are importing the FHR client ID. + let clientID = CommonUtils.generateUUID(); + yield CommonUtils.writeJSON({clientID: clientID}, fhrPath); + Assert.equal(clientID, yield ClientID.getClientID()); + + // We should persist the ID in DRS now and not pick up a differing ID from FHR. + yield ClientID._reset(); + yield CommonUtils.writeJSON({clientID: CommonUtils.generateUUID()}, fhrPath); + Assert.equal(clientID, yield ClientID.getClientID()); + + // We should be guarded against broken FHR data. + for (let invalidID of invalidIDs) { + yield ClientID._reset(); + yield OS.File.remove(drsPath); + yield CommonUtils.writeJSON({clientID: invalidID}, fhrPath); + clientID = yield ClientID.getClientID(); + Assert.equal(typeof(clientID), 'string'); + Assert.ok(uuidRegex.test(clientID)); + } + + // We should be guarded against invalid FHR json. + yield ClientID._reset(); + yield OS.File.remove(drsPath); + yield OS.File.writeAtomic(fhrPath, "abcd", {encoding: "utf-8", tmpPath: fhrPath + ".tmp"}); + clientID = yield ClientID.getClientID(); + Assert.equal(typeof(clientID), 'string'); + Assert.ok(uuidRegex.test(clientID)); + + // We should be guarded against broken DRS data too and fall back to loading + // the FHR ID. + for (let invalidID of invalidIDs) { + yield ClientID._reset(); + clientID = CommonUtils.generateUUID(); + yield CommonUtils.writeJSON({clientID: clientID}, fhrPath); + yield CommonUtils.writeJSON({clientID: invalidID}, drsPath); + Assert.equal(clientID, yield ClientID.getClientID()); + } + + // We should be guarded against invalid DRS json too. + yield ClientID._reset(); + yield OS.File.remove(fhrPath); + yield OS.File.writeAtomic(drsPath, "abcd", {encoding: "utf-8", tmpPath: drsPath + ".tmp"}); + clientID = yield ClientID.getClientID(); + Assert.equal(typeof(clientID), 'string'); + Assert.ok(uuidRegex.test(clientID)); + + // If both the FHR and DSR data are broken, we should end up with a new client ID. + for (let invalidID of invalidIDs) { + yield ClientID._reset(); + yield CommonUtils.writeJSON({clientID: invalidID}, fhrPath); + yield CommonUtils.writeJSON({clientID: invalidID}, drsPath); + clientID = yield ClientID.getClientID(); + Assert.equal(typeof(clientID), 'string'); + Assert.ok(uuidRegex.test(clientID)); + } + + // Assure that cached IDs are being checked for validity. + for (let invalidID of invalidIDs) { + yield ClientID._reset(); + Preferences.set(PREF_CACHED_CLIENTID, invalidID); + let cachedID = ClientID.getCachedClientID(); + Assert.strictEqual(cachedID, null, "ClientID should ignore invalid cached IDs"); + let prefID = Preferences.get(PREF_CACHED_CLIENTID, null); + Assert.strictEqual(prefID, null, "ClientID should reset invalid cached IDs"); + } +}); diff --git a/toolkit/modules/tests/xpcshell/test_jsesc.js b/toolkit/modules/tests/xpcshell/test_jsesc.js new file mode 100644 index 0000000000..0c6cbba690 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_jsesc.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Components.utils.import("resource://gre/modules/third_party/jsesc/jsesc.js"); + +function run_test() { + do_check_eq(jsesc("teééést", {lowercaseHex: true}), "te\\xe9\\xe9\\xe9st"); + do_check_eq(jsesc("teééést", {lowercaseHex: false}), "te\\xE9\\xE9\\xE9st"); +} diff --git a/toolkit/modules/tests/xpcshell/test_propertyListsUtils.js b/toolkit/modules/tests/xpcshell/test_propertyListsUtils.js new file mode 100644 index 0000000000..9ccf50b739 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_propertyListsUtils.js @@ -0,0 +1,106 @@ +/* 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"; + +Components.utils.import("resource://gre/modules/PropertyListUtils.jsm"); + +function checkValue(aPropertyListObject, aType, aValue) { + do_check_eq(PropertyListUtils.getObjectType(aPropertyListObject), aType); + if (aValue !== undefined) { + // Perform strict equality checks until Bug 714467 is fixed. + let strictEqualityCheck = function(a, b) { + do_check_eq(typeof(a), typeof(b)); + do_check_eq(a, b); + }; + + if (typeof(aPropertyListObject) == "object") + strictEqualityCheck(aPropertyListObject.valueOf(), aValue.valueOf()); + else + strictEqualityCheck(aPropertyListObject, aValue); + } +} + +function checkLazyGetterValue(aObject, aPropertyName, aType, aValue) { + let descriptor = Object.getOwnPropertyDescriptor(aObject, aPropertyName); + do_check_eq(typeof(descriptor.get), "function"); + do_check_eq(typeof(descriptor.value), "undefined"); + checkValue(aObject[aPropertyName], aType, aValue); + descriptor = Object.getOwnPropertyDescriptor(aObject, aPropertyName); + do_check_eq(typeof(descriptor.get), "undefined"); + do_check_neq(typeof(descriptor.value), "undefined"); +} + +function checkMainPropertyList(aPropertyListRoot) { + const PRIMITIVE = PropertyListUtils.TYPE_PRIMITIVE; + + checkValue(aPropertyListRoot, PropertyListUtils.TYPE_DICTIONARY); + + // Check .has() + Assert.ok(aPropertyListRoot.has("Boolean")); + Assert.ok(!aPropertyListRoot.has("Nonexistent")); + + checkValue(aPropertyListRoot.get("Boolean"), PRIMITIVE, false); + + let array = aPropertyListRoot.get("Array"); + checkValue(array, PropertyListUtils.TYPE_ARRAY); + do_check_eq(array.length, 8); + + // Test both long and short values, since binary property lists store + // long values a little bit differently (see readDataLengthAndOffset). + + // Short ASCII string + checkLazyGetterValue(array, 0, PRIMITIVE, "abc"); + // Long ASCII string + checkLazyGetterValue(array, 1, PRIMITIVE, new Array(1001).join("a")); + // Short unicode string + checkLazyGetterValue(array, 2, PRIMITIVE, "\u05D0\u05D0\u05D0"); + // Long unicode string + checkLazyGetterValue(array, 3, PRIMITIVE, new Array(1001).join("\u05D0")); + // Unicode surrogate pair + checkLazyGetterValue(array, 4, PRIMITIVE, + "\uD800\uDC00\uD800\uDC00\uD800\uDC00"); + + // Date + checkLazyGetterValue(array, 5, PropertyListUtils.TYPE_DATE, + new Date("2011-12-31T11:15:23Z")); + + // Data + checkLazyGetterValue(array, 6, PropertyListUtils.TYPE_UINT8_ARRAY); + let dataAsString = Array.from(array[6]).map(b => String.fromCharCode(b)).join(""); + do_check_eq(dataAsString, "2011-12-31T11:15:33Z"); + + // Dict + let dict = array[7]; + checkValue(dict, PropertyListUtils.TYPE_DICTIONARY); + checkValue(dict.get("Negative Number"), PRIMITIVE, -400); + checkValue(dict.get("Real Number"), PRIMITIVE, 2.71828183); + checkValue(dict.get("Big Int"), + PropertyListUtils.TYPE_INT64, + "9007199254740993"); + checkValue(dict.get("Negative Big Int"), + PropertyListUtils.TYPE_INT64, + "-9007199254740993"); +} + +function readPropertyList(aFile, aCallback) { + PropertyListUtils.read(aFile, function(aPropertyListRoot) { + // Null root indicates failure to read property list. + // Note: It is important not to run do_check_n/eq directly on Dict and array + // objects, because it cases their toString to get invoked, doing away with + // all the lazy getter we'd like to test later. + do_check_true(aPropertyListRoot !== null); + aCallback(aPropertyListRoot); + run_next_test(); + }); +} + +function run_test() { + add_test(readPropertyList.bind(this, + do_get_file("propertyLists/bug710259_propertyListBinary.plist", false), + checkMainPropertyList)); + add_test(readPropertyList.bind(this, + do_get_file("propertyLists/bug710259_propertyListXML.plist", false), + checkMainPropertyList)); + run_next_test(); +} diff --git a/toolkit/modules/tests/xpcshell/test_readCertPrefs.js b/toolkit/modules/tests/xpcshell/test_readCertPrefs.js new file mode 100644 index 0000000000..837a9912a0 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_readCertPrefs.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/CertUtils.jsm"); + +const PREF_PREFIX = "certutils.certs."; + +function run_test() { + run_next_test(); +} + +function resetPrefs() { + var prefs = Services.prefs.getChildList(PREF_PREFIX); + prefs.forEach(Services.prefs.clearUserPref); +} + +function attributes_match(aCert, aExpected) { + if (Object.keys(aCert).length != Object.keys(aExpected).length) + return false; + + for (var attribute in aCert) { + if (!(attribute in aExpected)) + return false; + if (aCert[attribute] != aExpected[attribute]) + return false; + } + + return true; +} + +function test_results(aCerts, aExpected) { + do_check_eq(aCerts.length, aExpected.length); + + for (var i = 0; i < aCerts.length; i++) { + if (!attributes_match(aCerts[i], aExpected[i])) { + dump("Attributes for certificate " + (i + 1) + " did not match expected attributes\n"); + dump("Saw: " + aCerts[i].toSource() + "\n"); + dump("Expected: " + aExpected[i].toSource() + "\n"); + do_check_true(false); + } + } +} + +add_test(function test_singleCert() { + Services.prefs.setCharPref(PREF_PREFIX + "1.attribute1", "foo"); + Services.prefs.setCharPref(PREF_PREFIX + "1.attribute2", "bar"); + + var certs = readCertPrefs(PREF_PREFIX); + test_results(certs, [{ + attribute1: "foo", + attribute2: "bar" + }]); + + resetPrefs(); + run_next_test(); +}); + +add_test(function test_multipleCert() { + Services.prefs.setCharPref(PREF_PREFIX + "1.md5Fingerprint", "cf84a9a2a804e021f27cb5128fe151f4"); + Services.prefs.setCharPref(PREF_PREFIX + "1.nickname", "1st cert"); + Services.prefs.setCharPref(PREF_PREFIX + "2.md5Fingerprint", "9441051b7eb50e5ca2226095af710c1a"); + Services.prefs.setCharPref(PREF_PREFIX + "2.nickname", "2nd cert"); + + var certs = readCertPrefs(PREF_PREFIX); + test_results(certs, [{ + md5Fingerprint: "cf84a9a2a804e021f27cb5128fe151f4", + nickname: "1st cert" + }, { + md5Fingerprint: "9441051b7eb50e5ca2226095af710c1a", + nickname: "2nd cert" + }]); + + resetPrefs(); + run_next_test(); +}); + +add_test(function test_skippedCert() { + Services.prefs.setCharPref(PREF_PREFIX + "1.issuerName", "Mozilla"); + Services.prefs.setCharPref(PREF_PREFIX + "1.nickname", "1st cert"); + Services.prefs.setCharPref(PREF_PREFIX + "2.issuerName", "Top CA"); + Services.prefs.setCharPref(PREF_PREFIX + "2.nickname", "2nd cert"); + Services.prefs.setCharPref(PREF_PREFIX + "4.issuerName", "Unknown CA"); + Services.prefs.setCharPref(PREF_PREFIX + "4.nickname", "Ignored cert"); + + var certs = readCertPrefs(PREF_PREFIX); + test_results(certs, [{ + issuerName: "Mozilla", + nickname: "1st cert" + }, { + issuerName: "Top CA", + nickname: "2nd cert" + }]); + + resetPrefs(); + run_next_test(); +}); diff --git a/toolkit/modules/tests/xpcshell/test_servicerequest_xhr.js b/toolkit/modules/tests/xpcshell/test_servicerequest_xhr.js new file mode 100644 index 0000000000..b3c8a443ed --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_servicerequest_xhr.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/ServiceRequest.jsm"); + +add_task(function* test_tls_conservative() { + const request = new ServiceRequest(); + request.open("GET", "http://example.com", false); + + const sr_channel = request.channel.QueryInterface(Ci.nsIHttpChannelInternal); + ok(("beConservative" in sr_channel), "TLS setting is present in SR channel"); + ok(sr_channel.beConservative, "TLS setting in request channel is set to conservative for SR"); + + const xhr = new XMLHttpRequest(); + xhr.open("GET", "http://example.com", false); + + const xhr_channel = xhr.channel.QueryInterface(Ci.nsIHttpChannelInternal); + ok(("beConservative" in xhr_channel), "TLS setting is present in XHR channel"); + ok(!xhr_channel.beConservative, "TLS setting in request channel is not set to conservative for XHR"); + +}); diff --git a/toolkit/modules/tests/xpcshell/test_session_recorder.js b/toolkit/modules/tests/xpcshell/test_session_recorder.js new file mode 100644 index 0000000000..dd9159c6e8 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_session_recorder.js @@ -0,0 +1,306 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var {utils: Cu} = Components; + +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/SessionRecorder.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://services-common/utils.js"); + + +function run_test() { + run_next_test(); +} + +function monkeypatchStartupInfo(recorder, start=new Date(), offset=500) { + Object.defineProperty(recorder, "_getStartupInfo", { + value: function _getStartupInfo() { + return { + process: start, + main: new Date(start.getTime() + offset), + firstPaint: new Date(start.getTime() + 2 * offset), + sessionRestored: new Date(start.getTime() + 3 * offset), + }; + } + }); +} + +function sleep(wait) { + let deferred = Promise.defer(); + + let timer = CommonUtils.namedTimer(function onTimer() { + deferred.resolve(); + }, wait, deferred.promise, "_sleepTimer"); + + return deferred.promise; +} + +function getRecorder(name, start, offset) { + let recorder = new SessionRecorder("testing." + name + "."); + monkeypatchStartupInfo(recorder, start, offset); + + return recorder; +} + +add_test(function test_basic() { + let recorder = getRecorder("basic"); + recorder.onStartup(); + recorder.onShutdown(); + + run_next_test(); +}); + +add_task(function* test_current_properties() { + let now = new Date(); + let recorder = getRecorder("current_properties", now); + yield sleep(25); + recorder.onStartup(); + + do_check_eq(recorder.startDate.getTime(), now.getTime()); + do_check_eq(recorder.activeTicks, 0); + do_check_true(recorder.fineTotalTime > 0); + do_check_eq(recorder.main, 500); + do_check_eq(recorder.firstPaint, 1000); + do_check_eq(recorder.sessionRestored, 1500); + + recorder.incrementActiveTicks(); + do_check_eq(recorder.activeTicks, 1); + + recorder._startDate = new Date(Date.now() - 1000); + recorder.updateTotalTime(); + do_check_eq(recorder.totalTime, 1); + + recorder.onShutdown(); +}); + +// If startup info isn't present yet, we should install a timer and get +// it eventually. +add_task(function* test_current_availability() { + let recorder = new SessionRecorder("testing.current_availability."); + let now = new Date(); + + Object.defineProperty(recorder, "_getStartupInfo", { + value: function _getStartupInfo() { + return { + process: now, + main: new Date(now.getTime() + 500), + firstPaint: new Date(now.getTime() + 1000), + }; + }, + writable: true, + }); + + Object.defineProperty(recorder, "STARTUP_RETRY_INTERVAL_MS", { + value: 100, + }); + + let oldRecord = recorder.recordStartupFields; + let recordCount = 0; + + Object.defineProperty(recorder, "recordStartupFields", { + value: function () { + recordCount++; + return oldRecord.call(recorder); + } + }); + + do_check_null(recorder._timer); + recorder.onStartup(); + do_check_eq(recordCount, 1); + do_check_eq(recorder.sessionRestored, -1); + do_check_neq(recorder._timer, null); + + yield sleep(125); + do_check_eq(recordCount, 2); + yield sleep(100); + do_check_eq(recordCount, 3); + do_check_eq(recorder.sessionRestored, -1); + + monkeypatchStartupInfo(recorder, now); + yield sleep(100); + do_check_eq(recordCount, 4); + do_check_eq(recorder.sessionRestored, 1500); + + // The timer should be removed and we should not fire again. + do_check_null(recorder._timer); + yield sleep(100); + do_check_eq(recordCount, 4); + + recorder.onShutdown(); +}); + +add_test(function test_timer_clear_on_shutdown() { + let recorder = new SessionRecorder("testing.timer_clear_on_shutdown."); + let now = new Date(); + + Object.defineProperty(recorder, "_getStartupInfo", { + value: function _getStartupInfo() { + return { + process: now, + main: new Date(now.getTime() + 500), + firstPaint: new Date(now.getTime() + 1000), + }; + }, + }); + + do_check_null(recorder._timer); + recorder.onStartup(); + do_check_neq(recorder._timer, null); + + recorder.onShutdown(); + do_check_null(recorder._timer); + + run_next_test(); +}); + +add_task(function* test_previous_clean() { + let now = new Date(); + let recorder = getRecorder("previous_clean", now); + yield sleep(25); + recorder.onStartup(); + + recorder.incrementActiveTicks(); + recorder.incrementActiveTicks(); + + yield sleep(25); + recorder.onShutdown(); + + let total = recorder.totalTime; + + yield sleep(25); + let now2 = new Date(); + let recorder2 = getRecorder("previous_clean", now2, 100); + yield sleep(25); + recorder2.onStartup(); + + do_check_eq(recorder2.startDate.getTime(), now2.getTime()); + do_check_eq(recorder2.main, 100); + do_check_eq(recorder2.firstPaint, 200); + do_check_eq(recorder2.sessionRestored, 300); + + let sessions = recorder2.getPreviousSessions(); + do_check_eq(Object.keys(sessions).length, 1); + do_check_true(0 in sessions); + let session = sessions[0]; + do_check_true(session.clean); + do_check_eq(session.startDate.getTime(), now.getTime()); + do_check_eq(session.main, 500); + do_check_eq(session.firstPaint, 1000); + do_check_eq(session.sessionRestored, 1500); + do_check_eq(session.totalTime, total); + do_check_eq(session.activeTicks, 2); + + recorder2.onShutdown(); +}); + +add_task(function* test_previous_abort() { + let now = new Date(); + let recorder = getRecorder("previous_abort", now); + yield sleep(25); + recorder.onStartup(); + recorder.incrementActiveTicks(); + yield sleep(25); + let total = recorder.totalTime; + yield sleep(25); + + let now2 = new Date(); + let recorder2 = getRecorder("previous_abort", now2); + yield sleep(25); + recorder2.onStartup(); + + let sessions = recorder2.getPreviousSessions(); + do_check_eq(Object.keys(sessions).length, 1); + do_check_true(0 in sessions); + let session = sessions[0]; + do_check_false(session.clean); + do_check_eq(session.totalTime, total); + + recorder.onShutdown(); + recorder2.onShutdown(); +}); + +add_task(function* test_multiple_sessions() { + for (let i = 0; i < 10; i++) { + let recorder = getRecorder("multiple_sessions"); + yield sleep(25); + recorder.onStartup(); + for (let j = 0; j < i; j++) { + recorder.incrementActiveTicks(); + } + yield sleep(25); + recorder.onShutdown(); + yield sleep(25); + } + + let recorder = getRecorder("multiple_sessions"); + recorder.onStartup(); + + let sessions = recorder.getPreviousSessions(); + do_check_eq(Object.keys(sessions).length, 10); + + for (let [i, session] of Object.entries(sessions)) { + do_check_eq(session.activeTicks, i); + + if (i > 0) { + do_check_true(session.startDate.getTime() > sessions[i-1].startDate.getTime()); + } + } + + // #6 is preserved since >=. + let threshold = sessions[6].startDate; + recorder.pruneOldSessions(threshold); + + sessions = recorder.getPreviousSessions(); + do_check_eq(Object.keys(sessions).length, 4); + + recorder.pruneOldSessions(threshold); + sessions = recorder.getPreviousSessions(); + do_check_eq(Object.keys(sessions).length, 4); + do_check_eq(recorder._prunedIndex, 5); + + recorder.onShutdown(); +}); + +add_task(function* test_record_activity() { + let recorder = getRecorder("record_activity"); + yield sleep(25); + recorder.onStartup(); + let total = recorder.totalTime; + yield sleep(25); + + for (let i = 0; i < 3; i++) { + Services.obs.notifyObservers(null, "user-interaction-active", null); + yield sleep(25); + do_check_true(recorder.fineTotalTime > total); + total = recorder.fineTotalTime; + } + + do_check_eq(recorder.activeTicks, 3); + + // Now send inactive. We should increment total time but not active. + Services.obs.notifyObservers(null, "user-interaction-inactive", null); + do_check_eq(recorder.activeTicks, 3); + do_check_true(recorder.fineTotalTime > total); + total = recorder.fineTotalTime; + yield sleep(25); + + // If we send active again, this should be counted as inactive. + Services.obs.notifyObservers(null, "user-interaction-active", null); + do_check_eq(recorder.activeTicks, 3); + do_check_true(recorder.fineTotalTime > total); + total = recorder.fineTotalTime; + yield sleep(25); + + // If we send active again, this should be counted as active. + Services.obs.notifyObservers(null, "user-interaction-active", null); + do_check_eq(recorder.activeTicks, 4); + + Services.obs.notifyObservers(null, "user-interaction-active", null); + do_check_eq(recorder.activeTicks, 5); + + recorder.onShutdown(); +}); + diff --git a/toolkit/modules/tests/xpcshell/test_sqlite.js b/toolkit/modules/tests/xpcshell/test_sqlite.js new file mode 100644 index 0000000000..edd39d9770 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_sqlite.js @@ -0,0 +1,1094 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +do_get_profile(); + +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/PromiseUtils.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Sqlite.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +// To spin the event loop in test. +Cu.import("resource://services-common/async.js"); + +function sleep(ms) { + let deferred = Promise.defer(); + + let timer = Cc["@mozilla.org/timer;1"] + .createInstance(Ci.nsITimer); + + timer.initWithCallback({ + notify: function () { + deferred.resolve(); + }, + }, ms, timer.TYPE_ONE_SHOT); + + return deferred.promise; +} + +// When testing finalization, use this to tell Sqlite.jsm to not throw +// an uncatchable `Promise.reject` +function failTestsOnAutoClose(enabled) { + Cu.getGlobalForObject(Sqlite).Debugging.failTestsOnAutoClose = enabled; +} + +function getConnection(dbName, extraOptions={}) { + let path = dbName + ".sqlite"; + let options = {path: path}; + for (let [k, v] of Object.entries(extraOptions)) { + options[k] = v; + } + + return Sqlite.openConnection(options); +} + +function* getDummyDatabase(name, extraOptions={}) { + const TABLES = { + dirs: "id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT", + files: "id INTEGER PRIMARY KEY AUTOINCREMENT, dir_id INTEGER, path TEXT", + }; + + let c = yield getConnection(name, extraOptions); + c._initialStatementCount = 0; + + for (let [k, v] of Object.entries(TABLES)) { + yield c.execute("CREATE TABLE " + k + "(" + v + ")"); + c._initialStatementCount++; + } + + return c; +} + +function* getDummyTempDatabase(name, extraOptions={}) { + const TABLES = { + dirs: "id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT", + files: "id INTEGER PRIMARY KEY AUTOINCREMENT, dir_id INTEGER, path TEXT", + }; + + let c = yield getConnection(name, extraOptions); + c._initialStatementCount = 0; + + for (let [k, v] of Object.entries(TABLES)) { + yield c.execute("CREATE TEMP TABLE " + k + "(" + v + ")"); + c._initialStatementCount++; + } + + return c; +} + +function run_test() { + Cu.import("resource://testing-common/services/common/logging.js"); + initTestLogging("Trace"); + + run_next_test(); +} + +add_task(function* test_open_normal() { + let c = yield Sqlite.openConnection({path: "test_open_normal.sqlite"}); + yield c.close(); +}); + +add_task(function* test_open_unshared() { + let path = OS.Path.join(OS.Constants.Path.profileDir, "test_open_unshared.sqlite"); + + let c = yield Sqlite.openConnection({path: path, sharedMemoryCache: false}); + yield c.close(); +}); + +add_task(function* test_get_dummy_database() { + let db = yield getDummyDatabase("get_dummy_database"); + + do_check_eq(typeof(db), "object"); + yield db.close(); +}); + +add_task(function* test_schema_version() { + let db = yield getDummyDatabase("schema_version"); + + let version = yield db.getSchemaVersion(); + do_check_eq(version, 0); + + db.setSchemaVersion(14); + version = yield db.getSchemaVersion(); + do_check_eq(version, 14); + + for (let v of [0.5, "foobar", NaN]) { + let success; + try { + yield db.setSchemaVersion(v); + do_print("Schema version " + v + " should have been rejected"); + success = false; + } catch (ex) { + if (!ex.message.startsWith("Schema version must be an integer.")) + throw ex; + success = true; + } + do_check_true(success); + + version = yield db.getSchemaVersion(); + do_check_eq(version, 14); + } + + yield db.close(); +}); + +add_task(function* test_simple_insert() { + let c = yield getDummyDatabase("simple_insert"); + + let result = yield c.execute("INSERT INTO dirs VALUES (NULL, 'foo')"); + do_check_true(Array.isArray(result)); + do_check_eq(result.length, 0); + yield c.close(); +}); + +add_task(function* test_simple_bound_array() { + let c = yield getDummyDatabase("simple_bound_array"); + + let result = yield c.execute("INSERT INTO dirs VALUES (?, ?)", [1, "foo"]); + do_check_eq(result.length, 0); + yield c.close(); +}); + +add_task(function* test_simple_bound_object() { + let c = yield getDummyDatabase("simple_bound_object"); + let result = yield c.execute("INSERT INTO dirs VALUES (:id, :path)", + {id: 1, path: "foo"}); + do_check_eq(result.length, 0); + result = yield c.execute("SELECT id, path FROM dirs"); + do_check_eq(result.length, 1); + do_check_eq(result[0].getResultByName("id"), 1); + do_check_eq(result[0].getResultByName("path"), "foo"); + yield c.close(); +}); + +// This is mostly a sanity test to ensure simple executions work. +add_task(function* test_simple_insert_then_select() { + let c = yield getDummyDatabase("simple_insert_then_select"); + + yield c.execute("INSERT INTO dirs VALUES (NULL, 'foo')"); + yield c.execute("INSERT INTO dirs (path) VALUES (?)", ["bar"]); + + let result = yield c.execute("SELECT * FROM dirs"); + do_check_eq(result.length, 2); + + let i = 0; + for (let row of result) { + i++; + + do_check_eq(row.numEntries, 2); + do_check_eq(row.getResultByIndex(0), i); + + let expected = {1: "foo", 2: "bar"}[i]; + do_check_eq(row.getResultByName("path"), expected); + } + + yield c.close(); +}); + +add_task(function* test_repeat_execution() { + let c = yield getDummyDatabase("repeat_execution"); + + let sql = "INSERT INTO dirs (path) VALUES (:path)"; + yield c.executeCached(sql, {path: "foo"}); + yield c.executeCached(sql); + + let result = yield c.execute("SELECT * FROM dirs"); + + do_check_eq(result.length, 2); + + yield c.close(); +}); + +add_task(function* test_table_exists() { + let c = yield getDummyDatabase("table_exists"); + + do_check_false(yield c.tableExists("does_not_exist")); + do_check_true(yield c.tableExists("dirs")); + do_check_true(yield c.tableExists("files")); + + yield c.close(); +}); + +add_task(function* test_index_exists() { + let c = yield getDummyDatabase("index_exists"); + + do_check_false(yield c.indexExists("does_not_exist")); + + yield c.execute("CREATE INDEX my_index ON dirs (path)"); + do_check_true(yield c.indexExists("my_index")); + + yield c.close(); +}); + +add_task(function* test_temp_table_exists() { + let c = yield getDummyTempDatabase("temp_table_exists"); + + do_check_false(yield c.tableExists("temp_does_not_exist")); + do_check_true(yield c.tableExists("dirs")); + do_check_true(yield c.tableExists("files")); + + yield c.close(); +}); + +add_task(function* test_temp_index_exists() { + let c = yield getDummyTempDatabase("temp_index_exists"); + + do_check_false(yield c.indexExists("temp_does_not_exist")); + + yield c.execute("CREATE INDEX my_index ON dirs (path)"); + do_check_true(yield c.indexExists("my_index")); + + yield c.close(); +}); + +add_task(function* test_close_cached() { + let c = yield getDummyDatabase("close_cached"); + + yield c.executeCached("SELECT * FROM dirs"); + yield c.executeCached("SELECT * FROM files"); + + yield c.close(); +}); + +add_task(function* test_execute_invalid_statement() { + let c = yield getDummyDatabase("invalid_statement"); + + let deferred = Promise.defer(); + + do_check_eq(c._connectionData._anonymousStatements.size, 0); + + c.execute("SELECT invalid FROM unknown").then(do_throw, function onError(error) { + deferred.resolve(); + }); + + yield deferred.promise; + + // Ensure we don't leak the statement instance. + do_check_eq(c._connectionData._anonymousStatements.size, 0); + + yield c.close(); +}); + +add_task(function* test_incorrect_like_bindings() { + let c = yield getDummyDatabase("incorrect_like_bindings"); + + let sql = "select * from dirs where path LIKE 'non%'"; + Assert.throws(() => c.execute(sql), /Please enter a LIKE clause/); + Assert.throws(() => c.executeCached(sql), /Please enter a LIKE clause/); + + yield c.close(); +}); +add_task(function* test_on_row_exception_ignored() { + let c = yield getDummyDatabase("on_row_exception_ignored"); + + let sql = "INSERT INTO dirs (path) VALUES (?)"; + for (let i = 0; i < 10; i++) { + yield c.executeCached(sql, ["dir" + i]); + } + + let i = 0; + let hasResult = yield c.execute("SELECT * FROM DIRS", null, function onRow(row) { + i++; + + throw new Error("Some silly error."); + }); + + do_check_eq(hasResult, true); + do_check_eq(i, 10); + + yield c.close(); +}); + +// Ensure StopIteration during onRow causes processing to stop. +add_task(function* test_on_row_stop_iteration() { + let c = yield getDummyDatabase("on_row_stop_iteration"); + + let sql = "INSERT INTO dirs (path) VALUES (?)"; + for (let i = 0; i < 10; i++) { + yield c.executeCached(sql, ["dir" + i]); + } + + let i = 0; + let hasResult = yield c.execute("SELECT * FROM dirs", null, function onRow(row) { + i++; + + if (i == 5) { + throw StopIteration; + } + }); + + do_check_eq(hasResult, true); + do_check_eq(i, 5); + + yield c.close(); +}); + +// Ensure execute resolves to false when no rows are selected. +add_task(function* test_on_row_stop_iteration() { + let c = yield getDummyDatabase("no_on_row"); + + let i = 0; + let hasResult = yield c.execute(`SELECT * FROM dirs WHERE path="nonexistent"`, null, function onRow(row) { + i++; + }); + + do_check_eq(hasResult, false); + do_check_eq(i, 0); + + yield c.close(); +}); + +add_task(function* test_invalid_transaction_type() { + let c = yield getDummyDatabase("invalid_transaction_type"); + + Assert.throws(() => c.executeTransaction(function* () {}, "foobar"), + /Unknown transaction type/, + "Unknown transaction type should throw"); + + yield c.close(); +}); + +add_task(function* test_execute_transaction_success() { + let c = yield getDummyDatabase("execute_transaction_success"); + + do_check_false(c.transactionInProgress); + + yield c.executeTransaction(function* transaction(conn) { + do_check_eq(c, conn); + do_check_true(conn.transactionInProgress); + + yield conn.execute("INSERT INTO dirs (path) VALUES ('foo')"); + }); + + do_check_false(c.transactionInProgress); + let rows = yield c.execute("SELECT * FROM dirs"); + do_check_true(Array.isArray(rows)); + do_check_eq(rows.length, 1); + + yield c.close(); +}); + +add_task(function* test_execute_transaction_rollback() { + let c = yield getDummyDatabase("execute_transaction_rollback"); + + let deferred = Promise.defer(); + + c.executeTransaction(function* transaction(conn) { + yield conn.execute("INSERT INTO dirs (path) VALUES ('foo')"); + print("Expecting error with next statement."); + yield conn.execute("INSERT INTO invalid VALUES ('foo')"); + + // We should never get here. + do_throw(); + }).then(do_throw, function onError(error) { + deferred.resolve(); + }); + + yield deferred.promise; + + let rows = yield c.execute("SELECT * FROM dirs"); + do_check_eq(rows.length, 0); + + yield c.close(); +}); + +add_task(function* test_close_during_transaction() { + let c = yield getDummyDatabase("close_during_transaction"); + + yield c.execute("INSERT INTO dirs (path) VALUES ('foo')"); + + let promise = c.executeTransaction(function* transaction(conn) { + yield c.execute("INSERT INTO dirs (path) VALUES ('bar')"); + }); + yield c.close(); + + yield Assert.rejects(promise, + /Transaction canceled due to a closed connection/, + "closing a connection in the middle of a transaction should reject it"); + + let c2 = yield getConnection("close_during_transaction"); + let rows = yield c2.execute("SELECT * FROM dirs"); + do_check_eq(rows.length, 1); + + yield c2.close(); +}); + +// Verify that we support concurrent transactions. +add_task(function* test_multiple_transactions() { + let c = yield getDummyDatabase("detect_multiple_transactions"); + + for (let i = 0; i < 10; ++i) { + // We don't wait for these transactions. + c.executeTransaction(function* () { + yield c.execute("INSERT INTO dirs (path) VALUES (:path)", + { path: `foo${i}` }); + yield c.execute("SELECT * FROM dirs"); + }); + } + for (let i = 0; i < 10; ++i) { + yield c.executeTransaction(function* () { + yield c.execute("INSERT INTO dirs (path) VALUES (:path)", + { path: `bar${i}` }); + yield c.execute("SELECT * FROM dirs"); + }); + } + + let rows = yield c.execute("SELECT * FROM dirs"); + do_check_eq(rows.length, 20); + + yield c.close(); +}); + +// Verify that wrapped transactions ignore a BEGIN TRANSACTION failure, when +// an externally opened transaction exists. +add_task(function* test_wrapped_connection_transaction() { + let file = new FileUtils.File(OS.Path.join(OS.Constants.Path.profileDir, + "test_wrapStorageConnection.sqlite")); + let c = yield new Promise((resolve, reject) => { + Services.storage.openAsyncDatabase(file, null, (status, db) => { + if (Components.isSuccessCode(status)) { + resolve(db.QueryInterface(Ci.mozIStorageAsyncConnection)); + } else { + reject(new Error(status)); + } + }); + }); + + let wrapper = yield Sqlite.wrapStorageConnection({ connection: c }); + // Start a transaction on the raw connection. + yield c.executeSimpleSQLAsync("BEGIN"); + // Now use executeTransaction, it will be executed, but not in a transaction. + yield wrapper.executeTransaction(function* () { + yield wrapper.execute("CREATE TABLE test (id INTEGER PRIMARY KEY AUTOINCREMENT)"); + }); + // This should not fail cause the internal transaction has not been created. + yield c.executeSimpleSQLAsync("COMMIT"); + + yield wrapper.execute("SELECT * FROM test"); + + // Closing the wrapper should just finalize statements but not close the + // database. + yield wrapper.close(); + yield c.asyncClose(); +}); + +add_task(function* test_shrink_memory() { + let c = yield getDummyDatabase("shrink_memory"); + + // It's just a simple sanity test. We have no way of measuring whether this + // actually does anything. + + yield c.shrinkMemory(); + yield c.close(); +}); + +add_task(function* test_no_shrink_on_init() { + let c = yield getConnection("no_shrink_on_init", + {shrinkMemoryOnConnectionIdleMS: 200}); + + let oldShrink = c._connectionData.shrinkMemory; + let count = 0; + Object.defineProperty(c._connectionData, "shrinkMemory", { + value: function () { + count++; + }, + }); + + // We should not shrink until a statement has been executed. + yield sleep(220); + do_check_eq(count, 0); + + yield c.execute("SELECT 1"); + yield sleep(220); + do_check_eq(count, 1); + + yield c.close(); +}); + +add_task(function* test_idle_shrink_fires() { + let c = yield getDummyDatabase("idle_shrink_fires", + {shrinkMemoryOnConnectionIdleMS: 200}); + c._connectionData._clearIdleShrinkTimer(); + + let oldShrink = c._connectionData.shrinkMemory; + let shrinkPromises = []; + + let count = 0; + Object.defineProperty(c._connectionData, "shrinkMemory", { + value: function () { + count++; + let promise = oldShrink.call(c._connectionData); + shrinkPromises.push(promise); + return promise; + }, + }); + + // We reset the idle shrink timer after monkeypatching because otherwise the + // installed timer callback will reference the non-monkeypatched function. + c._connectionData._startIdleShrinkTimer(); + + yield sleep(220); + do_check_eq(count, 1); + do_check_eq(shrinkPromises.length, 1); + yield shrinkPromises[0]; + shrinkPromises.shift(); + + // We shouldn't shrink again unless a statement was executed. + yield sleep(300); + do_check_eq(count, 1); + + yield c.execute("SELECT 1"); + yield sleep(300); + + do_check_eq(count, 2); + do_check_eq(shrinkPromises.length, 1); + yield shrinkPromises[0]; + + yield c.close(); +}); + +add_task(function* test_idle_shrink_reset_on_operation() { + const INTERVAL = 500; + let c = yield getDummyDatabase("idle_shrink_reset_on_operation", + {shrinkMemoryOnConnectionIdleMS: INTERVAL}); + + c._connectionData._clearIdleShrinkTimer(); + + let oldShrink = c._connectionData.shrinkMemory; + let shrinkPromises = []; + let count = 0; + + Object.defineProperty(c._connectionData, "shrinkMemory", { + value: function () { + count++; + let promise = oldShrink.call(c._connectionData); + shrinkPromises.push(promise); + return promise; + }, + }); + + let now = new Date(); + c._connectionData._startIdleShrinkTimer(); + + let initialIdle = new Date(now.getTime() + INTERVAL); + + // Perform database operations until initial scheduled time has been passed. + let i = 0; + while (new Date() < initialIdle) { + yield c.execute("INSERT INTO dirs (path) VALUES (?)", ["" + i]); + i++; + } + + do_check_true(i > 0); + + // We should not have performed an idle while doing operations. + do_check_eq(count, 0); + + // Wait for idle timer. + yield sleep(INTERVAL); + + // Ensure we fired. + do_check_eq(count, 1); + do_check_eq(shrinkPromises.length, 1); + yield shrinkPromises[0]; + + yield c.close(); +}); + +add_task(function* test_in_progress_counts() { + let c = yield getDummyDatabase("in_progress_counts"); + do_check_eq(c._connectionData._statementCounter, c._initialStatementCount); + do_check_eq(c._connectionData._pendingStatements.size, 0); + yield c.executeCached("INSERT INTO dirs (path) VALUES ('foo')"); + do_check_eq(c._connectionData._statementCounter, c._initialStatementCount + 1); + do_check_eq(c._connectionData._pendingStatements.size, 0); + + let expectOne; + let expectTwo; + + // Please forgive me. + let inner = Async.makeSpinningCallback(); + let outer = Async.makeSpinningCallback(); + + // We want to make sure that two queries executing simultaneously + // result in `_pendingStatements.size` reaching 2, then dropping back to 0. + // + // To do so, we kick off a second statement within the row handler + // of the first, then wait for both to finish. + + yield c.executeCached("SELECT * from dirs", null, function onRow() { + // In the onRow handler, we're still an outstanding query. + // Expect a single in-progress entry. + expectOne = c._connectionData._pendingStatements.size; + + // Start another query, checking that after its statement has been created + // there are two statements in progress. + let p = c.executeCached("SELECT 10, path from dirs"); + expectTwo = c._connectionData._pendingStatements.size; + + // Now wait for it to be done before we return from the row handler … + p.then(function onInner() { + inner(); + }); + }).then(function onOuter() { + // … and wait for the inner to be done before we finish … + inner.wait(); + outer(); + }); + + // … and wait for both queries to have finished before we go on and + // test postconditions. + outer.wait(); + + do_check_eq(expectOne, 1); + do_check_eq(expectTwo, 2); + do_check_eq(c._connectionData._statementCounter, c._initialStatementCount + 3); + do_check_eq(c._connectionData._pendingStatements.size, 0); + + yield c.close(); +}); + +add_task(function* test_discard_while_active() { + let c = yield getDummyDatabase("discard_while_active"); + + yield c.executeCached("INSERT INTO dirs (path) VALUES ('foo')"); + yield c.executeCached("INSERT INTO dirs (path) VALUES ('bar')"); + + let discarded = -1; + let first = true; + let sql = "SELECT * FROM dirs"; + yield c.executeCached(sql, null, function onRow(row) { + if (!first) { + return; + } + first = false; + discarded = c.discardCachedStatements(); + }); + + // We discarded everything, because the SELECT had already started to run. + do_check_eq(3, discarded); + + // And again is safe. + do_check_eq(0, c.discardCachedStatements()); + + yield c.close(); +}); + +add_task(function* test_discard_cached() { + let c = yield getDummyDatabase("discard_cached"); + + yield c.executeCached("SELECT * from dirs"); + do_check_eq(1, c._connectionData._cachedStatements.size); + + yield c.executeCached("SELECT * from files"); + do_check_eq(2, c._connectionData._cachedStatements.size); + + yield c.executeCached("SELECT * from dirs"); + do_check_eq(2, c._connectionData._cachedStatements.size); + + c.discardCachedStatements(); + do_check_eq(0, c._connectionData._cachedStatements.size); + + yield c.close(); +}); + +add_task(function* test_programmatic_binding() { + let c = yield getDummyDatabase("programmatic_binding"); + + let bindings = [ + {id: 1, path: "foobar"}, + {id: null, path: "baznoo"}, + {id: 5, path: "toofoo"}, + ]; + + let sql = "INSERT INTO dirs VALUES (:id, :path)"; + let result = yield c.execute(sql, bindings); + do_check_eq(result.length, 0); + + let rows = yield c.executeCached("SELECT * from dirs"); + do_check_eq(rows.length, 3); + yield c.close(); +}); + +add_task(function* test_programmatic_binding_transaction() { + let c = yield getDummyDatabase("programmatic_binding_transaction"); + + let bindings = [ + {id: 1, path: "foobar"}, + {id: null, path: "baznoo"}, + {id: 5, path: "toofoo"}, + ]; + + let sql = "INSERT INTO dirs VALUES (:id, :path)"; + yield c.executeTransaction(function* transaction() { + let result = yield c.execute(sql, bindings); + do_check_eq(result.length, 0); + + let rows = yield c.executeCached("SELECT * from dirs"); + do_check_eq(rows.length, 3); + }); + + // Transaction committed. + let rows = yield c.executeCached("SELECT * from dirs"); + do_check_eq(rows.length, 3); + yield c.close(); +}); + +add_task(function* test_programmatic_binding_transaction_partial_rollback() { + let c = yield getDummyDatabase("programmatic_binding_transaction_partial_rollback"); + + let bindings = [ + {id: 2, path: "foobar"}, + {id: 3, path: "toofoo"}, + ]; + + let sql = "INSERT INTO dirs VALUES (:id, :path)"; + + // Add some data in an implicit transaction before beginning the batch insert. + yield c.execute(sql, {id: 1, path: "works"}); + + let secondSucceeded = false; + try { + yield c.executeTransaction(function* transaction() { + // Insert one row. This won't implicitly start a transaction. + let result = yield c.execute(sql, bindings[0]); + + // Insert multiple rows. mozStorage will want to start a transaction. + // One of the inserts will fail, so the transaction should be rolled back. + result = yield c.execute(sql, bindings); + secondSucceeded = true; + }); + } catch (ex) { + print("Caught expected exception: " + ex); + } + + // We did not get to the end of our in-transaction block. + do_check_false(secondSucceeded); + + // Everything that happened in *our* transaction, not mozStorage's, got + // rolled back, but the first row still exists. + let rows = yield c.executeCached("SELECT * from dirs"); + do_check_eq(rows.length, 1); + do_check_eq(rows[0].getResultByName("path"), "works"); + yield c.close(); +}); + +// Just like the previous test, but relying on the implicit +// transaction established by mozStorage. +add_task(function* test_programmatic_binding_implicit_transaction() { + let c = yield getDummyDatabase("programmatic_binding_implicit_transaction"); + + let bindings = [ + {id: 2, path: "foobar"}, + {id: 1, path: "toofoo"}, + ]; + + let sql = "INSERT INTO dirs VALUES (:id, :path)"; + let secondSucceeded = false; + yield c.execute(sql, {id: 1, path: "works"}); + try { + let result = yield c.execute(sql, bindings); + secondSucceeded = true; + } catch (ex) { + print("Caught expected exception: " + ex); + } + + do_check_false(secondSucceeded); + + // The entire batch failed. + let rows = yield c.executeCached("SELECT * from dirs"); + do_check_eq(rows.length, 1); + do_check_eq(rows[0].getResultByName("path"), "works"); + yield c.close(); +}); + +// Test that direct binding of params and execution through mozStorage doesn't +// error when we manually create a transaction. See Bug 856925. +add_task(function* test_direct() { + let file = FileUtils.getFile("TmpD", ["test_direct.sqlite"]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + print("Opening " + file.path); + + let db = Services.storage.openDatabase(file); + print("Opened " + db); + + db.executeSimpleSQL("CREATE TABLE types (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, UNIQUE (name))"); + print("Executed setup."); + + let statement = db.createAsyncStatement("INSERT INTO types (name) VALUES (:name)"); + let params = statement.newBindingParamsArray(); + let one = params.newBindingParams(); + one.bindByName("name", null); + params.addParams(one); + let two = params.newBindingParams(); + two.bindByName("name", "bar"); + params.addParams(two); + + print("Beginning transaction."); + let begin = db.createAsyncStatement("BEGIN DEFERRED TRANSACTION"); + let end = db.createAsyncStatement("COMMIT TRANSACTION"); + + let deferred = Promise.defer(); + begin.executeAsync({ + handleCompletion: function (reason) { + deferred.resolve(); + } + }); + yield deferred.promise; + + statement.bindParameters(params); + + deferred = Promise.defer(); + print("Executing async."); + statement.executeAsync({ + handleResult: function (resultSet) { + }, + + handleError: function (error) { + print("Error when executing SQL (" + error.result + "): " + + error.message); + print("Original error: " + error.error); + errors.push(error); + deferred.reject(); + }, + + handleCompletion: function (reason) { + print("Completed."); + deferred.resolve(); + } + }); + + yield deferred.promise; + + deferred = Promise.defer(); + end.executeAsync({ + handleCompletion: function (reason) { + deferred.resolve(); + } + }); + yield deferred.promise; + + statement.finalize(); + begin.finalize(); + end.finalize(); + + deferred = Promise.defer(); + db.asyncClose(function () { + deferred.resolve() + }); + yield deferred.promise; +}); + +// Test Sqlite.cloneStorageConnection. +add_task(function* test_cloneStorageConnection() { + let file = new FileUtils.File(OS.Path.join(OS.Constants.Path.profileDir, + "test_cloneStorageConnection.sqlite")); + let c = yield new Promise((resolve, reject) => { + Services.storage.openAsyncDatabase(file, null, (status, db) => { + if (Components.isSuccessCode(status)) { + resolve(db.QueryInterface(Ci.mozIStorageAsyncConnection)); + } else { + reject(new Error(status)); + } + }); + }); + + let clone = yield Sqlite.cloneStorageConnection({ connection: c, readOnly: true }); + // Just check that it works. + yield clone.execute("SELECT 1"); + + let clone2 = yield Sqlite.cloneStorageConnection({ connection: c, readOnly: false }); + // Just check that it works. + yield clone2.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)"); + + // Closing order should not matter. + yield c.asyncClose(); + yield clone2.close(); + yield clone.close(); +}); + +// Test Sqlite.cloneStorageConnection invalid argument. +add_task(function* test_cloneStorageConnection() { + try { + let clone = yield Sqlite.cloneStorageConnection({ connection: null }); + do_throw(new Error("Should throw on invalid connection")); + } catch (ex) { + if (ex.name != "TypeError") { + throw ex; + } + } +}); + +// Test clone() method. +add_task(function* test_clone() { + let c = yield getDummyDatabase("clone"); + + let clone = yield c.clone(); + // Just check that it works. + yield clone.execute("SELECT 1"); + // Closing order should not matter. + yield c.close(); + yield clone.close(); +}); + +// Test clone(readOnly) method. +add_task(function* test_readOnly_clone() { + let path = OS.Path.join(OS.Constants.Path.profileDir, "test_readOnly_clone.sqlite"); + let c = yield Sqlite.openConnection({path: path, sharedMemoryCache: false}); + + let clone = yield c.clone(true); + // Just check that it works. + yield clone.execute("SELECT 1"); + // But should not be able to write. + + yield Assert.rejects(clone.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)"), + /readonly/); + // Closing order should not matter. + yield c.close(); + yield clone.close(); +}); + +// Test Sqlite.wrapStorageConnection. +add_task(function* test_wrapStorageConnection() { + let file = new FileUtils.File(OS.Path.join(OS.Constants.Path.profileDir, + "test_wrapStorageConnection.sqlite")); + let c = yield new Promise((resolve, reject) => { + Services.storage.openAsyncDatabase(file, null, (status, db) => { + if (Components.isSuccessCode(status)) { + resolve(db.QueryInterface(Ci.mozIStorageAsyncConnection)); + } else { + reject(new Error(status)); + } + }); + }); + + let wrapper = yield Sqlite.wrapStorageConnection({ connection: c }); + // Just check that it works. + yield wrapper.execute("SELECT 1"); + yield wrapper.executeCached("SELECT 1"); + + // Closing the wrapper should just finalize statements but not close the + // database. + yield wrapper.close(); + yield c.asyncClose(); +}); + +// Test finalization +add_task(function* test_closed_by_witness() { + failTestsOnAutoClose(false); + let c = yield getDummyDatabase("closed_by_witness"); + + Services.obs.notifyObservers(null, "sqlite-finalization-witness", + c._connectionData._identifier); + // Since we triggered finalization ourselves, tell the witness to + // forget the connection so it does not trigger a finalization again + c._witness.forget(); + yield c._connectionData._deferredClose.promise; + do_check_false(c._connectionData._open); + failTestsOnAutoClose(true); +}); + +add_task(function* test_warning_message_on_finalization() { + failTestsOnAutoClose(false); + let c = yield getDummyDatabase("warning_message_on_finalization"); + let identifier = c._connectionData._identifier; + let deferred = Promise.defer(); + + let listener = { + observe: function(msg) { + let messageText = msg.message; + // Make sure the message starts with a warning containing the + // connection identifier + if (messageText.indexOf("Warning: Sqlite connection '" + identifier + "'") !== -1) { + deferred.resolve(); + } + } + }; + Services.console.registerListener(listener); + + Services.obs.notifyObservers(null, "sqlite-finalization-witness", identifier); + // Since we triggered finalization ourselves, tell the witness to + // forget the connection so it does not trigger a finalization again + c._witness.forget(); + + yield deferred.promise; + Services.console.unregisterListener(listener); + failTestsOnAutoClose(true); +}); + +add_task(function* test_error_message_on_unknown_finalization() { + failTestsOnAutoClose(false); + let deferred = Promise.defer(); + + let listener = { + observe: function(msg) { + let messageText = msg.message; + if (messageText.indexOf("Error: Attempt to finalize unknown " + + "Sqlite connection: foo") !== -1) { + deferred.resolve(); + } + } + }; + Services.console.registerListener(listener); + Services.obs.notifyObservers(null, "sqlite-finalization-witness", "foo"); + + yield deferred.promise; + Services.console.unregisterListener(listener); + failTestsOnAutoClose(true); +}); + +add_task(function* test_forget_witness_on_close() { + let c = yield getDummyDatabase("forget_witness_on_close"); + + let forgetCalled = false; + let oldWitness = c._witness; + c._witness = { + forget: function () { + forgetCalled = true; + oldWitness.forget(); + }, + }; + + yield c.close(); + // After close, witness should have forgotten the connection + do_check_true(forgetCalled); +}); + +add_task(function* test_close_database_on_gc() { + failTestsOnAutoClose(false); + let finalPromise; + + { + let collectedPromises = []; + for (let i = 0; i < 100; ++i) { + let deferred = PromiseUtils.defer(); + let c = yield getDummyDatabase("gc_" + i); + c._connectionData._deferredClose.promise.then(deferred.resolve); + collectedPromises.push(deferred.promise); + } + finalPromise = Promise.all(collectedPromises); + } + + // Call getDummyDatabase once more to clear any remaining + // references. This is needed at the moment, otherwise + // garbage-collection takes place after the shutdown barrier and the + // test will timeout. Once that is fixed, we can remove this line + // and be fine as long as the connections are garbage-collected. + let last = yield getDummyDatabase("gc_last"); + yield last.close(); + + Components.utils.forceGC(); + Components.utils.forceCC(); + Components.utils.forceShrinkingGC(); + + yield finalPromise; + failTestsOnAutoClose(true); +}); diff --git a/toolkit/modules/tests/xpcshell/test_sqlite_shutdown.js b/toolkit/modules/tests/xpcshell/test_sqlite_shutdown.js new file mode 100644 index 0000000000..b97fd85582 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_sqlite_shutdown.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +do_get_profile(); + +Cu.import("resource://gre/modules/osfile.jsm"); + // OS.File doesn't like to be first imported during shutdown +Cu.import("resource://gre/modules/Sqlite.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/AsyncShutdown.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); + +function getConnection(dbName, extraOptions={}) { + let path = dbName + ".sqlite"; + let options = {path: path}; + for (let [k, v] of Object.entries(extraOptions)) { + options[k] = v; + } + + return Sqlite.openConnection(options); +} + +function* getDummyDatabase(name, extraOptions={}) { + const TABLES = { + dirs: "id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT", + files: "id INTEGER PRIMARY KEY AUTOINCREMENT, dir_id INTEGER, path TEXT", + }; + + let c = yield getConnection(name, extraOptions); + c._initialStatementCount = 0; + + for (let [k, v] of Object.entries(TABLES)) { + yield c.execute("CREATE TABLE " + k + "(" + v + ")"); + c._initialStatementCount++; + } + + return c; +} + +function sleep(ms) { + let deferred = Promise.defer(); + + let timer = Cc["@mozilla.org/timer;1"] + .createInstance(Ci.nsITimer); + + timer.initWithCallback({ + notify: function () { + deferred.resolve(); + }, + }, ms, timer.TYPE_ONE_SHOT); + + return deferred.promise; +} + +function run_test() { + run_next_test(); +} + + +// +// ----------- Don't add a test after this one, as it shuts down Sqlite.jsm +// +add_task(function* test_shutdown_clients() { + do_print("Ensuring that Sqlite.jsm doesn't shutdown before its clients"); + + let assertions = []; + + let sleepStarted = false; + let sleepComplete = false; + Sqlite.shutdown.addBlocker("test_sqlite.js shutdown blocker (sleep)", + Task.async(function*() { + sleepStarted = true; + yield sleep(100); + sleepComplete = true; + })); + assertions.push({name: "sleepStarted", value: () => sleepStarted}); + assertions.push({name: "sleepComplete", value: () => sleepComplete}); + + Sqlite.shutdown.addBlocker("test_sqlite.js shutdown blocker (immediate)", + true); + + let dbOpened = false; + let dbClosed = false; + + Sqlite.shutdown.addBlocker("test_sqlite.js shutdown blocker (open a connection during shutdown)", + Task.async(function*() { + let db = yield getDummyDatabase("opened during shutdown"); + dbOpened = true; + db.close().then( + () => dbClosed = true + ); // Don't wait for this task to complete, Sqlite.jsm must wait automatically + })); + + assertions.push({name: "dbOpened", value: () => dbOpened}); + assertions.push({name: "dbClosed", value: () => dbClosed}); + + do_print("Now shutdown Sqlite.jsm synchronously"); + Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true); + AsyncShutdown.profileBeforeChange._trigger(); + Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); + + + for (let {name, value} of assertions) { + do_print("Checking: " + name); + do_check_true(value()); + } + + do_print("Ensure that we cannot open databases anymore"); + let exn; + try { + yield getDummyDatabase("opened after shutdown"); + } catch (ex) { + exn = ex; + } + do_check_true(!!exn); + do_check_true(exn.message.indexOf("Sqlite.jsm has been shutdown") != -1); +}); diff --git a/toolkit/modules/tests/xpcshell/test_task.js b/toolkit/modules/tests/xpcshell/test_task.js new file mode 100644 index 0000000000..fdcd565149 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_task.js @@ -0,0 +1,642 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests the Task.jsm module. + */ + +//////////////////////////////////////////////////////////////////////////////// +/// Globals + +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; +var Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); + +/** + * Returns a promise that will be resolved with the given value, when an event + * posted on the event loop of the main thread is processed. + */ +function promiseResolvedLater(aValue) { + let deferred = Promise.defer(); + Services.tm.mainThread.dispatch(() => deferred.resolve(aValue), + Ci.nsIThread.DISPATCH_NORMAL); + return deferred.promise; +} + +//////////////////////////////////////////////////////////////////////////////// +/// Tests + +function run_test() +{ + run_next_test(); +} + +add_test(function test_normal() +{ + Task.spawn(function () { + let result = yield Promise.resolve("Value"); + for (let i = 0; i < 3; i++) { + result += yield promiseResolvedLater("!"); + } + throw new Task.Result("Task result: " + result); + }).then(function (result) { + do_check_eq("Task result: Value!!!", result); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_exceptions() +{ + Task.spawn(function () { + try { + yield Promise.reject("Rejection result by promise."); + do_throw("Exception expected because the promise was rejected."); + } catch (ex) { + // We catch this exception now, we will throw a different one later. + do_check_eq("Rejection result by promise.", ex); + } + throw new Error("Exception uncaught by task."); + }).then(function (result) { + do_throw("Unexpected success!"); + }, function (ex) { + do_check_eq("Exception uncaught by task.", ex.message); + run_next_test(); + }); +}); + +add_test(function test_recursion() +{ + function task_fibonacci(n) { + throw new Task.Result(n < 2 ? n : (yield task_fibonacci(n - 1)) + + (yield task_fibonacci(n - 2))); + }; + + Task.spawn(task_fibonacci(6)).then(function (result) { + do_check_eq(8, result); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_spawn_primitive() +{ + function fibonacci(n) { + return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2); + }; + + // Polymorphism between task and non-task functions (see "test_recursion"). + Task.spawn(fibonacci(6)).then(function (result) { + do_check_eq(8, result); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_spawn_function() +{ + Task.spawn(function () { + return "This is not a generator."; + }).then(function (result) { + do_check_eq("This is not a generator.", result); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_spawn_function_this() +{ + Task.spawn(function () { + return this; + }).then(function (result) { + // Since the task function wasn't defined in strict mode, its "this" object + // should be the same as the "this" object in this function, i.e. the global + // object. + do_check_eq(result, this); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_spawn_function_this_strict() +{ + "use strict"; + Task.spawn(function () { + return this; + }).then(function (result) { + // Since the task function was defined in strict mode, its "this" object + // should be undefined. + do_check_eq(typeof(result), "undefined"); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_spawn_function_returning_promise() +{ + Task.spawn(function () { + return promiseResolvedLater("Resolution value."); + }).then(function (result) { + do_check_eq("Resolution value.", result); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_spawn_function_exceptions() +{ + Task.spawn(function () { + throw new Error("Exception uncaught by task."); + }).then(function (result) { + do_throw("Unexpected success!"); + }, function (ex) { + do_check_eq("Exception uncaught by task.", ex.message); + run_next_test(); + }); +}); + +add_test(function test_spawn_function_taskresult() +{ + Task.spawn(function () { + throw new Task.Result("Task result"); + }).then(function (result) { + do_check_eq("Task result", result); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_yielded_undefined() +{ + Task.spawn(function () { + yield; + throw new Task.Result("We continued correctly."); + }).then(function (result) { + do_check_eq("We continued correctly.", result); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_yielded_primitive() +{ + Task.spawn(function () { + throw new Task.Result("Primitive " + (yield "value.")); + }).then(function (result) { + do_check_eq("Primitive value.", result); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_star_normal() +{ + Task.spawn(function* () { + let result = yield Promise.resolve("Value"); + for (let i = 0; i < 3; i++) { + result += yield promiseResolvedLater("!"); + } + return "Task result: " + result; + }).then(function (result) { + do_check_eq("Task result: Value!!!", result); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_star_exceptions() +{ + Task.spawn(function* () { + try { + yield Promise.reject("Rejection result by promise."); + do_throw("Exception expected because the promise was rejected."); + } catch (ex) { + // We catch this exception now, we will throw a different one later. + do_check_eq("Rejection result by promise.", ex); + } + throw new Error("Exception uncaught by task."); + }).then(function (result) { + do_throw("Unexpected success!"); + }, function (ex) { + do_check_eq("Exception uncaught by task.", ex.message); + run_next_test(); + }); +}); + +add_test(function test_star_recursion() +{ + function* task_fibonacci(n) { + return n < 2 ? n : (yield task_fibonacci(n - 1)) + + (yield task_fibonacci(n - 2)); + }; + + Task.spawn(task_fibonacci(6)).then(function (result) { + do_check_eq(8, result); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_mixed_legacy_and_star() +{ + Task.spawn(function* () { + return yield (function() { + throw new Task.Result(yield 5); + })(); + }).then(function (result) { + do_check_eq(5, result); + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_async_function_from_generator() +{ + Task.spawn(function* () { + let object = { + asyncFunction: Task.async(function* (param) { + do_check_eq(this, object); + return param; + }) + }; + + // Ensure the async function returns a promise that resolves as expected. + do_check_eq((yield object.asyncFunction(1)), 1); + + // Ensure a second call to the async function also returns such a promise. + do_check_eq((yield object.asyncFunction(3)), 3); + }).then(function () { + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_async_function_from_function() +{ + Task.spawn(function* () { + return Task.spawn(function* () { + let object = { + asyncFunction: Task.async(function (param) { + do_check_eq(this, object); + return param; + }) + }; + + // Ensure the async function returns a promise that resolves as expected. + do_check_eq((yield object.asyncFunction(5)), 5); + + // Ensure a second call to the async function also returns such a promise. + do_check_eq((yield object.asyncFunction(7)), 7); + }); + }).then(function () { + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_async_function_that_throws_rejects_promise() +{ + Task.spawn(function* () { + let object = { + asyncFunction: Task.async(function* () { + throw "Rejected!"; + }) + }; + + yield object.asyncFunction(); + }).then(function () { + do_throw("unexpected success calling async function that throws error"); + }, function (ex) { + do_check_eq(ex, "Rejected!"); + run_next_test(); + }); +}); + +add_test(function test_async_return_function() +{ + Task.spawn(function* () { + // Ensure an async function that returns a function resolves to the function + // itself instead of calling the function and resolving to its return value. + return Task.spawn(function* () { + let returnValue = function () { + return "These aren't the droids you're looking for."; + }; + + let asyncFunction = Task.async(function () { + return returnValue; + }); + + do_check_eq((yield asyncFunction()), returnValue); + }); + }).then(function () { + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_async_throw_argument_not_function() +{ + Task.spawn(function* () { + // Ensure Task.async throws if its aTask argument is not a function. + Assert.throws(() => Task.async("not a function"), + /aTask argument must be a function/); + }).then(function () { + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + +add_test(function test_async_throw_on_function_in_place_of_promise() +{ + Task.spawn(function* () { + // Ensure Task.spawn throws if passed an async function. + Assert.throws(() => Task.spawn(Task.async(function* () {})), + /Cannot use an async function in place of a promise/); + }).then(function () { + run_next_test(); + }, function (ex) { + do_throw("Unexpected error: " + ex); + }); +}); + + +////////////////// Test rewriting of stack traces + +// Backup Task.Debuggin.maintainStack. +// Will be restored by `exit_stack_tests`. +var maintainStack; +add_test(function enter_stack_tests() { + maintainStack = Task.Debugging.maintainStack; + Task.Debugging.maintainStack = true; + run_next_test(); +}); + + +/** + * Ensure that a list of frames appear in a stack, in the right order + */ +function do_check_rewritten_stack(frames, ex) { + do_print("Checking that the expected frames appear in the right order"); + do_print(frames.join(", ")); + let stack = ex.stack; + do_print(stack); + + let framesFound = 0; + let lineNumber = 0; + let reLine = /([^\r\n])+/g; + let match; + while (framesFound < frames.length && (match = reLine.exec(stack))) { + let line = match[0]; + let frame = frames[framesFound]; + do_print("Searching for " + frame + " in line " + line); + if (line.indexOf(frame) != -1) { + do_print("Found " + frame); + ++framesFound; + } else { + do_print("Didn't find " + frame); + } + } + + if (framesFound >= frames.length) { + return; + } + do_throw("Did not find: " + frames.slice(framesFound).join(", ") + + " in " + stack.substr(reLine.lastIndex)); + + do_print("Ensuring that we have removed Task.jsm, Promise.jsm"); + do_check_true(stack.indexOf("Task.jsm") == -1); + do_check_true(stack.indexOf("Promise.jsm") == -1); + do_check_true(stack.indexOf("Promise-backend.js") == -1); +} + + +// Test that we get an acceptable rewritten stack when we launch +// an error in a Task.spawn. +add_test(function test_spawn_throw_stack() { + Task.spawn(function* task_spawn_throw_stack() { + for (let i = 0; i < 5; ++i) { + yield Promise.resolve(); // Without stack rewrite, this would lose valuable information + } + throw new Error("BOOM"); + }).then(do_throw, function(ex) { + do_check_rewritten_stack(["task_spawn_throw_stack", + "test_spawn_throw_stack"], + ex); + run_next_test(); + }); +}); + +// Test that we get an acceptable rewritten stack when we yield +// a rejection in a Task.spawn. +add_test(function test_spawn_yield_reject_stack() { + Task.spawn(function* task_spawn_yield_reject_stack() { + for (let i = 0; i < 5; ++i) { + yield Promise.resolve(); // Without stack rewrite, this would lose valuable information + } + yield Promise.reject(new Error("BOOM")); + }).then(do_throw, function(ex) { + do_check_rewritten_stack(["task_spawn_yield_reject_stack", + "test_spawn_yield_reject_stack"], + ex); + run_next_test(); + }); +}); + +// Test that we get an acceptable rewritten stack when we launch +// an error in a Task.async function. +add_test(function test_async_function_throw_stack() { + let task_async_function_throw_stack = Task.async(function*() { + for (let i = 0; i < 5; ++i) { + yield Promise.resolve(); // Without stack rewrite, this would lose valuable information + } + throw new Error("BOOM"); + })().then(do_throw, function(ex) { + do_check_rewritten_stack(["task_async_function_throw_stack", + "test_async_function_throw_stack"], + ex); + run_next_test(); + }); +}); + +// Test that we get an acceptable rewritten stack when we launch +// an error in a Task.async function. +add_test(function test_async_function_yield_reject_stack() { + let task_async_function_yield_reject_stack = Task.async(function*() { + for (let i = 0; i < 5; ++i) { + yield Promise.resolve(); // Without stack rewrite, this would lose valuable information + } + yield Promise.reject(new Error("BOOM")); + })().then(do_throw, function(ex) { + do_check_rewritten_stack(["task_async_function_yield_reject_stack", + "test_async_function_yield_reject_stack"], + ex); + run_next_test(); + }); +}); + +// Test that we get an acceptable rewritten stack when we launch +// an error in a Task.async function. +add_test(function test_async_method_throw_stack() { + let object = { + task_async_method_throw_stack: Task.async(function*() { + for (let i = 0; i < 5; ++i) { + yield Promise.resolve(); // Without stack rewrite, this would lose valuable information + } + throw new Error("BOOM"); + }) + }; + object.task_async_method_throw_stack().then(do_throw, function(ex) { + do_check_rewritten_stack(["task_async_method_throw_stack", + "test_async_method_throw_stack"], + ex); + run_next_test(); + }); +}); + +// Test that we get an acceptable rewritten stack when we launch +// an error in a Task.async function. +add_test(function test_async_method_yield_reject_stack() { + let object = { + task_async_method_yield_reject_stack: Task.async(function*() { + for (let i = 0; i < 5; ++i) { + yield Promise.resolve(); // Without stack rewrite, this would lose valuable information + } + yield Promise.reject(new Error("BOOM")); + }) + }; + object.task_async_method_yield_reject_stack().then(do_throw, function(ex) { + do_check_rewritten_stack(["task_async_method_yield_reject_stack", + "test_async_method_yield_reject_stack"], + ex); + run_next_test(); + }); +}); + +// Test that two tasks whose execution takes place interleaved do not capture each other's stack. +add_test(function test_throw_stack_do_not_capture_the_wrong_task() { + for (let iter_a of [3, 4, 5]) { // Vary the interleaving + for (let iter_b of [3, 4, 5]) { + Task.spawn(function* task_a() { + for (let i = 0; i < iter_a; ++i) { + yield Promise.resolve(); + } + throw new Error("BOOM"); + }).then(do_throw, function(ex) { + do_check_rewritten_stack(["task_a", + "test_throw_stack_do_not_capture_the_wrong_task"], + ex); + do_check_true(!ex.stack.includes("task_b")); + run_next_test(); + }); + Task.spawn(function* task_b() { + for (let i = 0; i < iter_b; ++i) { + yield Promise.resolve(); + } + }); + } + } +}); + +// Put things together +add_test(function test_throw_complex_stack() +{ + // Setup the following stack: + // inner_method() + // task_3() + // task_2() + // task_1() + // function_3() + // function_2() + // function_1() + // test_throw_complex_stack() + (function function_1() { + return (function function_2() { + return (function function_3() { + return Task.spawn(function* task_1() { + yield Promise.resolve(); + try { + yield Task.spawn(function* task_2() { + yield Promise.resolve(); + yield Task.spawn(function* task_3() { + yield Promise.resolve(); + let inner_object = { + inner_method: Task.async(function*() { + throw new Error("BOOM"); + }) + }; + yield Promise.resolve(); + yield inner_object.inner_method(); + }); + }); + } catch (ex) { + yield Promise.resolve(); + throw ex; + } + }); + })(); + })(); + })().then( + () => do_throw("Shouldn't have succeeded"), + (ex) => { + let expect = ["inner_method", + "task_3", + "task_2", + "task_1", + "function_3", + "function_2", + "function_1", + "test_throw_complex_stack"]; + do_check_rewritten_stack(expect, ex); + + run_next_test(); + }); +}); + +add_test(function test_without_maintainStack() { + do_print("Calling generateReadableStack without a Task"); + Task.Debugging.generateReadableStack(new Error("Not a real error")); + + Task.Debugging.maintainStack = false; + + do_print("Calling generateReadableStack with neither a Task nor maintainStack"); + Task.Debugging.generateReadableStack(new Error("Not a real error")); + + do_print("Calling generateReadableStack without maintainStack"); + Task.spawn(function*() { + Task.Debugging.generateReadableStack(new Error("Not a real error")); + run_next_test(); + }); +}); + +add_test(function exit_stack_tests() { + Task.Debugging.maintainStack = maintainStack; + run_next_test(); +}); + diff --git a/toolkit/modules/tests/xpcshell/test_timer.js b/toolkit/modules/tests/xpcshell/test_timer.js new file mode 100644 index 0000000000..57e3006637 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_timer.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests exports from Timer.jsm + +var imported = {}; +Components.utils.import("resource://gre/modules/Timer.jsm", imported); + +function run_test() { + run_next_test(); +} + +add_task(function* test_setTimeout() { + let timeout1 = imported.setTimeout(() => do_throw("Should not be called"), 100); + do_check_eq(typeof timeout1, "number", "setTimeout returns a number"); + do_check_true(timeout1 > 0, "setTimeout returns a positive number"); + + imported.clearTimeout(timeout1); + + yield new Promise((resolve) => { + let timeout2 = imported.setTimeout((param1, param2) => { + do_check_true(true, "Should be called"); + do_check_eq(param1, 5, "first parameter is correct"); + do_check_eq(param2, "test", "second parameter is correct"); + resolve(); + }, 100, 5, "test"); + + do_check_eq(typeof timeout2, "number", "setTimeout returns a number"); + do_check_true(timeout2 > 0, "setTimeout returns a positive number"); + do_check_neq(timeout1, timeout2, "Calling setTimeout again returns a different value"); + }); +}); + +add_task(function* test_setInterval() { + let interval1 = imported.setInterval(() => do_throw("Should not be called!"), 100); + do_check_eq(typeof interval1, "number", "setInterval returns a number"); + do_check_true(interval1 > 0, "setTimeout returns a positive number"); + + imported.clearInterval(interval1); + + const EXPECTED_CALLS = 5; + let calls = 0; + + yield new Promise((resolve) => { + let interval2 = imported.setInterval((param1, param2) => { + do_check_true(true, "Should be called"); + do_check_eq(param1, 15, "first parameter is correct"); + do_check_eq(param2, "hola", "second parameter is correct"); + if (calls >= EXPECTED_CALLS) { + resolve(); + } + calls++; + }, 100, 15, "hola"); + }); +}); diff --git a/toolkit/modules/tests/xpcshell/test_web_channel.js b/toolkit/modules/tests/xpcshell/test_web_channel.js new file mode 100644 index 0000000000..05f1bc03df --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_web_channel.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var Cu = Components.utils; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/WebChannel.jsm"); + +const ERROR_ID_ORIGIN_REQUIRED = "WebChannel id and originOrPermission are required."; +const VALID_WEB_CHANNEL_ID = "id"; +const URL_STRING = "http://example.com"; +const VALID_WEB_CHANNEL_ORIGIN = Services.io.newURI(URL_STRING, null, null); +const TEST_PERMISSION_NAME = "test-webchannel-permissions"; + +var MockWebChannelBroker = { + _channelMap: new Map(), + registerChannel: function(channel) { + if (!this._channelMap.has(channel)) { + this._channelMap.set(channel); + } + }, + unregisterChannel: function (channelToRemove) { + this._channelMap.delete(channelToRemove) + } +}; + +function run_test() { + run_next_test(); +} + +/** + * Web channel tests + */ + +/** + * Test channel listening with originOrPermission being an nsIURI. + */ +add_task(function test_web_channel_listen() { + return new Promise((resolve, reject) => { + let channel = new WebChannel(VALID_WEB_CHANNEL_ID, VALID_WEB_CHANNEL_ORIGIN, { + broker: MockWebChannelBroker + }); + let delivered = 0; + do_check_eq(channel.id, VALID_WEB_CHANNEL_ID); + do_check_eq(channel._originOrPermission.spec, VALID_WEB_CHANNEL_ORIGIN.spec); + do_check_eq(channel._deliverCallback, null); + + channel.listen(function(id, message, target) { + do_check_eq(id, VALID_WEB_CHANNEL_ID); + do_check_true(message); + do_check_true(message.command); + do_check_true(target.sender); + delivered++; + // 2 messages should be delivered + if (delivered === 2) { + channel.stopListening(); + do_check_eq(channel._deliverCallback, null); + resolve(); + } + }); + + // send two messages + channel.deliver({ + id: VALID_WEB_CHANNEL_ID, + message: { + command: "one" + } + }, { sender: true }); + + channel.deliver({ + id: VALID_WEB_CHANNEL_ID, + message: { + command: "two" + } + }, { sender: true }); + }); +}); + +/** + * Test channel listening with originOrPermission being a permission string. + */ +add_task(function test_web_channel_listen_permission() { + return new Promise((resolve, reject) => { + // add a new permission + Services.perms.add(VALID_WEB_CHANNEL_ORIGIN, TEST_PERMISSION_NAME, Services.perms.ALLOW_ACTION); + do_register_cleanup(() => Services.perms.remove(VALID_WEB_CHANNEL_ORIGIN, TEST_PERMISSION_NAME)); + let channel = new WebChannel(VALID_WEB_CHANNEL_ID, TEST_PERMISSION_NAME, { + broker: MockWebChannelBroker + }); + let delivered = 0; + do_check_eq(channel.id, VALID_WEB_CHANNEL_ID); + do_check_eq(channel._originOrPermission, TEST_PERMISSION_NAME); + do_check_eq(channel._deliverCallback, null); + + channel.listen(function(id, message, target) { + do_check_eq(id, VALID_WEB_CHANNEL_ID); + do_check_true(message); + do_check_true(message.command); + do_check_true(target.sender); + delivered++; + // 2 messages should be delivered + if (delivered === 2) { + channel.stopListening(); + do_check_eq(channel._deliverCallback, null); + resolve(); + } + }); + + // send two messages + channel.deliver({ + id: VALID_WEB_CHANNEL_ID, + message: { + command: "one" + } + }, { sender: true }); + + channel.deliver({ + id: VALID_WEB_CHANNEL_ID, + message: { + command: "two" + } + }, { sender: true }); + }); +}); + + +/** + * Test constructor + */ +add_test(function test_web_channel_constructor() { + do_check_eq(constructorTester(), ERROR_ID_ORIGIN_REQUIRED); + do_check_eq(constructorTester(undefined), ERROR_ID_ORIGIN_REQUIRED); + do_check_eq(constructorTester(undefined, VALID_WEB_CHANNEL_ORIGIN), ERROR_ID_ORIGIN_REQUIRED); + do_check_eq(constructorTester(VALID_WEB_CHANNEL_ID, undefined), ERROR_ID_ORIGIN_REQUIRED); + do_check_false(constructorTester(VALID_WEB_CHANNEL_ID, VALID_WEB_CHANNEL_ORIGIN)); + + run_next_test(); +}); + +function constructorTester(id, origin) { + try { + new WebChannel(id, origin); + } catch (e) { + return e.message; + } + return false; +} diff --git a/toolkit/modules/tests/xpcshell/test_web_channel_broker.js b/toolkit/modules/tests/xpcshell/test_web_channel_broker.js new file mode 100644 index 0000000000..132597c207 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_web_channel_broker.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var Cu = Components.utils; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/WebChannel.jsm"); + +const VALID_WEB_CHANNEL_ID = "id"; +const URL_STRING = "http://example.com"; +const VALID_WEB_CHANNEL_ORIGIN = Services.io.newURI(URL_STRING, null, null); + +function run_test() { + run_next_test(); +} + +/** + * Test WebChannelBroker channel map + */ +add_test(function test_web_channel_broker_channel_map() { + let channel = {}; + let channel2 = {}; + + do_check_eq(WebChannelBroker._channelMap.size, 0); + do_check_false(WebChannelBroker._messageListenerAttached); + + // make sure _channelMap works correctly + WebChannelBroker.registerChannel(channel); + do_check_eq(WebChannelBroker._channelMap.size, 1); + do_check_true(WebChannelBroker._messageListenerAttached); + + WebChannelBroker.registerChannel(channel2); + do_check_eq(WebChannelBroker._channelMap.size, 2); + + WebChannelBroker.unregisterChannel(channel); + do_check_eq(WebChannelBroker._channelMap.size, 1); + + // make sure the correct channel is unregistered + do_check_false(WebChannelBroker._channelMap.has(channel)); + do_check_true(WebChannelBroker._channelMap.has(channel2)); + + WebChannelBroker.unregisterChannel(channel2); + do_check_eq(WebChannelBroker._channelMap.size, 0); + + run_next_test(); +}); + + +/** + * Test WebChannelBroker _listener test + */ +add_task(function test_web_channel_broker_listener() { + return new Promise((resolve, reject) => { + var channel = { + id: VALID_WEB_CHANNEL_ID, + _originCheckCallback: requestPrincipal => { + return VALID_WEB_CHANNEL_ORIGIN.prePath === requestPrincipal.origin; + }, + deliver: function(data, sender) { + do_check_eq(data.id, VALID_WEB_CHANNEL_ID); + do_check_eq(data.message.command, "hello"); + do_check_neq(sender, undefined); + WebChannelBroker.unregisterChannel(channel); + resolve(); + } + }; + + WebChannelBroker.registerChannel(channel); + + var mockEvent = { + data: { + id: VALID_WEB_CHANNEL_ID, + message: { + command: "hello" + } + }, + principal: { + origin: URL_STRING + }, + objects: { + }, + }; + + WebChannelBroker._listener(mockEvent); + }); +}); diff --git a/toolkit/modules/tests/xpcshell/xpcshell.ini b/toolkit/modules/tests/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..65d7c45e98 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/xpcshell.ini @@ -0,0 +1,75 @@ +[DEFAULT] +head = +tail = +support-files = + propertyLists/bug710259_propertyListBinary.plist + propertyLists/bug710259_propertyListXML.plist + chromeappsstore.sqlite + zips/zen.zip + +[test_BinarySearch.js] +skip-if = toolkit == 'android' +[test_CanonicalJSON.js] +[test_client_id.js] +skip-if = toolkit == 'android' +[test_Color.js] +[test_DeferredTask.js] +skip-if = toolkit == 'android' +[test_FileUtils.js] +skip-if = toolkit == 'android' +[test_FinderIterator.js] +[test_GMPInstallManager.js] +skip-if = toolkit == 'android' +[test_Http.js] +skip-if = toolkit == 'android' +[test_Integration.js] +[test_jsesc.js] +skip-if = toolkit == 'android' +[test_JSONFile.js] +skip-if = toolkit == 'android' +[test_Log.js] +skip-if = toolkit == 'android' +[test_MatchPattern.js] +skip-if = toolkit == 'android' +[test_MatchGlobs.js] +skip-if = toolkit == 'android' +[test_MatchURLFilters.js] +skip-if = toolkit == 'android' +[test_NewTabUtils.js] +skip-if = toolkit == 'android' +[test_ObjectUtils.js] +skip-if = toolkit == 'android' +[test_ObjectUtils_strict.js] +skip-if = toolkit == 'android' +[test_PermissionsUtils.js] +skip-if = toolkit == 'android' +[test_Preferences.js] +skip-if = toolkit == 'android' +[test_Promise.js] +skip-if = toolkit == 'android' +[test_PromiseUtils.js] +skip-if = toolkit == 'android' +[test_propertyListsUtils.js] +skip-if = toolkit == 'android' +[test_readCertPrefs.js] +skip-if = toolkit == 'android' +[test_Services.js] +skip-if = toolkit == 'android' +[test_session_recorder.js] +skip-if = toolkit == 'android' +[test_sqlite.js] +skip-if = toolkit == 'android' +[test_sqlite_shutdown.js] +skip-if = toolkit == 'android' +[test_task.js] +skip-if = toolkit == 'android' +[test_timer.js] +skip-if = toolkit == 'android' +[test_UpdateUtils_url.js] +[test_UpdateUtils_updatechannel.js] +[test_web_channel.js] +[test_web_channel_broker.js] +[test_ZipUtils.js] +skip-if = toolkit == 'android' +[test_Log_stackTrace.js] +[test_servicerequest_xhr.js] diff --git a/toolkit/modules/tests/xpcshell/zips/zen.zip b/toolkit/modules/tests/xpcshell/zips/zen.zip Binary files differnew file mode 100644 index 0000000000..4756247936 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/zips/zen.zip diff --git a/toolkit/modules/third_party/jsesc/README b/toolkit/modules/third_party/jsesc/README new file mode 100644 index 0000000000..6665923c42 --- /dev/null +++ b/toolkit/modules/third_party/jsesc/README @@ -0,0 +1,10 @@ +This code comes from an externally managed library, available at +<https://github.com/mathiasbynens/jsesc>. Bugs should be reported directly +upstream and integrated back here. + +In order to regenerate this file, you need to do the following: + + $ git clone git@github.com:mathiasbynens/jsesc.git && cd jsesc + $ grunt template + $ export MOZ_JSESC="../mozilla-central/toolkit/modules/third_party/jsesc" + $ cat $MOZ_JSESC/fx-header jsesc.js > $MOZ_JSESC/jsesc.js diff --git a/toolkit/modules/third_party/jsesc/fx-header b/toolkit/modules/third_party/jsesc/fx-header new file mode 100644 index 0000000000..fbac20f1c0 --- /dev/null +++ b/toolkit/modules/third_party/jsesc/fx-header @@ -0,0 +1,26 @@ +/* +DO NOT TOUCH THIS FILE DIRECTLY. See the README for instructions. + +Copyright Mathias Bynens <https://mathiasbynens.be/> + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +this.EXPORTED_SYMBOLS = ["jsesc"]; diff --git a/toolkit/modules/third_party/jsesc/jsesc.js b/toolkit/modules/third_party/jsesc/jsesc.js new file mode 100644 index 0000000000..0145101d53 --- /dev/null +++ b/toolkit/modules/third_party/jsesc/jsesc.js @@ -0,0 +1,299 @@ +/* +DO NOT TOUCH THIS FILE DIRECTLY. See the README for instructions. + +Copyright Mathias Bynens <https://mathiasbynens.be/> + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +this.EXPORTED_SYMBOLS = ["jsesc"]; +/*! https://mths.be/jsesc v1.0.0 by @mathias */ +;(function(root) { + + // Detect free variables `exports` + var freeExports = typeof exports == 'object' && exports; + + // Detect free variable `module` + var freeModule = typeof module == 'object' && module && + module.exports == freeExports && module; + + // Detect free variable `global`, from Node.js or Browserified code, + // and use it as `root` + var freeGlobal = typeof global == 'object' && global; + if (freeGlobal.global === freeGlobal || freeGlobal.window === freeGlobal) { + root = freeGlobal; + } + + /*--------------------------------------------------------------------------*/ + + var object = {}; + var hasOwnProperty = object.hasOwnProperty; + var forOwn = function(object, callback) { + var key; + for (key in object) { + if (hasOwnProperty.call(object, key)) { + callback(key, object[key]); + } + } + }; + + var extend = function(destination, source) { + if (!source) { + return destination; + } + forOwn(source, function(key, value) { + destination[key] = value; + }); + return destination; + }; + + var forEach = function(array, callback) { + var length = array.length; + var index = -1; + while (++index < length) { + callback(array[index]); + } + }; + + var toString = object.toString; + var isArray = function(value) { + return toString.call(value) == '[object Array]'; + }; + var isObject = function(value) { + // This is a very simple check, but it’s good enough for what we need. + return toString.call(value) == '[object Object]'; + }; + var isString = function(value) { + return typeof value == 'string' || + toString.call(value) == '[object String]'; + }; + var isFunction = function(value) { + // In a perfect world, the `typeof` check would be sufficient. However, + // in Chrome 1–12, `typeof /x/ == 'object'`, and in IE 6–8 + // `typeof alert == 'object'` and similar for other host objects. + return typeof value == 'function' || + toString.call(value) == '[object Function]'; + }; + + /*--------------------------------------------------------------------------*/ + + // https://mathiasbynens.be/notes/javascript-escapes#single + var singleEscapes = { + '"': '\\"', + '\'': '\\\'', + '\\': '\\\\', + '\b': '\\b', + '\f': '\\f', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t' + // `\v` is omitted intentionally, because in IE < 9, '\v' == 'v'. + // '\v': '\\x0B' + }; + var regexSingleEscape = /["'\\\b\f\n\r\t]/; + + var regexDigit = /[0-9]/; + var regexWhitelist = /[ !#-&\(-\[\]-~]/; + + var jsesc = function(argument, options) { + // Handle options + var defaults = { + 'escapeEverything': false, + 'quotes': 'single', + 'wrap': false, + 'es6': false, + 'json': false, + 'compact': true, + 'lowercaseHex': false, + 'indent': '\t', + '__indent__': '' + }; + var json = options && options.json; + if (json) { + defaults.quotes = 'double'; + defaults.wrap = true; + } + options = extend(defaults, options); + if (options.quotes != 'single' && options.quotes != 'double') { + options.quotes = 'single'; + } + var quote = options.quotes == 'double' ? '"' : '\''; + var compact = options.compact; + var indent = options.indent; + var oldIndent; + var newLine = compact ? '' : '\n'; + var result; + var isEmpty = true; + + if (json && argument && isFunction(argument.toJSON)) { + argument = argument.toJSON(); + } + + if (!isString(argument)) { + if (isArray(argument)) { + result = []; + options.wrap = true; + oldIndent = options.__indent__; + indent += oldIndent; + options.__indent__ = indent; + forEach(argument, function(value) { + isEmpty = false; + result.push( + (compact ? '' : indent) + + jsesc(value, options) + ); + }); + if (isEmpty) { + return '[]'; + } + return '[' + newLine + result.join(',' + newLine) + newLine + + (compact ? '' : oldIndent) + ']'; + } else if (!isObject(argument)) { + if (json) { + // For some values (e.g. `undefined`, `function` objects), + // `JSON.stringify(value)` returns `undefined` (which isn’t valid + // JSON) instead of `'null'`. + return JSON.stringify(argument) || 'null'; + } + return String(argument); + } else { // it’s an object + result = []; + options.wrap = true; + oldIndent = options.__indent__; + indent += oldIndent; + options.__indent__ = indent; + forOwn(argument, function(key, value) { + isEmpty = false; + result.push( + (compact ? '' : indent) + + jsesc(key, options) + ':' + + (compact ? '' : ' ') + + jsesc(value, options) + ); + }); + if (isEmpty) { + return '{}'; + } + return '{' + newLine + result.join(',' + newLine) + newLine + + (compact ? '' : oldIndent) + '}'; + } + } + + var string = argument; + // Loop over each code unit in the string and escape it + var index = -1; + var length = string.length; + var first; + var second; + var codePoint; + result = ''; + while (++index < length) { + var character = string.charAt(index); + if (options.es6) { + first = string.charCodeAt(index); + if ( // check if it’s the start of a surrogate pair + first >= 0xD800 && first <= 0xDBFF && // high surrogate + length > index + 1 // there is a next code unit + ) { + second = string.charCodeAt(index + 1); + if (second >= 0xDC00 && second <= 0xDFFF) { // low surrogate + // https://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae + codePoint = (first - 0xD800) * 0x400 + second - 0xDC00 + 0x10000; + var hexadecimal = codePoint.toString(16); + if (!options.lowercaseHex) { + hexadecimal = hexadecimal.toUpperCase(); + } + result += '\\u{' + hexadecimal + '}'; + index++; + continue; + } + } + } + if (!options.escapeEverything) { + if (regexWhitelist.test(character)) { + // It’s a printable ASCII character that is not `"`, `'` or `\`, + // so don’t escape it. + result += character; + continue; + } + if (character == '"') { + result += quote == character ? '\\"' : character; + continue; + } + if (character == '\'') { + result += quote == character ? '\\\'' : character; + continue; + } + } + if ( + character == '\0' && + !json && + !regexDigit.test(string.charAt(index + 1)) + ) { + result += '\\0'; + continue; + } + if (regexSingleEscape.test(character)) { + // no need for a `hasOwnProperty` check here + result += singleEscapes[character]; + continue; + } + var charCode = character.charCodeAt(0); + var hexadecimal = charCode.toString(16); + if (!options.lowercaseHex) { + hexadecimal = hexadecimal.toUpperCase(); + } + var longhand = hexadecimal.length > 2 || json; + var escaped = '\\' + (longhand ? 'u' : 'x') + + ('0000' + hexadecimal).slice(longhand ? -4 : -2); + result += escaped; + continue; + } + if (options.wrap) { + result = quote + result + quote; + } + return result; + }; + + jsesc.version = '1.0.0'; + + /*--------------------------------------------------------------------------*/ + + // Some AMD build optimizers, like r.js, check for specific condition patterns + // like the following: + if ( + typeof define == 'function' && + typeof define.amd == 'object' && + define.amd + ) { + define(function() { + return jsesc; + }); + } else if (freeExports && !freeExports.nodeType) { + if (freeModule) { // in Node.js or RingoJS v0.8.0+ + freeModule.exports = jsesc; + } else { // in Narwhal or RingoJS v0.7.0- + freeExports.jsesc = jsesc; + } + } else { // in Rhino or a web browser + root.jsesc = jsesc; + } + +}(this)); |