diff options
Diffstat (limited to 'addon-sdk/source/lib/sdk')
216 files changed, 30621 insertions, 0 deletions
diff --git a/addon-sdk/source/lib/sdk/addon/bootstrap.js b/addon-sdk/source/lib/sdk/addon/bootstrap.js new file mode 100644 index 000000000..0397d91e5 --- /dev/null +++ b/addon-sdk/source/lib/sdk/addon/bootstrap.js @@ -0,0 +1,182 @@ +/* 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 } = require("chrome"); +const { NetUtil } = require("resource://gre/modules/NetUtil.jsm"); +const { Task: { spawn } } = require("resource://gre/modules/Task.jsm"); +const { readURI } = require("sdk/net/url"); +const { mount, unmount } = require("sdk/uri/resource"); +const { setTimeout } = require("sdk/timers"); +const { Loader, Require, Module, main, unload } = require("toolkit/loader"); +const prefs = require("sdk/preferences/service"); + +// load below now, so that it can be used by sdk/addon/runner +// see bug https://bugzilla.mozilla.org/show_bug.cgi?id=1042239 +const Startup = Cu.import("resource://gre/modules/sdk/system/Startup.js", {}); + +const REASON = [ "unknown", "startup", "shutdown", "enable", "disable", + "install", "uninstall", "upgrade", "downgrade" ]; + +const UUID_PATTERN = /^\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\}$/; +// Takes add-on ID and normalizes it to a domain name so that add-on +// can be mapped to resource://domain/ +const readDomain = id => + // If only `@` character is the first one, than just substract it, + // otherwise fallback to legacy normalization code path. Note: `.` + // is valid character for resource substitutaiton & we intend to + // make add-on URIs intuitive, so it's best to just stick to an + // add-on author typed input. + id.lastIndexOf("@") === 0 ? id.substr(1).toLowerCase() : + id.toLowerCase(). + replace(/@/g, "-at-"). + replace(/\./g, "-dot-"). + replace(UUID_PATTERN, "$1"); + +const readPaths = id => { + const base = `extensions.modules.${id}.path.`; + const domain = readDomain(id); + return prefs.keys(base).reduce((paths, key) => { + const value = prefs.get(key); + const name = key.replace(base, ""); + const path = name.split(".").join("/"); + const prefix = path.length ? `${path}/` : path; + const uri = value.endsWith("/") ? value : `${value}/`; + const root = `extensions.modules.${domain}.commonjs.path.${name}`; + + mount(root, uri); + + paths[prefix] = `resource://${root}/`; + return paths; + }, {}); +}; + +const Bootstrap = function(mountURI) { + this.mountURI = mountURI; + this.install = this.install.bind(this); + this.uninstall = this.uninstall.bind(this); + this.startup = this.startup.bind(this); + this.shutdown = this.shutdown.bind(this); +}; +Bootstrap.prototype = { + constructor: Bootstrap, + mount(domain, rootURI) { + mount(domain, rootURI); + this.domain = domain; + }, + unmount() { + if (this.domain) { + unmount(this.domain); + this.domain = null; + } + }, + install(addon, reason) { + return new Promise(resolve => resolve()); + }, + uninstall(addon, reason) { + return new Promise(resolve => { + const {id} = addon; + + prefs.reset(`extensions.${id}.sdk.domain`); + prefs.reset(`extensions.${id}.sdk.version`); + prefs.reset(`extensions.${id}.sdk.rootURI`); + prefs.reset(`extensions.${id}.sdk.baseURI`); + prefs.reset(`extensions.${id}.sdk.load.reason`); + + resolve(); + }); + }, + startup(addon, reasonCode) { + const { id, version, resourceURI: { spec: addonURI } } = addon; + const rootURI = this.mountURI || addonURI; + const reason = REASON[reasonCode]; + const self = this; + + return spawn(function*() { + const metadata = JSON.parse(yield readURI(`${rootURI}package.json`)); + const domain = readDomain(id); + const baseURI = `resource://${domain}/`; + + this.mount(domain, rootURI); + + prefs.set(`extensions.${id}.sdk.domain`, domain); + prefs.set(`extensions.${id}.sdk.version`, version); + prefs.set(`extensions.${id}.sdk.rootURI`, rootURI); + prefs.set(`extensions.${id}.sdk.baseURI`, baseURI); + prefs.set(`extensions.${id}.sdk.load.reason`, reason); + + const command = prefs.get(`extensions.${id}.sdk.load.command`); + + const loader = Loader({ + id, + isNative: true, + checkCompatibility: true, + prefixURI: baseURI, + rootURI: baseURI, + name: metadata.name, + paths: Object.assign({ + "": "resource://gre/modules/commonjs/", + "devtools/": "resource://devtools/", + "./": baseURI + }, readPaths(id)), + manifest: metadata, + metadata: metadata, + modules: { + "@test/options": {}, + }, + noQuit: prefs.get(`extensions.${id}.sdk.test.no-quit`, false) + }); + self.loader = loader; + + const module = Module("package.json", `${baseURI}package.json`); + const require = Require(loader, module); + const main = command === "test" ? "sdk/test/runner" : null; + const prefsURI = `${baseURI}defaults/preferences/prefs.js`; + + // Init the 'sdk/webextension' module from the bootstrap addon parameter. + require("sdk/webextension").initFromBootstrapAddonParam(addon); + + const { startup } = require("sdk/addon/runner"); + startup(reason, {loader, main, prefsURI}); + }.bind(this)).catch(error => { + console.error(`Failed to start ${id} addon`, error); + throw error; + }); + }, + shutdown(addon, code) { + this.unmount(); + return this.unload(REASON[code]); + }, + unload(reason) { + return new Promise(resolve => { + const { loader } = this; + if (loader) { + this.loader = null; + unload(loader, reason); + + setTimeout(() => { + for (let uri of Object.keys(loader.sandboxes)) { + let sandbox = loader.sandboxes[uri]; + if (Cu.getClassName(sandbox, true) == "Sandbox") + Cu.nukeSandbox(sandbox); + delete loader.sandboxes[uri]; + delete loader.modules[uri]; + } + + try { + Cu.nukeSandbox(loader.sharedGlobalSandbox); + } catch (e) { + Cu.reportError(e); + } + + resolve(); + }, 1000); + } + else { + resolve(); + } + }); + } +}; +exports.Bootstrap = Bootstrap; diff --git a/addon-sdk/source/lib/sdk/addon/events.js b/addon-sdk/source/lib/sdk/addon/events.js new file mode 100644 index 000000000..45bada6e1 --- /dev/null +++ b/addon-sdk/source/lib/sdk/addon/events.js @@ -0,0 +1,56 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental' +}; + +var { request: hostReq, response: hostRes } = require('./host'); +var { defer: async } = require('../lang/functional'); +var { defer } = require('../core/promise'); +var { emit: emitSync, on, off } = require('../event/core'); +var { uuid } = require('../util/uuid'); +var emit = async(emitSync); + +// Map of IDs to deferreds +var requests = new Map(); + +// May not be necessary to wrap this in `async` +// once promises are async via bug 881047 +var receive = async(function ({data, id, error}) { + let request = requests.get(id); + if (request) { + if (error) request.reject(error); + else request.resolve(clone(data)); + requests.delete(id); + } +}); +on(hostRes, 'data', receive); + +/* + * Send is a helper to be used in client APIs to send + * a request to host + */ +function send (eventName, data) { + let id = uuid(); + let deferred = defer(); + requests.set(id, deferred); + emit(hostReq, 'data', { + id: id, + data: clone(data), + event: eventName + }); + return deferred.promise; +} +exports.send = send; + +/* + * Implement internal structured cloning algorithm in the future? + * http://www.whatwg.org/specs/web-apps/current-work/multipage/common-dom-interfaces.html#internal-structured-cloning-algorithm + */ +function clone (obj) { + return JSON.parse(JSON.stringify(obj || {})); +} diff --git a/addon-sdk/source/lib/sdk/addon/host.js b/addon-sdk/source/lib/sdk/addon/host.js new file mode 100644 index 000000000..91aa0e869 --- /dev/null +++ b/addon-sdk/source/lib/sdk/addon/host.js @@ -0,0 +1,12 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +exports.request = {}; +exports.response = {}; diff --git a/addon-sdk/source/lib/sdk/addon/installer.js b/addon-sdk/source/lib/sdk/addon/installer.js new file mode 100644 index 000000000..bb8cf8d16 --- /dev/null +++ b/addon-sdk/source/lib/sdk/addon/installer.js @@ -0,0 +1,121 @@ +/* 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.metadata = { + "stability": "experimental" +}; + +const { Cc, Ci, Cu } = require("chrome"); +const { AddonManager } = Cu.import("resource://gre/modules/AddonManager.jsm"); +const { defer } = require("../core/promise"); +const { setTimeout } = require("../timers"); + +/** + * `install` method error codes: + * + * https://developer.mozilla.org/en/Addons/Add-on_Manager/AddonManager#AddonInstall_errors + */ +exports.ERROR_NETWORK_FAILURE = AddonManager.ERROR_NETWORK_FAILURE; +exports.ERROR_INCORRECT_HASH = AddonManager.ERROR_INCORRECT_HASH; +exports.ERROR_CORRUPT_FILE = AddonManager.ERROR_CORRUPT_FILE; +exports.ERROR_FILE_ACCESS = AddonManager.ERROR_FILE_ACCESS; + +/** + * Immediatly install an addon. + * + * @param {String} xpiPath + * file path to an xpi file to install + * @return {Promise} + * A promise resolved when the addon is finally installed. + * Resolved with addon id as value or rejected with an error code. + */ +exports.install = function install(xpiPath) { + let { promise, resolve, reject } = defer(); + + // Create nsIFile for the xpi file + let file = Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsILocalFile); + try { + file.initWithPath(xpiPath); + } + catch(e) { + reject(exports.ERROR_FILE_ACCESS); + return promise; + } + + // Listen for installation end + let listener = { + onInstallEnded: function(aInstall, aAddon) { + aInstall.removeListener(listener); + // Bug 749745: on FF14+, onInstallEnded is called just before `startup()` + // is called, but we expect to resolve the promise only after it. + // As startup is called synchronously just after onInstallEnded, + // a simple setTimeout(0) is enough + setTimeout(resolve, 0, aAddon.id); + }, + onInstallFailed: function (aInstall) { + aInstall.removeListener(listener); + reject(aInstall.error); + }, + onDownloadFailed: function(aInstall) { + this.onInstallFailed(aInstall); + } + }; + + // Order AddonManager to install the addon + AddonManager.getInstallForFile(file, function(install) { + if (install.error == 0) { + install.addListener(listener); + install.install(); + } else { + reject(install.error); + } + }); + + return promise; +}; + +exports.uninstall = function uninstall(addonId) { + let { promise, resolve, reject } = defer(); + + // Listen for uninstallation end + let listener = { + onUninstalled: function onUninstalled(aAddon) { + if (aAddon.id != addonId) + return; + AddonManager.removeAddonListener(listener); + resolve(); + } + }; + AddonManager.addAddonListener(listener); + + // Order Addonmanager to uninstall the addon + getAddon(addonId).then(addon => addon.uninstall(), reject); + + return promise; +}; + +exports.disable = function disable(addonId) { + return getAddon(addonId).then(addon => { + addon.userDisabled = true; + return addonId; + }); +}; + +exports.enable = function enabled(addonId) { + return getAddon(addonId).then(addon => { + addon.userDisabled = false; + return addonId; + }); +}; + +exports.isActive = function isActive(addonId) { + return getAddon(addonId).then(addon => addon.isActive && !addon.appDisabled); +}; + +const getAddon = function getAddon (id) { + let { promise, resolve, reject } = defer(); + AddonManager.getAddonByID(id, addon => addon ? resolve(addon) : reject()); + return promise; +} +exports.getAddon = getAddon; diff --git a/addon-sdk/source/lib/sdk/addon/manager.js b/addon-sdk/source/lib/sdk/addon/manager.js new file mode 100644 index 000000000..7ac0a7d6e --- /dev/null +++ b/addon-sdk/source/lib/sdk/addon/manager.js @@ -0,0 +1,18 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { AddonManager } = require("resource://gre/modules/AddonManager.jsm"); +const { defer } = require("../core/promise"); + +function getAddonByID(id) { + let { promise, resolve } = defer(); + AddonManager.getAddonByID(id, resolve); + return promise; +} +exports.getAddonByID = getAddonByID; diff --git a/addon-sdk/source/lib/sdk/addon/runner.js b/addon-sdk/source/lib/sdk/addon/runner.js new file mode 100644 index 000000000..3977a04e4 --- /dev/null +++ b/addon-sdk/source/lib/sdk/addon/runner.js @@ -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/. */ + +module.metadata = { + "stability": "experimental" +}; + +const { Cc, Ci, Cu } = require('chrome'); +const { rootURI, metadata, isNative } = require('@loader/options'); +const { id, loadReason } = require('../self'); +const { descriptor, Sandbox, evaluate, main, resolveURI } = require('toolkit/loader'); +const { once } = require('../system/events'); +const { exit, env, staticArgs } = require('../system'); +const { when: unload } = require('../system/unload'); +const globals = require('../system/globals'); +const xulApp = require('../system/xul-app'); +const { get } = require('../preferences/service'); +const appShellService = Cc['@mozilla.org/appshell/appShellService;1']. + getService(Ci.nsIAppShellService); +const { preferences } = metadata; + +const Startup = Cu.import("resource://gre/modules/sdk/system/Startup.js", {}).exports; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyGetter(this, "BrowserToolboxProcess", function () { + return Cu.import("resource://devtools/client/framework/ToolboxProcess.jsm", {}). + BrowserToolboxProcess; +}); + +// Initializes default preferences +function setDefaultPrefs(prefsURI) { + const prefs = Cc['@mozilla.org/preferences-service;1']. + getService(Ci.nsIPrefService). + QueryInterface(Ci.nsIPrefBranch2); + const branch = prefs.getDefaultBranch(''); + const sandbox = Sandbox({ + name: prefsURI, + prototype: { + pref: function(key, val) { + switch (typeof val) { + case 'boolean': + branch.setBoolPref(key, val); + break; + case 'number': + if (val % 1 == 0) // number must be a integer, otherwise ignore it + branch.setIntPref(key, val); + break; + case 'string': + branch.setCharPref(key, val); + break; + } + } + } + }); + // load preferences. + evaluate(sandbox, prefsURI); +} + +function definePseudo(loader, id, exports) { + let uri = resolveURI(id, loader.mapping); + loader.modules[uri] = { exports: exports }; +} + +function startup(reason, options) { + return Startup.onceInitialized.then(() => { + // Inject globals ASAP in order to have console API working ASAP + Object.defineProperties(options.loader.globals, descriptor(globals)); + + // NOTE: Module is intentionally required only now because it relies + // on existence of hidden window, which does not exists until startup. + let { ready } = require('../addon/window'); + // Load localization manifest and .properties files. + // Run the addon even in case of error (best effort approach) + require('../l10n/loader'). + load(rootURI). + then(null, function failure(error) { + if (!isNative) + console.info("Error while loading localization: " + error.message); + }). + then(function onLocalizationReady(data) { + // Exports data to a pseudo module so that api-utils/l10n/core + // can get access to it + definePseudo(options.loader, '@l10n/data', data ? data : null); + return ready; + }).then(function() { + run(options); + }).then(null, console.exception); + return void 0; // otherwise we raise a warning, see bug 910304 + }); +} + +function run(options) { + try { + // Try initializing HTML localization before running main module. Just print + // an exception in case of error, instead of preventing addon to be run. + try { + // Do not enable HTML localization while running test as it is hard to + // disable. Because unit tests are evaluated in a another Loader who + // doesn't have access to this current loader. + if (options.main !== 'sdk/test/runner') { + require('../l10n/html').enable(); + } + } + catch(error) { + console.exception(error); + } + + // native-options does stuff directly with preferences key from package.json + if (preferences && preferences.length > 0) { + try { + require('../preferences/native-options'). + enable({ preferences: preferences, id: id }). + catch(console.exception); + } + catch (error) { + console.exception(error); + } + } + else { + // keeping support for addons packaged with older SDK versions, + // when cfx didn't include the 'preferences' key in @loader/options + + // Initialize inline options localization, without preventing addon to be + // run in case of error + try { + require('../l10n/prefs').enable(); + } + catch(error) { + console.exception(error); + } + + // TODO: When bug 564675 is implemented this will no longer be needed + // Always set the default prefs, because they disappear on restart + if (options.prefsURI) { + // Only set if `prefsURI` specified + try { + setDefaultPrefs(options.prefsURI); + } + catch (err) { + // cfx bootstrap always passes prefsURI, even in addons without prefs + } + } + } + + // this is where the addon's main.js finally run. + let program = main(options.loader, options.main); + + if (typeof(program.onUnload) === 'function') + unload(program.onUnload); + + if (typeof(program.main) === 'function') { + program.main({ + loadReason: loadReason, + staticArgs: staticArgs + }, { + print: function print(_) { dump(_ + '\n') }, + quit: exit + }); + } + + if (get("extensions." + id + ".sdk.debug.show", false)) { + BrowserToolboxProcess.init({ addonID: id }); + } + } catch (error) { + console.exception(error); + throw error; + } +} +exports.startup = startup; + +// If add-on is lunched via `cfx run` we need to use `system.exit` to let +// cfx know we're done (`cfx test` will take care of exit so we don't do +// anything here). +if (env.CFX_COMMAND === 'run') { + unload(function(reason) { + if (reason === 'shutdown') + exit(0); + }); +} diff --git a/addon-sdk/source/lib/sdk/addon/window.js b/addon-sdk/source/lib/sdk/addon/window.js new file mode 100644 index 000000000..93ed1d8dc --- /dev/null +++ b/addon-sdk/source/lib/sdk/addon/window.js @@ -0,0 +1,66 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Ci, Cc } = require("chrome"); +const { make: makeWindow, getHiddenWindow } = require("../window/utils"); +const { create: makeFrame, getDocShell } = require("../frame/utils"); +const { defer } = require("../core/promise"); +const { when: unload } = require("../system/unload"); +const cfxArgs = require("../test/options"); + +var addonPrincipal = Cc["@mozilla.org/systemprincipal;1"]. + createInstance(Ci.nsIPrincipal); + +var hiddenWindow = getHiddenWindow(); + +if (cfxArgs.parseable) { + console.info("hiddenWindow document.documentURI:" + + hiddenWindow.document.documentURI); + console.info("hiddenWindow document.readyState:" + + hiddenWindow.document.readyState); +} + +// Once Bug 565388 is fixed and shipped we'll be able to make invisible, +// permanent docShells. Meanwhile we create hidden top level window and +// use it's docShell. +var frame = makeFrame(hiddenWindow.document, { + nodeName: "iframe", + namespaceURI: "http://www.w3.org/1999/xhtml", + allowJavascript: true, + allowPlugins: true +}) +var docShell = getDocShell(frame); +var eventTarget = docShell.chromeEventHandler; + +// We need to grant docShell system principals in order to load XUL document +// from data URI into it. +docShell.createAboutBlankContentViewer(addonPrincipal); + +// Get a reference to the DOM window of the given docShell and load +// such document into that would allow us to create XUL iframes, that +// are necessary for hidden frames etc.. +var window = docShell.contentViewer.DOMDocument.defaultView; +window.location = "data:application/vnd.mozilla.xul+xml;charset=utf-8,<window/>"; + +// Create a promise that is delivered once add-on window is interactive, +// used by add-on runner to defer add-on loading until window is ready. +var { promise, resolve } = defer(); +eventTarget.addEventListener("DOMContentLoaded", function handler(event) { + eventTarget.removeEventListener("DOMContentLoaded", handler, false); + resolve(); +}, false); + +exports.ready = promise; +exports.window = window; + +// Still close window on unload to claim memory back early. +unload(function() { + window.close() + frame.parentNode.removeChild(frame); +}); diff --git a/addon-sdk/source/lib/sdk/base64.js b/addon-sdk/source/lib/sdk/base64.js new file mode 100644 index 000000000..a07b302e0 --- /dev/null +++ b/addon-sdk/source/lib/sdk/base64.js @@ -0,0 +1,47 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cu } = require("chrome"); + +// Passing an empty object as second argument to avoid scope's pollution +// (devtools loader injects these symbols as global and prevent using +// const here) +var { atob, btoa } = Cu.import("resource://gre/modules/Services.jsm", {}); + +function isUTF8(charset) { + let type = typeof charset; + + if (type === "undefined") + return false; + + if (type === "string" && charset.toLowerCase() === "utf-8") + return true; + + throw new Error("The charset argument can be only 'utf-8'"); +} + +function toOctetChar(c) { + return String.fromCharCode(c.charCodeAt(0) & 0xFF); +} + +exports.decode = function (data, charset) { + if (isUTF8(charset)) + return decodeURIComponent(escape(atob(data))) + + return atob(data); +} + +exports.encode = function (data, charset) { + if (isUTF8(charset)) + return btoa(unescape(encodeURIComponent(data))) + + data = data.replace(/[^\x00-\xFF]/g, toOctetChar); + return btoa(data); +} diff --git a/addon-sdk/source/lib/sdk/browser/events.js b/addon-sdk/source/lib/sdk/browser/events.js new file mode 100644 index 000000000..f91119031 --- /dev/null +++ b/addon-sdk/source/lib/sdk/browser/events.js @@ -0,0 +1,20 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { events } = require("../window/events"); +const { filter } = require("../event/utils"); +const { isBrowser } = require("../window/utils"); + +// TODO: `isBrowser` detects weather window is a browser by checking +// `windowtype` attribute, which means that all 'open' events will be +// filtered out since document is not loaded yet. Maybe we can find a better +// implementation for `isBrowser`. Either way it's not really needed yet +// neither window tracker provides this event. + +exports.events = filter(events, ({target}) => isBrowser(target)); diff --git a/addon-sdk/source/lib/sdk/clipboard.js b/addon-sdk/source/lib/sdk/clipboard.js new file mode 100644 index 000000000..048d5f2f1 --- /dev/null +++ b/addon-sdk/source/lib/sdk/clipboard.js @@ -0,0 +1,337 @@ +/* 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"; + +module.metadata = { + "stability": "stable", + "engines": { + // TODO Fennec Support 789757 + "Firefox": "*", + "SeaMonkey": "*", + "Thunderbird": "*" + } +}; + +const { Cc, Ci } = require("chrome"); +const { DataURL } = require("./url"); +const apiUtils = require("./deprecated/api-utils"); +/* +While these data flavors resemble Internet media types, they do +no directly map to them. +*/ +const kAllowableFlavors = [ + "text/unicode", + "text/html", + "image/png" + /* CURRENTLY UNSUPPORTED FLAVORS + "text/plain", + "image/jpg", + "image/jpeg", + "image/gif", + "text/x-moz-text-internal", + "AOLMAIL", + "application/x-moz-file", + "text/x-moz-url", + "text/x-moz-url-data", + "text/x-moz-url-desc", + "text/x-moz-url-priv", + "application/x-moz-nativeimage", + "application/x-moz-nativehtml", + "application/x-moz-file-promise-url", + "application/x-moz-file-promise-dest-filename", + "application/x-moz-file-promise", + "application/x-moz-file-promise-dir" + */ +]; + +/* +Aliases for common flavors. Not all flavors will +get an alias. New aliases must be approved by a +Jetpack API druid. +*/ +const kFlavorMap = [ + { short: "text", long: "text/unicode" }, + { short: "html", long: "text/html" }, + { short: "image", long: "image/png" } +]; + +var clipboardService = Cc["@mozilla.org/widget/clipboard;1"]. + getService(Ci.nsIClipboard); + +var clipboardHelper = Cc["@mozilla.org/widget/clipboardhelper;1"]. + getService(Ci.nsIClipboardHelper); + +var imageTools = Cc["@mozilla.org/image/tools;1"]. + getService(Ci.imgITools); + +exports.set = function(aData, aDataType) { + + let options = { + data: aData, + datatype: aDataType || "text" + }; + + // If `aDataType` is not given or if it's "image", the data is parsed as + // data URL to detect a better datatype + if (aData && (!aDataType || aDataType === "image")) { + try { + let dataURL = new DataURL(aData); + + options.datatype = dataURL.mimeType; + options.data = dataURL.data; + } + catch (e) { + // Ignore invalid URIs + if (e.name !== "URIError") { + throw e; + } + } + } + + options = apiUtils.validateOptions(options, { + data: { + is: ["string"] + }, + datatype: { + is: ["string"] + } + }); + + let flavor = fromJetpackFlavor(options.datatype); + + if (!flavor) + throw new Error("Invalid flavor for " + options.datatype); + + // Additional checks for using the simple case + if (flavor == "text/unicode") { + clipboardHelper.copyString(options.data); + return true; + } + + // Below are the more complex cases where we actually have to work with a + // nsITransferable object + var xferable = Cc["@mozilla.org/widget/transferable;1"]. + createInstance(Ci.nsITransferable); + if (!xferable) + throw new Error("Couldn't set the clipboard due to an internal error " + + "(couldn't create a Transferable object)."); + // Bug 769440: Starting with FF16, transferable have to be inited + if ("init" in xferable) + xferable.init(null); + + switch (flavor) { + case "text/html": + // add text/html flavor + let str = Cc["@mozilla.org/supports-string;1"]. + createInstance(Ci.nsISupportsString); + + str.data = options.data; + xferable.addDataFlavor(flavor); + xferable.setTransferData(flavor, str, str.data.length * 2); + + // add a text/unicode flavor (html converted to plain text) + str = Cc["@mozilla.org/supports-string;1"]. + createInstance(Ci.nsISupportsString); + let converter = Cc["@mozilla.org/feed-textconstruct;1"]. + createInstance(Ci.nsIFeedTextConstruct); + + converter.type = "html"; + converter.text = options.data; + str.data = converter.plainText(); + xferable.addDataFlavor("text/unicode"); + xferable.setTransferData("text/unicode", str, str.data.length * 2); + break; + + // Set images to the clipboard is not straightforward, to have an idea how + // it works on platform side, see: + // http://mxr.mozilla.org/mozilla-central/source/content/base/src/nsCopySupport.cpp?rev=7857c5bff017#530 + case "image/png": + let image = options.data; + + let container = {}; + + try { + let input = Cc["@mozilla.org/io/string-input-stream;1"]. + createInstance(Ci.nsIStringInputStream); + + input.setData(image, image.length); + + imageTools.decodeImageData(input, flavor, container); + } + catch (e) { + throw new Error("Unable to decode data given in a valid image."); + } + + // Store directly the input stream makes the cliboard's data available + // for Firefox but not to the others application or to the OS. Therefore, + // a `nsISupportsInterfacePointer` object that reference an `imgIContainer` + // with the image is needed. + var imgPtr = Cc["@mozilla.org/supports-interface-pointer;1"]. + createInstance(Ci.nsISupportsInterfacePointer); + + imgPtr.data = container.value; + + xferable.addDataFlavor(flavor); + xferable.setTransferData(flavor, imgPtr, -1); + + break; + default: + throw new Error("Unable to handle the flavor " + flavor + "."); + } + + // TODO: Not sure if this will ever actually throw. -zpao + try { + clipboardService.setData( + xferable, + null, + clipboardService.kGlobalClipboard + ); + } catch (e) { + throw new Error("Couldn't set clipboard data due to an internal error: " + e); + } + return true; +}; + + +exports.get = function(aDataType) { + let options = { + datatype: aDataType + }; + + // Figure out the best data type for the clipboard's data, if omitted + if (!aDataType) { + if (~currentFlavors().indexOf("image")) + options.datatype = "image"; + else + options.datatype = "text"; + } + + options = apiUtils.validateOptions(options, { + datatype: { + is: ["string"] + } + }); + + var xferable = Cc["@mozilla.org/widget/transferable;1"]. + createInstance(Ci.nsITransferable); + if (!xferable) + throw new Error("Couldn't set the clipboard due to an internal error " + + "(couldn't create a Transferable object)."); + // Bug 769440: Starting with FF16, transferable have to be inited + if ("init" in xferable) + xferable.init(null); + + var flavor = fromJetpackFlavor(options.datatype); + + // Ensure that the user hasn't requested a flavor that we don't support. + if (!flavor) + throw new Error("Getting the clipboard with the flavor '" + flavor + + "' is not supported."); + + // TODO: Check for matching flavor first? Probably not worth it. + + xferable.addDataFlavor(flavor); + // Get the data into our transferable. + clipboardService.getData( + xferable, + clipboardService.kGlobalClipboard + ); + + var data = {}; + var dataLen = {}; + try { + xferable.getTransferData(flavor, data, dataLen); + } catch (e) { + // Clipboard doesn't contain data in flavor, return null. + return null; + } + + // There's no data available, return. + if (data.value === null) + return null; + + // TODO: Add flavors here as we support more in kAllowableFlavors. + switch (flavor) { + case "text/unicode": + case "text/html": + data = data.value.QueryInterface(Ci.nsISupportsString).data; + break; + case "image/png": + let dataURL = new DataURL(); + + dataURL.mimeType = flavor; + dataURL.base64 = true; + + let image = data.value; + + // Due to the differences in how images could be stored in the clipboard + // the checks below are needed. The clipboard could already provide the + // image as byte streams, but also as pointer, or as image container. + // If it's not possible obtain a byte stream, the function returns `null`. + if (image instanceof Ci.nsISupportsInterfacePointer) + image = image.data; + + if (image instanceof Ci.imgIContainer) + image = imageTools.encodeImage(image, flavor); + + if (image instanceof Ci.nsIInputStream) { + let binaryStream = Cc["@mozilla.org/binaryinputstream;1"]. + createInstance(Ci.nsIBinaryInputStream); + + binaryStream.setInputStream(image); + + dataURL.data = binaryStream.readBytes(binaryStream.available()); + + data = dataURL.toString(); + } + else + data = null; + + break; + default: + data = null; + } + + return data; +}; + +function currentFlavors() { + // Loop over kAllowableFlavors, calling hasDataMatchingFlavors for each. + // This doesn't seem like the most efficient way, but we can't get + // confirmation for specific flavors any other way. This is supposed to be + // an inexpensive call, so performance shouldn't be impacted (much). + var currentFlavors = []; + for (var flavor of kAllowableFlavors) { + var matches = clipboardService.hasDataMatchingFlavors( + [flavor], + 1, + clipboardService.kGlobalClipboard + ); + if (matches) + currentFlavors.push(toJetpackFlavor(flavor)); + } + return currentFlavors; +}; + +Object.defineProperty(exports, "currentFlavors", { get : currentFlavors }); + +// SUPPORT FUNCTIONS //////////////////////////////////////////////////////// + +function toJetpackFlavor(aFlavor) { + for (let flavorMap of kFlavorMap) + if (flavorMap.long == aFlavor) + return flavorMap.short; + // Return null in the case where we don't match + return null; +} + +function fromJetpackFlavor(aJetpackFlavor) { + // TODO: Handle proper flavors better + for (let flavorMap of kFlavorMap) + if (flavorMap.short == aJetpackFlavor || flavorMap.long == aJetpackFlavor) + return flavorMap.long; + // Return null in the case where we don't match. + return null; +} diff --git a/addon-sdk/source/lib/sdk/console/plain-text.js b/addon-sdk/source/lib/sdk/console/plain-text.js new file mode 100644 index 000000000..0e44cf106 --- /dev/null +++ b/addon-sdk/source/lib/sdk/console/plain-text.js @@ -0,0 +1,78 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci, Cu, Cr } = require("chrome"); +const self = require("../self"); +const prefs = require("../preferences/service"); +const { merge } = require("../util/object"); +const { ConsoleAPI } = Cu.import("resource://gre/modules/Console.jsm", {}); + +const DEFAULT_LOG_LEVEL = "error"; +const ADDON_LOG_LEVEL_PREF = "extensions." + self.id + ".sdk.console.logLevel"; +const SDK_LOG_LEVEL_PREF = "extensions.sdk.console.logLevel"; + +var logLevel = DEFAULT_LOG_LEVEL; +function setLogLevel() { + logLevel = prefs.get(ADDON_LOG_LEVEL_PREF, + prefs.get(SDK_LOG_LEVEL_PREF, + DEFAULT_LOG_LEVEL)); +} +setLogLevel(); + +var logLevelObserver = { + QueryInterface: function(iid) { + if (!iid.equals(Ci.nsIObserver) && + !iid.equals(Ci.nsISupportsWeakReference) && + !iid.equals(Ci.nsISupports)) + throw Cr.NS_ERROR_NO_INTERFACE; + return this; + }, + observe: function(subject, topic, data) { + setLogLevel(); + } +}; +var branch = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefService). + getBranch(null); +branch.addObserver(ADDON_LOG_LEVEL_PREF, logLevelObserver, true); +branch.addObserver(SDK_LOG_LEVEL_PREF, logLevelObserver, true); + +function PlainTextConsole(print, innerID) { + + let consoleOptions = { + prefix: self.name, + maxLogLevel: logLevel, + dump: print, + innerID: innerID, + consoleID: "addon/" + self.id + }; + let console = new ConsoleAPI(consoleOptions); + + // As we freeze the console object, we can't modify this property afterward + Object.defineProperty(console, "maxLogLevel", { + get: function() { + return logLevel; + } + }); + + // We defined the `__exposedProps__` in our console chrome object. + // + // Meanwhile we're investigating with the platform team if `__exposedProps__` + // are needed, or are just a left-over. + + console.__exposedProps__ = Object.keys(ConsoleAPI.prototype).reduce(function(exposed, prop) { + exposed[prop] = "r"; + return exposed; + }, {}); + + Object.freeze(console); + return console; +}; +exports.PlainTextConsole = PlainTextConsole; diff --git a/addon-sdk/source/lib/sdk/console/traceback.js b/addon-sdk/source/lib/sdk/console/traceback.js new file mode 100644 index 000000000..be0fb7b94 --- /dev/null +++ b/addon-sdk/source/lib/sdk/console/traceback.js @@ -0,0 +1,86 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Ci, components } = require("chrome"); +const { parseStack, sourceURI } = require("toolkit/loader"); +const { readURISync } = require("../net/url"); + +function safeGetFileLine(path, line) { + try { + var scheme = require("../url").URL(path).scheme; + // TODO: There should be an easier, more accurate way to figure out + // what's the case here. + if (!(scheme == "http" || scheme == "https")) + return readURISync(path).split("\n")[line - 1]; + } catch (e) {} + return null; +} + +function nsIStackFramesToJSON(frame) { + var stack = []; + + while (frame) { + if (frame.filename) { + stack.unshift({ + fileName: sourceURI(frame.filename), + lineNumber: frame.lineNumber, + name: frame.name + }); + } + frame = frame.caller; + } + + return stack; +}; + +var fromException = exports.fromException = function fromException(e) { + if (e instanceof Ci.nsIException) + return nsIStackFramesToJSON(e.location); + if (e.stack && e.stack.length) + return parseStack(e.stack); + if (e.fileName && typeof(e.lineNumber == "number")) + return [{fileName: sourceURI(e.fileName), + lineNumber: e.lineNumber, + name: null}]; + return []; +}; + +var get = exports.get = function get() { + return nsIStackFramesToJSON(components.stack.caller); +}; + +var format = exports.format = function format(tbOrException) { + if (tbOrException === undefined) { + tbOrException = get(); + tbOrException.pop(); + } + + var tb; + if (typeof(tbOrException) == "object" && + tbOrException.constructor.name == "Array") + tb = tbOrException; + else + tb = fromException(tbOrException); + + var lines = ["Traceback (most recent call last):"]; + + tb.forEach( + function(frame) { + if (!(frame.fileName || frame.lineNumber || frame.name)) + return; + + lines.push(' File "' + frame.fileName + '", line ' + + frame.lineNumber + ', in ' + frame.name); + var sourceLine = safeGetFileLine(frame.fileName, frame.lineNumber); + if (sourceLine) + lines.push(' ' + sourceLine.trim()); + }); + + return lines.join("\n"); +}; diff --git a/addon-sdk/source/lib/sdk/content/content-worker.js b/addon-sdk/source/lib/sdk/content/content-worker.js new file mode 100644 index 000000000..0a8225733 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/content-worker.js @@ -0,0 +1,305 @@ +/* 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/. */ + +Object.freeze({ + // TODO: Bug 727854 Use same implementation than common JS modules, + // i.e. EventEmitter module + + /** + * Create an EventEmitter instance. + */ + createEventEmitter: function createEventEmitter(emit) { + let listeners = Object.create(null); + let eventEmitter = Object.freeze({ + emit: emit, + on: function on(name, callback) { + if (typeof callback !== "function") + return this; + if (!(name in listeners)) + listeners[name] = []; + listeners[name].push(callback); + return this; + }, + once: function once(name, callback) { + eventEmitter.on(name, function onceCallback() { + eventEmitter.removeListener(name, onceCallback); + callback.apply(callback, arguments); + }); + }, + removeListener: function removeListener(name, callback) { + if (!(name in listeners)) + return; + let index = listeners[name].indexOf(callback); + if (index == -1) + return; + listeners[name].splice(index, 1); + } + }); + function onEvent(name) { + if (!(name in listeners)) + return []; + let args = Array.slice(arguments, 1); + let results = []; + for (let callback of listeners[name]) { + results.push(callback.apply(null, args)); + } + return results; + } + return { + eventEmitter: eventEmitter, + emit: onEvent + }; + }, + + /** + * Create an EventEmitter instance to communicate with chrome module + * by passing only strings between compartments. + * This function expects `emitToChrome` function, that allows to send + * events to the chrome module. It returns the EventEmitter as `pipe` + * attribute, and, `onChromeEvent` a function that allows chrome module + * to send event into the EventEmitter. + * + * pipe.emit --> emitToChrome + * onChromeEvent --> callback registered through pipe.on + */ + createPipe: function createPipe(emitToChrome) { + let ContentWorker = this; + function onEvent(type, ...args) { + // JSON.stringify is buggy with cross-sandbox values, + // it may return "{}" on functions. Use a replacer to match them correctly. + let replacer = (k, v) => + typeof(v) === "function" + ? (type === "console" ? Function.toString.call(v) : void(0)) + : v; + + let str = JSON.stringify([type, ...args], replacer); + emitToChrome(str); + } + + let { eventEmitter, emit } = + ContentWorker.createEventEmitter(onEvent); + + return { + pipe: eventEmitter, + onChromeEvent: function onChromeEvent(array) { + // We either receive a stringified array, or a real array. + // We still allow to pass an array of objects, in WorkerSandbox.emitSync + // in order to allow sending DOM node reference between content script + // and modules (only used for context-menu API) + let args = typeof array == "string" ? JSON.parse(array) : array; + return emit.apply(null, args); + } + }; + }, + + injectConsole: function injectConsole(exports, pipe) { + exports.console = Object.freeze({ + log: pipe.emit.bind(null, "console", "log"), + info: pipe.emit.bind(null, "console", "info"), + warn: pipe.emit.bind(null, "console", "warn"), + error: pipe.emit.bind(null, "console", "error"), + debug: pipe.emit.bind(null, "console", "debug"), + exception: pipe.emit.bind(null, "console", "exception"), + trace: pipe.emit.bind(null, "console", "trace"), + time: pipe.emit.bind(null, "console", "time"), + timeEnd: pipe.emit.bind(null, "console", "timeEnd") + }); + }, + + injectTimers: function injectTimers(exports, chromeAPI, pipe, console) { + // wrapped functions from `'timer'` module. + // Wrapper adds `try catch` blocks to the callbacks in order to + // emit `error` event if exception is thrown in + // the Worker global scope. + // @see http://www.w3.org/TR/workers/#workerutils + + // List of all living timeouts/intervals + let _timers = Object.create(null); + + // Keep a reference to original timeout functions + let { + setTimeout: chromeSetTimeout, + setInterval: chromeSetInterval, + clearTimeout: chromeClearTimeout, + clearInterval: chromeClearInterval + } = chromeAPI.timers; + + function registerTimer(timer) { + let registerMethod = null; + if (timer.kind == "timeout") + registerMethod = chromeSetTimeout; + else if (timer.kind == "interval") + registerMethod = chromeSetInterval; + else + throw new Error("Unknown timer kind: " + timer.kind); + + if (typeof timer.fun == 'string') { + let code = timer.fun; + timer.fun = () => chromeAPI.sandbox.evaluate(exports, code); + } else if (typeof timer.fun != 'function') { + throw new Error('Unsupported callback type' + typeof timer.fun); + } + + let id = registerMethod(onFire, timer.delay); + function onFire() { + try { + if (timer.kind == "timeout") + delete _timers[id]; + timer.fun.apply(null, timer.args); + } catch(e) { + console.exception(e); + let wrapper = { + instanceOfError: instanceOf(e, Error), + value: e, + }; + if (wrapper.instanceOfError) { + wrapper.value = { + message: e.message, + fileName: e.fileName, + lineNumber: e.lineNumber, + stack: e.stack, + name: e.name, + }; + } + pipe.emit('error', wrapper); + } + } + _timers[id] = timer; + return id; + } + + // copied from sdk/lang/type.js since modules are not available here + function instanceOf(value, Type) { + var isConstructorNameSame; + var isConstructorSourceSame; + + // If `instanceof` returned `true` we know result right away. + var isInstanceOf = value instanceof Type; + + // If `instanceof` returned `false` we do ducktype check since `Type` may be + // from a different sandbox. If a constructor of the `value` or a constructor + // of the value's prototype has same name and source we assume that it's an + // instance of the Type. + if (!isInstanceOf && value) { + isConstructorNameSame = value.constructor.name === Type.name; + isConstructorSourceSame = String(value.constructor) == String(Type); + isInstanceOf = (isConstructorNameSame && isConstructorSourceSame) || + instanceOf(Object.getPrototypeOf(value), Type); + } + return isInstanceOf; + } + + function unregisterTimer(id) { + if (!(id in _timers)) + return; + let { kind } = _timers[id]; + delete _timers[id]; + if (kind == "timeout") + chromeClearTimeout(id); + else if (kind == "interval") + chromeClearInterval(id); + else + throw new Error("Unknown timer kind: " + kind); + } + + function disableAllTimers() { + Object.keys(_timers).forEach(unregisterTimer); + } + + exports.setTimeout = function ContentScriptSetTimeout(callback, delay) { + return registerTimer({ + kind: "timeout", + fun: callback, + delay: delay, + args: Array.slice(arguments, 2) + }); + }; + exports.clearTimeout = function ContentScriptClearTimeout(id) { + unregisterTimer(id); + }; + + exports.setInterval = function ContentScriptSetInterval(callback, delay) { + return registerTimer({ + kind: "interval", + fun: callback, + delay: delay, + args: Array.slice(arguments, 2) + }); + }; + exports.clearInterval = function ContentScriptClearInterval(id) { + unregisterTimer(id); + }; + + // On page-hide, save a list of all existing timers before disabling them, + // in order to be able to restore them on page-show. + // These events are fired when the page goes in/out of bfcache. + // https://developer.mozilla.org/En/Working_with_BFCache + let frozenTimers = []; + pipe.on("pageshow", function onPageShow() { + frozenTimers.forEach(registerTimer); + }); + pipe.on("pagehide", function onPageHide() { + frozenTimers = []; + for (let id in _timers) + frozenTimers.push(_timers[id]); + disableAllTimers(); + // Some other pagehide listeners may register some timers that won't be + // frozen as this particular pagehide listener is called first. + // So freeze these timers on next cycle. + chromeSetTimeout(function () { + for (let id in _timers) + frozenTimers.push(_timers[id]); + disableAllTimers(); + }, 0); + }); + + // Unregister all timers when the page is destroyed + // (i.e. when it is removed from bfcache) + pipe.on("detach", function clearTimeouts() { + disableAllTimers(); + _timers = {}; + frozenTimers = []; + }); + }, + + injectMessageAPI: function injectMessageAPI(exports, pipe, console) { + + let ContentWorker = this; + let { eventEmitter: port, emit : portEmit } = + ContentWorker.createEventEmitter(pipe.emit.bind(null, "event")); + pipe.on("event", portEmit); + + let self = { + port: port, + postMessage: pipe.emit.bind(null, "message"), + on: pipe.on.bind(null), + once: pipe.once.bind(null), + removeListener: pipe.removeListener.bind(null), + }; + Object.defineProperty(exports, "self", { + value: self + }); + }, + + injectOptions: function (exports, options) { + Object.defineProperty( exports.self, "options", { value: JSON.parse( options ) }); + }, + + inject: function (exports, chromeAPI, emitToChrome, options) { + let ContentWorker = this; + let { pipe, onChromeEvent } = + ContentWorker.createPipe(emitToChrome); + + ContentWorker.injectConsole(exports, pipe); + ContentWorker.injectTimers(exports, chromeAPI, pipe, exports.console); + ContentWorker.injectMessageAPI(exports, pipe, exports.console); + if ( options !== undefined ) { + ContentWorker.injectOptions(exports, options); + } + + Object.freeze( exports.self ); + + return onChromeEvent; + } +}); diff --git a/addon-sdk/source/lib/sdk/content/content.js b/addon-sdk/source/lib/sdk/content/content.js new file mode 100644 index 000000000..9655223a3 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/content.js @@ -0,0 +1,17 @@ +/* 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"; + +module.metadata = { + "stability": "deprecated" +}; + +const { deprecateUsage } = require('../util/deprecate'); + +Object.defineProperty(exports, "Worker", { + get: function() { + deprecateUsage('`sdk/content/content` is deprecated. Please use `sdk/content/worker` directly.'); + return require('./worker').Worker; + } +}); diff --git a/addon-sdk/source/lib/sdk/content/context-menu.js b/addon-sdk/source/lib/sdk/content/context-menu.js new file mode 100644 index 000000000..2955e2f09 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/context-menu.js @@ -0,0 +1,408 @@ +/* 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 { Class } = require("../core/heritage"); +const self = require("../self"); +const { WorkerChild } = require("./worker-child"); +const { getInnerId } = require("../window/utils"); +const { Ci } = require("chrome"); +const { Services } = require("resource://gre/modules/Services.jsm"); +const system = require('../system/events'); +const { process } = require('../remote/child'); + +// These functions are roughly copied from sdk/selection which doesn't work +// in the content process +function getElementWithSelection(window) { + let element = Services.focus.getFocusedElementForWindow(window, false, {}); + if (!element) + return null; + + try { + // Accessing selectionStart and selectionEnd on e.g. a button + // results in an exception thrown as per the HTML5 spec. See + // http://www.whatwg.org/specs/web-apps/current-work/multipage/association-of-controls-and-forms.html#textFieldSelection + + let { value, selectionStart, selectionEnd } = element; + + let hasSelection = typeof value === "string" && + !isNaN(selectionStart) && + !isNaN(selectionEnd) && + selectionStart !== selectionEnd; + + return hasSelection ? element : null; + } + catch (err) { + console.exception(err); + return null; + } +} + +function safeGetRange(selection, rangeNumber) { + try { + let { rangeCount } = selection; + let range = null; + + for (let rangeNumber = 0; rangeNumber < rangeCount; rangeNumber++ ) { + range = selection.getRangeAt(rangeNumber); + + if (range && range.toString()) + break; + + range = null; + } + + return range; + } + catch (e) { + return null; + } +} + +function getSelection(window) { + let selection = window.getSelection(); + let range = safeGetRange(selection); + if (range) + return range.toString(); + + let node = getElementWithSelection(window); + if (!node) + return null; + + return node.value.substring(node.selectionStart, node.selectionEnd); +} + +//These are used by PageContext.isCurrent below. If the popupNode or any of +//its ancestors is one of these, Firefox uses a tailored context menu, and so +//the page context doesn't apply. +const NON_PAGE_CONTEXT_ELTS = [ + Ci.nsIDOMHTMLAnchorElement, + Ci.nsIDOMHTMLAppletElement, + Ci.nsIDOMHTMLAreaElement, + Ci.nsIDOMHTMLButtonElement, + Ci.nsIDOMHTMLCanvasElement, + Ci.nsIDOMHTMLEmbedElement, + Ci.nsIDOMHTMLImageElement, + Ci.nsIDOMHTMLInputElement, + Ci.nsIDOMHTMLMapElement, + Ci.nsIDOMHTMLMediaElement, + Ci.nsIDOMHTMLMenuElement, + Ci.nsIDOMHTMLObjectElement, + Ci.nsIDOMHTMLOptionElement, + Ci.nsIDOMHTMLSelectElement, + Ci.nsIDOMHTMLTextAreaElement, +]; + +// List all editable types of inputs. Or is it better to have a list +// of non-editable inputs? +var editableInputs = { + email: true, + number: true, + password: true, + search: true, + tel: true, + text: true, + textarea: true, + url: true +}; + +var CONTEXTS = {}; + +var Context = Class({ + initialize: function(id) { + this.id = id; + }, + + adjustPopupNode: function adjustPopupNode(popupNode) { + return popupNode; + }, + + // Gets state to pass through to the parent process for the node the user + // clicked on + getState: function(popupNode) { + return false; + } +}); + +// Matches when the context-clicked node doesn't have any of +// NON_PAGE_CONTEXT_ELTS in its ancestors +CONTEXTS.PageContext = Class({ + extends: Context, + + getState: function(popupNode) { + // If there is a selection in the window then this context does not match + if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) + return false; + + // If the clicked node or any of its ancestors is one of the blocked + // NON_PAGE_CONTEXT_ELTS then this context does not match + while (!(popupNode instanceof Ci.nsIDOMDocument)) { + if (NON_PAGE_CONTEXT_ELTS.some(type => popupNode instanceof type)) + return false; + + popupNode = popupNode.parentNode; + } + + return true; + } +}); + +// Matches when there is an active selection in the window +CONTEXTS.SelectionContext = Class({ + extends: Context, + + getState: function(popupNode) { + if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) + return true; + + try { + // The node may be a text box which has selectionStart and selectionEnd + // properties. If not this will throw. + let { selectionStart, selectionEnd } = popupNode; + return !isNaN(selectionStart) && !isNaN(selectionEnd) && + selectionStart !== selectionEnd; + } + catch (e) { + return false; + } + } +}); + +// Matches when the context-clicked node or any of its ancestors matches the +// selector given +CONTEXTS.SelectorContext = Class({ + extends: Context, + + initialize: function initialize(id, selector) { + Context.prototype.initialize.call(this, id); + this.selector = selector; + }, + + adjustPopupNode: function adjustPopupNode(popupNode) { + let selector = this.selector; + + while (!(popupNode instanceof Ci.nsIDOMDocument)) { + if (popupNode.matches(selector)) + return popupNode; + + popupNode = popupNode.parentNode; + } + + return null; + }, + + getState: function(popupNode) { + return !!this.adjustPopupNode(popupNode); + } +}); + +// Matches when the page url matches any of the patterns given +CONTEXTS.URLContext = Class({ + extends: Context, + + getState: function(popupNode) { + return popupNode.ownerDocument.URL; + } +}); + +// Matches when the user-supplied predicate returns true +CONTEXTS.PredicateContext = Class({ + extends: Context, + + getState: function(node) { + let window = node.ownerDocument.defaultView; + let data = {}; + + data.documentType = node.ownerDocument.contentType; + + data.documentURL = node.ownerDocument.location.href; + data.targetName = node.nodeName.toLowerCase(); + data.targetID = node.id || null ; + + if ((data.targetName === 'input' && editableInputs[node.type]) || + data.targetName === 'textarea') { + data.isEditable = !node.readOnly && !node.disabled; + } + else { + data.isEditable = node.isContentEditable; + } + + data.selectionText = getSelection(window, "TEXT"); + + data.srcURL = node.src || null; + data.value = node.value || null; + + while (!data.linkURL && node) { + data.linkURL = node.href || null; + node = node.parentNode; + } + + return data; + }, +}); + +function instantiateContext({ id, type, args }) { + if (!(type in CONTEXTS)) { + console.error("Attempt to use unknown context " + type); + return; + } + return new CONTEXTS[type](id, ...args); +} + +var ContextWorker = Class({ + implements: [ WorkerChild ], + + // Calls the context workers context listeners and returns the first result + // that is either a string or a value that evaluates to true. If all of the + // listeners returned false then returns false. If there are no listeners, + // returns true (show the menu item by default). + getMatchedContext: function getCurrentContexts(popupNode) { + let results = this.sandbox.emitSync("context", popupNode); + if (!results.length) + return true; + return results.reduce((val, result) => val || result); + }, + + // Emits a click event in the worker's port. popupNode is the node that was + // context-clicked, and clickedItemData is the data of the item that was + // clicked. + fireClick: function fireClick(popupNode, clickedItemData) { + this.sandbox.emitSync("click", popupNode, clickedItemData); + } +}); + +// Gets the item's content script worker for a window, creating one if necessary +// Once created it will be automatically destroyed when the window unloads. +// If there is not content scripts for the item then null will be returned. +function getItemWorkerForWindow(item, window) { + if (!item.contentScript && !item.contentScriptFile) + return null; + + let id = getInnerId(window); + let worker = item.workerMap.get(id); + + if (worker) + return worker; + + worker = ContextWorker({ + id: item.id, + window, + manager: item.manager, + contentScript: item.contentScript, + contentScriptFile: item.contentScriptFile, + onDetach: function() { + item.workerMap.delete(id); + } + }); + + item.workerMap.set(id, worker); + + return worker; +} + +// A very simple remote proxy for every item. It's job is to provide data for +// the main process to use to determine visibility state and to call into +// content scripts when clicked. +var RemoteItem = Class({ + initialize: function(options, manager) { + this.id = options.id; + this.contexts = options.contexts.map(instantiateContext); + this.contentScript = options.contentScript; + this.contentScriptFile = options.contentScriptFile; + + this.manager = manager; + + this.workerMap = new Map(); + keepAlive.set(this.id, this); + }, + + destroy: function() { + for (let worker of this.workerMap.values()) { + worker.destroy(); + } + keepAlive.delete(this.id); + }, + + activate: function(popupNode, data) { + let worker = getItemWorkerForWindow(this, popupNode.ownerDocument.defaultView); + if (!worker) + return; + + for (let context of this.contexts) + popupNode = context.adjustPopupNode(popupNode); + + worker.fireClick(popupNode, data); + }, + + // Fills addonInfo with state data to send through to the main process + getContextState: function(popupNode, addonInfo) { + if (!(self.id in addonInfo)) { + addonInfo[self.id] = { + processID: process.id, + items: {} + }; + } + + let worker = getItemWorkerForWindow(this, popupNode.ownerDocument.defaultView); + let contextStates = {}; + for (let context of this.contexts) + contextStates[context.id] = context.getState(popupNode); + + addonInfo[self.id].items[this.id] = { + // It isn't ideal to create a PageContext for every item but there isn't + // a good shared place to do it. + pageContext: (new CONTEXTS.PageContext()).getState(popupNode), + contextStates, + hasWorker: !!worker, + workerContext: worker ? worker.getMatchedContext(popupNode) : true + } + } +}); +exports.RemoteItem = RemoteItem; + +// Holds remote items for this frame. +var keepAlive = new Map(); + +// Called to create remote proxies for items. If they already exist we destroy +// and recreate. This can happen if the item changes in some way or in odd +// timing cases where the frame script is create around the same time as the +// item is created in the main process +process.port.on('sdk/contextmenu/createitems', (process, items) => { + for (let itemoptions of items) { + let oldItem = keepAlive.get(itemoptions.id); + if (oldItem) { + oldItem.destroy(); + } + + let item = new RemoteItem(itemoptions, this); + } +}); + +process.port.on('sdk/contextmenu/destroyitems', (process, items) => { + for (let id of items) { + let item = keepAlive.get(id); + item.destroy(); + } +}); + +var lastPopupNode = null; + +system.on('content-contextmenu', ({ subject }) => { + let { event: { target: popupNode }, addonInfo } = subject.wrappedJSObject; + lastPopupNode = popupNode; + + for (let item of keepAlive.values()) { + item.getContextState(popupNode, addonInfo); + } +}, true); + +process.port.on('sdk/contextmenu/activateitems', (process, items, data) => { + for (let id of items) { + let item = keepAlive.get(id); + if (!item) + continue; + + item.activate(lastPopupNode, data); + } +}); diff --git a/addon-sdk/source/lib/sdk/content/events.js b/addon-sdk/source/lib/sdk/content/events.js new file mode 100644 index 000000000..c085b6179 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/events.js @@ -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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Ci } = require("chrome"); +const { open } = require("../event/dom"); +const { observe } = require("../event/chrome"); +const { filter, merge, map, expand } = require("../event/utils"); +const { windows } = require("../window/utils"); +const { events: windowEvents } = require("sdk/window/events"); + +// Note: Please note that even though pagehide event is included +// it's not observable reliably since it's not always triggered +// when closing tabs. Implementation can be imrpoved once that +// event will be necessary. +var TYPES = ["DOMContentLoaded", "load", "pageshow", "pagehide"]; + +var insert = observe("document-element-inserted"); +var windowCreate = merge([ + observe("content-document-global-created"), + observe("chrome-document-global-created") +]); +var create = map(windowCreate, function({target, data, type}) { + return { target: target.document, type: type, data: data } +}); + +function streamEventsFrom({document}) { + // Map supported event types to a streams of those events on the given + // `window` for the inserted document and than merge these streams into + // single form stream off all window state change events. + let stateChanges = TYPES.map(function(type) { + return open(document, type, { capture: true }); + }); + + // Since load events on document occur for every loded resource + return filter(merge(stateChanges), function({target}) { + return target instanceof Ci.nsIDOMDocument + }) +} +exports.streamEventsFrom = streamEventsFrom; + +var opened = windows(null, { includePrivate: true }); +var state = merge(opened.map(streamEventsFrom)); + + +var futureReady = filter(windowEvents, ({type}) => + type === "DOMContentLoaded"); +var futureWindows = map(futureReady, ({target}) => target); +var futureState = expand(futureWindows, streamEventsFrom); + +exports.events = merge([insert, create, state, futureState]); diff --git a/addon-sdk/source/lib/sdk/content/l10n-html.js b/addon-sdk/source/lib/sdk/content/l10n-html.js new file mode 100644 index 000000000..f324623dc --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/l10n-html.js @@ -0,0 +1,133 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Ci, Cc, Cu } = require("chrome"); +const core = require("../l10n/core"); +const { loadSheet, removeSheet } = require("../stylesheet/utils"); +const { process, frames } = require("../remote/child"); +var observerService = Cc["@mozilla.org/observer-service;1"] + .getService(Ci.nsIObserverService); +const { ShimWaiver } = Cu.import("resource://gre/modules/ShimWaiver.jsm"); +const addObserver = ShimWaiver.getProperty(observerService, "addObserver"); +const removeObserver = ShimWaiver.getProperty(observerService, "removeObserver"); + +const assetsURI = require('../self').data.url(); + +const hideSheetUri = "data:text/css,:root {visibility: hidden !important;}"; + +function translateElementAttributes(element) { + // Translateable attributes + const attrList = ['title', 'accesskey', 'alt', 'label', 'placeholder']; + const ariaAttrMap = { + 'ariaLabel': 'aria-label', + 'ariaValueText': 'aria-valuetext', + 'ariaMozHint': 'aria-moz-hint' + }; + const attrSeparator = '.'; + + // Try to translate each of the attributes + for (let attribute of attrList) { + const data = core.get(element.dataset.l10nId + attrSeparator + attribute); + if (data) + element.setAttribute(attribute, data); + } + + // Look for the aria attribute translations that match fxOS's aliases + for (let attrAlias in ariaAttrMap) { + const data = core.get(element.dataset.l10nId + attrSeparator + attrAlias); + if (data) + element.setAttribute(ariaAttrMap[attrAlias], data); + } +} + +// Taken from Gaia: +// https://github.com/andreasgal/gaia/blob/04fde2640a7f40314643016a5a6c98bf3755f5fd/webapi.js#L1470 +function translateElement(element) { + element = element || document; + + // check all translatable children (= w/ a `data-l10n-id' attribute) + var children = element.querySelectorAll('*[data-l10n-id]'); + var elementCount = children.length; + for (var i = 0; i < elementCount; i++) { + var child = children[i]; + + // translate the child + var key = child.dataset.l10nId; + var data = core.get(key); + if (data) + child.textContent = data; + + translateElementAttributes(child); + } +} +exports.translateElement = translateElement; + +function onDocumentReady2Translate(event) { + let document = event.target; + document.removeEventListener("DOMContentLoaded", onDocumentReady2Translate, + false); + + translateElement(document); + + try { + // Finally display document when we finished replacing all text content + if (document.defaultView) + removeSheet(document.defaultView, hideSheetUri, 'user'); + } + catch(e) { + console.exception(e); + } +} + +function onContentWindow(document) { + // Accept only HTML documents + if (!(document instanceof Ci.nsIDOMHTMLDocument)) + return; + + // Bug 769483: data:URI documents instanciated with nsIDOMParser + // have a null `location` attribute at this time + if (!document.location) + return; + + // Accept only document from this addon + if (document.location.href.indexOf(assetsURI) !== 0) + return; + + try { + // First hide content of the document in order to have content blinking + // between untranslated and translated states + loadSheet(document.defaultView, hideSheetUri, 'user'); + } + catch(e) { + console.exception(e); + } + // Wait for DOM tree to be built before applying localization + document.addEventListener("DOMContentLoaded", onDocumentReady2Translate, + false); +} + +// Listen to creation of content documents in order to translate them as soon +// as possible in their loading process +const ON_CONTENT = "document-element-inserted"; +let enabled = false; +function enable() { + if (enabled) + return; + addObserver(onContentWindow, ON_CONTENT, false); + enabled = true; +} +process.port.on("sdk/l10n/html/enable", enable); + +function disable() { + if (!enabled) + return; + removeObserver(onContentWindow, ON_CONTENT); + enabled = false; +} +process.port.on("sdk/l10n/html/disable", disable); diff --git a/addon-sdk/source/lib/sdk/content/loader.js b/addon-sdk/source/lib/sdk/content/loader.js new file mode 100644 index 000000000..e4f0dd2aa --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/loader.js @@ -0,0 +1,74 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { isValidURI, isLocalURL, URL } = require('../url'); +const { contract } = require('../util/contract'); +const { isString, isNil, instanceOf, isJSONable } = require('../lang/type'); +const { validateOptions, + string, array, object, either, required } = require('../deprecated/api-utils'); + +const isValidScriptFile = (value) => + (isString(value) || instanceOf(value, URL)) && isLocalURL(value); + +// map of property validations +const valid = { + contentURL: { + is: either(string, object), + ok: url => isNil(url) || isLocalURL(url) || isValidURI(url), + msg: 'The `contentURL` option must be a valid URL.' + }, + contentScriptFile: { + is: either(string, object, array), + ok: value => isNil(value) || [].concat(value).every(isValidScriptFile), + msg: 'The `contentScriptFile` option must be a local URL or an array of URLs.' + }, + contentScript: { + is: either(string, array), + ok: value => isNil(value) || [].concat(value).every(isString), + msg: 'The `contentScript` option must be a string or an array of strings.' + }, + contentScriptWhen: { + is: required(string), + map: value => value || 'end', + ok: value => ~['start', 'ready', 'end'].indexOf(value), + msg: 'The `contentScriptWhen` option must be either "start", "ready" or "end".' + }, + contentScriptOptions: { + ok: value => isNil(value) || isJSONable(value), + msg: 'The contentScriptOptions should be a jsonable value.' + } +}; +exports.validationAttributes = valid; + +/** + * Shortcut function to validate property with validation. + * @param {Object|Number|String} suspect + * value to validate + * @param {Object} validation + * validation rule passed to `api-utils` + */ +function validate(suspect, validation) { + return validateOptions( + { $: suspect }, + { $: validation } + ).$; +} + +function Allow(script) { + return { + get script() { + return script; + }, + set script(value) { + script = !!value; + } + }; +} + +exports.contract = contract(valid); diff --git a/addon-sdk/source/lib/sdk/content/mod.js b/addon-sdk/source/lib/sdk/content/mod.js new file mode 100644 index 000000000..81fe9ee42 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/mod.js @@ -0,0 +1,68 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Ci } = require("chrome"); +const { dispatcher } = require("../util/dispatcher"); +const { add, remove, iterator } = require("../lang/weak-set"); + +var getTargetWindow = dispatcher("getTargetWindow"); + +getTargetWindow.define(function (target) { + if (target instanceof Ci.nsIDOMWindow) + return target; + if (target instanceof Ci.nsIDOMDocument) + return target.defaultView || null; + + return null; +}); + +exports.getTargetWindow = getTargetWindow; + +var attachTo = dispatcher("attachTo"); +exports.attachTo = attachTo; + +var detachFrom = dispatcher("detatchFrom"); +exports.detachFrom = detachFrom; + +function attach(modification, target) { + if (!modification) + return; + + let window = getTargetWindow(target); + + attachTo(modification, window); + + // modification are stored per content; `window` reference can still be the + // same even if the content is changed, therefore `document` is used instead. + add(modification, window.document); +} +exports.attach = attach; + +function detach(modification, target) { + if (!modification) + return; + + if (target) { + let window = getTargetWindow(target); + detachFrom(modification, window); + remove(modification, window.document); + } + else { + let documents = iterator(modification); + for (let document of documents) { + let window = document.defaultView; + // The window might have already gone away + if (!window) + continue; + detachFrom(modification, document.defaultView); + remove(modification, document); + } + } +} +exports.detach = detach; diff --git a/addon-sdk/source/lib/sdk/content/page-mod.js b/addon-sdk/source/lib/sdk/content/page-mod.js new file mode 100644 index 000000000..8ff9b1e7b --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/page-mod.js @@ -0,0 +1,236 @@ +/* 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"; + +module.metadata = { + "stability": "stable" +}; + +const { getAttachEventType } = require('../content/utils'); +const { Class } = require('../core/heritage'); +const { Disposable } = require('../core/disposable'); +const { WeakReference } = require('../core/reference'); +const { WorkerChild } = require('./worker-child'); +const { EventTarget } = require('../event/target'); +const { on, emit, once, setListeners } = require('../event/core'); +const { on: domOn, removeListener: domOff } = require('../dom/events'); +const { isRegExp, isUndefined } = require('../lang/type'); +const { merge } = require('../util/object'); +const { isBrowser, getFrames } = require('../window/utils'); +const { getTabs, getURI: getTabURI } = require('../tabs/utils'); +const { ignoreWindow } = require('../private-browsing/utils'); +const { Style } = require("../stylesheet/style"); +const { attach, detach } = require("../content/mod"); +const { has, hasAny } = require("../util/array"); +const { Rules } = require("../util/rules"); +const { List, addListItem, removeListItem } = require('../util/list'); +const { when } = require("../system/unload"); +const { uuid } = require('../util/uuid'); +const { frames, process } = require('../remote/child'); + +const pagemods = new Map(); +const styles = new WeakMap(); +var styleFor = (mod) => styles.get(mod); + +// Helper functions +var modMatchesURI = (mod, uri) => mod.include.matchesAny(uri) && !mod.exclude.matchesAny(uri); + +/** + * PageMod constructor (exported below). + * @constructor + */ +const ChildPageMod = Class({ + implements: [ + EventTarget, + Disposable, + ], + setup: function PageMod(model) { + merge(this, model); + + // Set listeners on {PageMod} itself, not the underlying worker, + // like `onMessage`, as it'll get piped. + setListeners(this, model); + + function deserializeRules(rules) { + for (let rule of rules) { + yield rule.type == "string" ? rule.value + : new RegExp(rule.pattern, rule.flags); + } + } + + let include = [...deserializeRules(this.include)]; + this.include = Rules(); + this.include.add.apply(this.include, include); + + let exclude = [...deserializeRules(this.exclude)]; + this.exclude = Rules(); + this.exclude.add.apply(this.exclude, exclude); + + if (this.contentStyle || this.contentStyleFile) { + styles.set(this, Style({ + uri: this.contentStyleFile, + source: this.contentStyle + })); + } + + pagemods.set(this.id, this); + this.seenDocuments = new WeakMap(); + + // `applyOnExistingDocuments` has to be called after `pagemods.add()` + // otherwise its calls to `onContent` method won't do anything. + if (has(this.attachTo, 'existing')) + applyOnExistingDocuments(this); + }, + + dispose: function() { + let style = styleFor(this); + if (style) + detach(style); + + for (let i in this.include) + this.include.remove(this.include[i]); + + pagemods.delete(this.id); + } +}); + +function onContentWindow({ target: document }) { + // Return if we have no pagemods + if (pagemods.size === 0) + return; + + let window = document.defaultView; + // XML documents don't have windows, and we don't yet support them. + if (!window) + return; + + // Frame event listeners are bound to the frame the event came from by default + let frame = this; + // We apply only on documents in tabs of Firefox + if (!frame.isTab) + return; + + // When the tab is private, only addons with 'private-browsing' flag in + // their package.json can apply content script to private documents + if (ignoreWindow(window)) + return; + + for (let pagemod of pagemods.values()) { + if (modMatchesURI(pagemod, window.location.href)) + onContent(pagemod, window); + } +} +frames.addEventListener("DOMDocElementInserted", onContentWindow, true); + +function applyOnExistingDocuments (mod) { + for (let frame of frames) { + // Fake a newly created document + let window = frame.content; + // on startup with e10s, contentWindow might not exist yet, + // in which case we will get notified by "document-element-inserted". + if (!window || !window.frames) + return; + let uri = window.location.href; + if (has(mod.attachTo, "top") && modMatchesURI(mod, uri)) + onContent(mod, window); + if (has(mod.attachTo, "frame")) + getFrames(window). + filter(iframe => modMatchesURI(mod, iframe.location.href)). + forEach(frame => onContent(mod, frame)); + } +} + +function createWorker(mod, window) { + let workerId = String(uuid()); + + // Instruct the parent to connect to this worker. Do this first so the parent + // side is connected before the worker attempts to send any messages there + let frame = frames.getFrameForWindow(window.top); + frame.port.emit('sdk/page-mod/worker-create', mod.id, { + id: workerId, + url: window.location.href + }); + + // Create a child worker and notify the parent + let worker = WorkerChild({ + id: workerId, + window: window, + contentScript: mod.contentScript, + contentScriptFile: mod.contentScriptFile, + contentScriptOptions: mod.contentScriptOptions + }); + + once(worker, 'detach', () => worker.destroy()); +} + +function onContent (mod, window) { + let isTopDocument = window.top === window; + // Is a top level document and `top` is not set, ignore + if (isTopDocument && !has(mod.attachTo, "top")) + return; + // Is a frame document and `frame` is not set, ignore + if (!isTopDocument && !has(mod.attachTo, "frame")) + return; + + // ensure we attach only once per document + let seen = mod.seenDocuments; + if (seen.has(window.document)) + return; + seen.set(window.document, true); + + let style = styleFor(mod); + if (style) + attach(style, window); + + // Immediately evaluate content script if the document state is already + // matching contentScriptWhen expectations + if (isMatchingAttachState(mod, window)) { + createWorker(mod, window); + return; + } + + let eventName = getAttachEventType(mod) || 'load'; + domOn(window, eventName, function onReady (e) { + if (e.target.defaultView !== window) + return; + domOff(window, eventName, onReady, true); + createWorker(mod, window); + + // Attaching is asynchronous so if the document is already loaded we will + // miss the pageshow event so send a synthetic one. + if (window.document.readyState == "complete") { + mod.on('attach', worker => { + try { + worker.send('pageshow'); + emit(worker, 'pageshow'); + } + catch (e) { + // This can fail if an earlier attach listener destroyed the worker + } + }); + } + }, true); +} + +function isMatchingAttachState (mod, window) { + let state = window.document.readyState; + return 'start' === mod.contentScriptWhen || + // Is `load` event already dispatched? + 'complete' === state || + // Is DOMContentLoaded already dispatched and waiting for it? + ('ready' === mod.contentScriptWhen && state === 'interactive') +} + +process.port.on('sdk/page-mod/create', (process, model) => { + if (pagemods.has(model.id)) + return; + + new ChildPageMod(model); +}); + +process.port.on('sdk/page-mod/destroy', (process, id) => { + let mod = pagemods.get(id); + if (mod) + mod.destroy(); +}); diff --git a/addon-sdk/source/lib/sdk/content/page-worker.js b/addon-sdk/source/lib/sdk/content/page-worker.js new file mode 100644 index 000000000..e9e741120 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/page-worker.js @@ -0,0 +1,154 @@ +/* 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 { frames } = require("../remote/child"); +const { Class } = require("../core/heritage"); +const { Disposable } = require('../core/disposable'); +const { data } = require("../self"); +const { once } = require("../dom/events"); +const { getAttachEventType } = require("./utils"); +const { Rules } = require('../util/rules'); +const { uuid } = require('../util/uuid'); +const { WorkerChild } = require("./worker-child"); +const { Cc, Ci, Cu } = require("chrome"); +const { observe } = require("../event/chrome"); +const { on } = require("../event/core"); + +const appShell = Cc["@mozilla.org/appshell/appShellService;1"].getService(Ci.nsIAppShellService); + +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); + +const pages = new Map(); + +const DOC_INSERTED = "document-element-inserted"; + +function isValidURL(page, url) { + return !page.rules || page.rules.matchesAny(url); +} + +const ChildPage = Class({ + implements: [ Disposable ], + setup: function(frame, id, options) { + this.id = id; + this.frame = frame; + this.options = options; + + this.webNav = appShell.createWindowlessBrowser(false); + this.docShell.allowJavascript = this.options.allow.script; + + // Accessing the browser's window forces the initial about:blank document to + // be created before we start listening for notifications + this.contentWindow; + + this.webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); + + pages.set(this.id, this); + + this.contentURL = options.contentURL; + + if (options.include) { + this.rules = Rules(); + this.rules.add.apply(this.rules, [].concat(options.include)); + } + }, + + dispose: function() { + pages.delete(this.id); + this.webProgress.removeProgressListener(this); + this.webNav.close(); + this.webNav = null; + }, + + attachWorker: function() { + if (!isValidURL(this, this.contentWindow.location.href)) + return; + + this.options.id = uuid().toString(); + this.options.window = this.contentWindow; + this.frame.port.emit("sdk/frame/connect", this.id, { + id: this.options.id, + url: this.contentWindow.document.documentURIObject.spec + }); + new WorkerChild(this.options); + }, + + get docShell() { + return this.webNav.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + }, + + get webProgress() { + return this.docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + }, + + get contentWindow() { + return this.docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + }, + + get contentURL() { + return this.options.contentURL; + }, + set contentURL(url) { + this.options.contentURL = url; + + url = this.options.contentURL ? data.url(this.options.contentURL) : "about:blank"; + this.webNav.loadURI(url, Ci.nsIWebNavigation.LOAD_FLAGS_NONE, null, null, null); + }, + + onLocationChange: function(progress, request, location, flags) { + // Ignore inner-frame events + if (progress != this.webProgress) + return; + // Ignore events that don't change the document + if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) + return; + + let event = getAttachEventType(this.options); + // Attaching at the start of the load is handled by the + // document-element-inserted listener. + if (event == DOC_INSERTED) + return; + + once(this.contentWindow, event, () => { + this.attachWorker(); + }, false); + }, + + QueryInterface: XPCOMUtils.generateQI(["nsIWebProgressListener", "nsISupportsWeakReference"]) +}); + +on(observe(DOC_INSERTED), "data", ({ target }) => { + let page = Array.from(pages.values()).find(p => p.contentWindow.document === target); + if (!page) + return; + + if (getAttachEventType(page.options) == DOC_INSERTED) + page.attachWorker(); +}); + +frames.port.on("sdk/frame/create", (frame, id, options) => { + new ChildPage(frame, id, options); +}); + +frames.port.on("sdk/frame/set", (frame, id, params) => { + let page = pages.get(id); + if (!page) + return; + + if ("allowScript" in params) + page.docShell.allowJavascript = params.allowScript; + if ("contentURL" in params) + page.contentURL = params.contentURL; +}); + +frames.port.on("sdk/frame/destroy", (frame, id) => { + let page = pages.get(id); + if (!page) + return; + + page.destroy(); +}); diff --git a/addon-sdk/source/lib/sdk/content/sandbox.js b/addon-sdk/source/lib/sdk/content/sandbox.js new file mode 100644 index 000000000..096ba5c87 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/sandbox.js @@ -0,0 +1,426 @@ +/* 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'; + +module.metadata = { + 'stability': 'unstable' +}; + +const { Class } = require('../core/heritage'); +const { EventTarget } = require('../event/target'); +const { on, off, emit } = require('../event/core'); +const { events } = require('./sandbox/events'); +const { requiresAddonGlobal } = require('./utils'); +const { delay: async } = require('../lang/functional'); +const { Ci, Cu, Cc } = require('chrome'); +const timer = require('../timers'); +const { URL } = require('../url'); +const { sandbox, evaluate, load } = require('../loader/sandbox'); +const { merge } = require('../util/object'); +const { getTabForContentWindowNoShim } = require('../tabs/utils'); +const { getInnerId } = require('../window/utils'); +const { PlainTextConsole } = require('../console/plain-text'); +const { data } = require('../self');const { isChildLoader } = require('../remote/core'); +// WeakMap of sandboxes so we can access private values +const sandboxes = new WeakMap(); + +/* Trick the linker in order to ensure shipping these files in the XPI. + require('./content-worker.js'); + Then, retrieve URL of these files in the XPI: +*/ +var prefix = module.uri.split('sandbox.js')[0]; +const CONTENT_WORKER_URL = prefix + 'content-worker.js'; +const metadata = require('@loader/options').metadata; + +// Fetch additional list of domains to authorize access to for each content +// script. It is stored in manifest `metadata` field which contains +// package.json data. This list is originaly defined by authors in +// `permissions` attribute of their package.json addon file. +const permissions = (metadata && metadata['permissions']) || {}; +const EXPANDED_PRINCIPALS = permissions['cross-domain-content'] || []; + +const waiveSecurityMembrane = !!permissions['unsafe-content-script']; + +const nsIScriptSecurityManager = Ci.nsIScriptSecurityManager; +const secMan = Cc["@mozilla.org/scriptsecuritymanager;1"]. + getService(Ci.nsIScriptSecurityManager); + +const JS_VERSION = '1.8'; + +// Tests whether this window is loaded in a tab +function isWindowInTab(window) { + if (isChildLoader) { + let { frames } = require('../remote/child'); + let frame = frames.getFrameForWindow(window.top); + return frame && frame.isTab; + } + else { + // The deprecated sync worker API still does everything in the main process + return getTabForContentWindowNoShim(window); + } +} + +const WorkerSandbox = Class({ + implements: [ EventTarget ], + + /** + * Emit a message to the worker content sandbox + */ + emit: function emit(type, ...args) { + // JSON.stringify is buggy with cross-sandbox values, + // it may return "{}" on functions. Use a replacer to match them correctly. + let replacer = (k, v) => + typeof(v) === "function" + ? (type === "console" ? Function.toString.call(v) : void(0)) + : v; + + // Ensure having an asynchronous behavior + async(() => + emitToContent(this, JSON.stringify([type, ...args], replacer)) + ); + }, + + /** + * Synchronous version of `emit`. + * /!\ Should only be used when it is strictly mandatory /!\ + * Doesn't ensure passing only JSON values. + * Mainly used by context-menu in order to avoid breaking it. + */ + emitSync: function emitSync(...args) { + // because the arguments could be also non JSONable values, + // we need to ensure the array instance is created from + // the content's sandbox + return emitToContent(this, new modelFor(this).sandbox.Array(...args)); + }, + + /** + * Configures sandbox and loads content scripts into it. + * @param {Worker} worker + * content worker + */ + initialize: function WorkerSandbox(worker, window) { + let model = {}; + sandboxes.set(this, model); + model.worker = worker; + // We receive a wrapped window, that may be an xraywrapper if it's content + let proto = window; + + // TODO necessary? + // Ensure that `emit` has always the right `this` + this.emit = this.emit.bind(this); + this.emitSync = this.emitSync.bind(this); + + // Use expanded principal for content-script if the content is a + // regular web content for better isolation. + // (This behavior can be turned off for now with the unsafe-content-script + // flag to give addon developers time for making the necessary changes) + // But prevent it when the Worker isn't used for a content script but for + // injecting `addon` object into a Panel scope, for example. + // That's because: + // 1/ It is useless to use multiple domains as the worker is only used + // to communicate with the addon, + // 2/ By using it it would prevent the document to have access to any JS + // value of the worker. As JS values coming from multiple domain principals + // can't be accessed by 'mono-principals' (principal with only one domain). + // Even if this principal is for a domain that is specified in the multiple + // domain principal. + let principals = window; + let wantGlobalProperties = []; + let isSystemPrincipal = secMan.isSystemPrincipal( + window.document.nodePrincipal); + if (!isSystemPrincipal && !requiresAddonGlobal(worker)) { + if (EXPANDED_PRINCIPALS.length > 0) { + // We have to replace XHR constructor of the content document + // with a custom cross origin one, automagically added by platform code: + delete proto.XMLHttpRequest; + wantGlobalProperties.push('XMLHttpRequest'); + } + if (!waiveSecurityMembrane) + principals = EXPANDED_PRINCIPALS.concat(window); + } + + // Create the sandbox and bind it to window in order for content scripts to + // have access to all standard globals (window, document, ...) + let content = sandbox(principals, { + sandboxPrototype: proto, + wantXrays: !requiresAddonGlobal(worker), + wantGlobalProperties: wantGlobalProperties, + wantExportHelpers: true, + sameZoneAs: window, + metadata: { + SDKContentScript: true, + 'inner-window-id': getInnerId(window) + } + }); + model.sandbox = content; + + // We have to ensure that window.top and window.parent are the exact same + // object than window object, i.e. the sandbox global object. But not + // always, in case of iframes, top and parent are another window object. + let top = window.top === window ? content : content.top; + let parent = window.parent === window ? content : content.parent; + merge(content, { + // We need 'this === window === top' to be true in toplevel scope: + get window() { + return content; + }, + get top() { + return top; + }, + get parent() { + return parent; + } + }); + + // Use the Greasemonkey naming convention to provide access to the + // unwrapped window object so the content script can access document + // JavaScript values. + // NOTE: this functionality is experimental and may change or go away + // at any time! + // + // Note that because waivers aren't propagated between origins, we + // need the unsafeWindow getter to live in the sandbox. + var unsafeWindowGetter = + new content.Function('return window.wrappedJSObject || window;'); + Object.defineProperty(content, 'unsafeWindow', {get: unsafeWindowGetter}); + + // Load trusted code that will inject content script API. + let ContentWorker = load(content, CONTENT_WORKER_URL); + + // prepare a clean `self.options` + let options = 'contentScriptOptions' in worker ? + JSON.stringify(worker.contentScriptOptions) : + undefined; + + // Then call `inject` method and communicate with this script + // by trading two methods that allow to send events to the other side: + // - `onEvent` called by content script + // - `result.emitToContent` called by addon script + let onEvent = Cu.exportFunction(onContentEvent.bind(null, this), ContentWorker); + let chromeAPI = createChromeAPI(ContentWorker); + let result = Cu.waiveXrays(ContentWorker).inject(content, chromeAPI, onEvent, options); + + // Merge `emitToContent` into our private model of the + // WorkerSandbox so we can communicate with content script + model.emitToContent = result; + + let console = new PlainTextConsole(null, getInnerId(window)); + + // Handle messages send by this script: + setListeners(this, console); + + // Inject `addon` global into target document if document is trusted, + // `addon` in document is equivalent to `self` in content script. + if (requiresAddonGlobal(worker)) { + Object.defineProperty(getUnsafeWindow(window), 'addon', { + value: content.self, + configurable: true + } + ); + } + + // Inject our `console` into target document if worker doesn't have a tab + // (e.g Panel, PageWorker). + // `worker.tab` can't be used because bug 804935. + if (!isWindowInTab(window)) { + let win = getUnsafeWindow(window); + + // export our chrome console to content window, as described here: + // https://developer.mozilla.org/en-US/docs/Components.utils.createObjectIn + let con = Cu.createObjectIn(win); + + let genPropDesc = function genPropDesc(fun) { + return { enumerable: true, configurable: true, writable: true, + value: console[fun] }; + } + + const properties = { + log: genPropDesc('log'), + info: genPropDesc('info'), + warn: genPropDesc('warn'), + error: genPropDesc('error'), + debug: genPropDesc('debug'), + trace: genPropDesc('trace'), + dir: genPropDesc('dir'), + group: genPropDesc('group'), + groupCollapsed: genPropDesc('groupCollapsed'), + groupEnd: genPropDesc('groupEnd'), + time: genPropDesc('time'), + timeEnd: genPropDesc('timeEnd'), + profile: genPropDesc('profile'), + profileEnd: genPropDesc('profileEnd'), + exception: genPropDesc('exception'), + assert: genPropDesc('assert'), + count: genPropDesc('count'), + table: genPropDesc('table'), + clear: genPropDesc('clear'), + dirxml: genPropDesc('dirxml'), + markTimeline: genPropDesc('markTimeline'), + timeline: genPropDesc('timeline'), + timelineEnd: genPropDesc('timelineEnd'), + timeStamp: genPropDesc('timeStamp'), + }; + + Object.defineProperties(con, properties); + Cu.makeObjectPropsNormal(con); + + win.console = con; + }; + + emit(events, "content-script-before-inserted", { + window: window, + worker: worker + }); + + // The order of `contentScriptFile` and `contentScript` evaluation is + // intentional, so programs can load libraries like jQuery from script URLs + // and use them in scripts. + let contentScriptFile = ('contentScriptFile' in worker) + ? worker.contentScriptFile + : null, + contentScript = ('contentScript' in worker) + ? worker.contentScript + : null; + + if (contentScriptFile) + importScripts.apply(null, [this].concat(contentScriptFile)); + + if (contentScript) { + evaluateIn( + this, + Array.isArray(contentScript) ? contentScript.join(';\n') : contentScript + ); + } + }, + destroy: function destroy(reason) { + if (typeof reason != 'string') + reason = ''; + this.emitSync('event', 'detach', reason); + let model = modelFor(this); + model.sandbox = null + model.worker = null; + }, + +}); + +exports.WorkerSandbox = WorkerSandbox; + +/** + * Imports scripts to the sandbox by reading files under urls and + * evaluating its source. If exception occurs during evaluation + * `'error'` event is emitted on the worker. + * This is actually an analog to the `importScript` method in web + * workers but in our case it's not exposed even though content + * scripts may be able to do it synchronously since IO operation + * takes place in the UI process. + */ +function importScripts (workerSandbox, ...urls) { + let { worker, sandbox } = modelFor(workerSandbox); + for (let i in urls) { + let contentScriptFile = data.url(urls[i]); + + try { + let uri = URL(contentScriptFile); + if (uri.scheme === 'resource') + load(sandbox, String(uri)); + else + throw Error('Unsupported `contentScriptFile` url: ' + String(uri)); + } + catch(e) { + emit(worker, 'error', e); + } + } +} + +function setListeners (workerSandbox, console) { + let { worker } = modelFor(workerSandbox); + // console.xxx calls + workerSandbox.on('console', function consoleListener (kind, ...args) { + console[kind].apply(console, args); + }); + + // self.postMessage calls + workerSandbox.on('message', function postMessage(data) { + // destroyed? + if (worker) + emit(worker, 'message', data); + }); + + // self.port.emit calls + workerSandbox.on('event', function portEmit (...eventArgs) { + // If not destroyed, emit event information to worker + // `eventArgs` has the event name as first element, + // and remaining elements are additional arguments to pass + if (worker) + emit.apply(null, [worker.port].concat(eventArgs)); + }); + + // unwrap, recreate and propagate async Errors thrown from content-script + workerSandbox.on('error', function onError({instanceOfError, value}) { + if (worker) { + let error = value; + if (instanceOfError) { + error = new Error(value.message, value.fileName, value.lineNumber); + error.stack = value.stack; + error.name = value.name; + } + emit(worker, 'error', error); + } + }); +} + +/** + * Evaluates code in the sandbox. + * @param {String} code + * JavaScript source to evaluate. + * @param {String} [filename='javascript:' + code] + * Name of the file + */ +function evaluateIn (workerSandbox, code, filename) { + let { worker, sandbox } = modelFor(workerSandbox); + try { + evaluate(sandbox, code, filename || 'javascript:' + code); + } + catch(e) { + emit(worker, 'error', e); + } +} + +/** + * Method called by the worker sandbox when it needs to send a message + */ +function onContentEvent (workerSandbox, args) { + // As `emit`, we ensure having an asynchronous behavior + async(function () { + // We emit event to chrome/addon listeners + emit.apply(null, [workerSandbox].concat(JSON.parse(args))); + }); +} + + +function modelFor (workerSandbox) { + return sandboxes.get(workerSandbox); +} + +function getUnsafeWindow (win) { + return win.wrappedJSObject || win; +} + +function emitToContent (workerSandbox, args) { + return modelFor(workerSandbox).emitToContent(args); +} + +function createChromeAPI (scope) { + return Cu.cloneInto({ + timers: { + setTimeout: timer.setTimeout.bind(timer), + setInterval: timer.setInterval.bind(timer), + clearTimeout: timer.clearTimeout.bind(timer), + clearInterval: timer.clearInterval.bind(timer), + }, + sandbox: { + evaluate: evaluate, + }, + }, scope, {cloneFunctions: true}); +} diff --git a/addon-sdk/source/lib/sdk/content/sandbox/events.js b/addon-sdk/source/lib/sdk/content/sandbox/events.js new file mode 100644 index 000000000..d6f7eb004 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/sandbox/events.js @@ -0,0 +1,12 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const events = {}; +exports.events = events; diff --git a/addon-sdk/source/lib/sdk/content/tab-events.js b/addon-sdk/source/lib/sdk/content/tab-events.js new file mode 100644 index 000000000..9e244a853 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/tab-events.js @@ -0,0 +1,58 @@ +/* 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 { Ci } = require('chrome'); +const system = require('sdk/system/events'); +const { frames } = require('sdk/remote/child'); +const { WorkerChild } = require('sdk/content/worker-child'); + +// map observer topics to tab event names +const EVENTS = { + 'content-document-global-created': 'create', + 'chrome-document-global-created': 'create', + 'content-document-interactive': 'ready', + 'chrome-document-interactive': 'ready', + 'content-document-loaded': 'load', + 'chrome-document-loaded': 'load', +// 'content-page-shown': 'pageshow', // bug 1024105 +} + +function topicListener({ subject, type }) { + // NOTE detect the window from the subject: + // - on *-global-created the subject is the window + // - in the other cases it is the document object + let window = subject instanceof Ci.nsIDOMWindow ? subject : subject.defaultView; + if (!window){ + return; + } + let frame = frames.getFrameForWindow(window); + if (frame) { + let readyState = frame.content.document.readyState; + frame.port.emit('sdk/tab/event', EVENTS[type], { readyState }); + } +} + +for (let topic in EVENTS) + system.on(topic, topicListener, true); + +// bug 1024105 - content-page-shown notification doesn't pass persisted param +function eventListener({target, type, persisted}) { + let frame = this; + if (target === frame.content.document) { + frame.port.emit('sdk/tab/event', type, persisted); + } +} +frames.addEventListener('pageshow', eventListener, true); + +frames.port.on('sdk/tab/attach', (frame, options) => { + options.window = frame.content; + new WorkerChild(options); +}); + +// Forward the existent frames's readyState. +for (let frame of frames) { + let readyState = frame.content.document.readyState; + frame.port.emit('sdk/tab/event', 'init', { readyState }); +} diff --git a/addon-sdk/source/lib/sdk/content/thumbnail.js b/addon-sdk/source/lib/sdk/content/thumbnail.js new file mode 100644 index 000000000..783615fc6 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/thumbnail.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'; + +module.metadata = { + 'stability': 'unstable' +}; + +const { Cc, Ci, Cu } = require('chrome'); +const AppShellService = Cc['@mozilla.org/appshell/appShellService;1']. + getService(Ci.nsIAppShellService); + +const NS = 'http://www.w3.org/1999/xhtml'; +const COLOR = 'rgb(255,255,255)'; + +/** + * Creates canvas element with a thumbnail of the passed window. + * @param {Window} window + * @returns {Element} + */ +function getThumbnailCanvasForWindow(window) { + let aspectRatio = 0.5625; // 16:9 + let thumbnail = AppShellService.hiddenDOMWindow.document + .createElementNS(NS, 'canvas'); + thumbnail.mozOpaque = true; + thumbnail.width = Math.ceil(window.screen.availWidth / 5.75); + thumbnail.height = Math.round(thumbnail.width * aspectRatio); + let ctx = thumbnail.getContext('2d'); + let snippetWidth = window.innerWidth * .6; + let scale = thumbnail.width / snippetWidth; + ctx.scale(scale, scale); + ctx.drawWindow(window, window.scrollX, window.scrollY, snippetWidth, + snippetWidth * aspectRatio, COLOR); + return thumbnail; +} +exports.getThumbnailCanvasForWindow = getThumbnailCanvasForWindow; + +/** + * Creates Base64 encoded data URI of the thumbnail for the passed window. + * @param {Window} window + * @returns {String} + */ +exports.getThumbnailURIForWindow = function getThumbnailURIForWindow(window) { + return getThumbnailCanvasForWindow(window).toDataURL() +}; + +// default 80x45 blank when not available +exports.BLANK = 'data:image/png;base64,' + + 'iVBORw0KGgoAAAANSUhEUgAAAFAAAAAtCAYAAAA5reyyAAAAJElEQVRoge3BAQ'+ + 'EAAACCIP+vbkhAAQAAAAAAAAAAAAAAAADXBjhtAAGQ0AF/AAAAAElFTkSuQmCC'; diff --git a/addon-sdk/source/lib/sdk/content/utils.js b/addon-sdk/source/lib/sdk/content/utils.js new file mode 100644 index 000000000..90995a614 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/utils.js @@ -0,0 +1,105 @@ +/* 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'; + +module.metadata = { + 'stability': 'unstable' +}; + +var { merge } = require('../util/object'); +var { data } = require('../self'); +var assetsURI = data.url(); +var isArray = Array.isArray; +var method = require('../../method/core'); +var { uuid } = require('../util/uuid'); + +const isAddonContent = ({ contentURL }) => + contentURL && data.url(contentURL).startsWith(assetsURI); + +exports.isAddonContent = isAddonContent; + +function hasContentScript({ contentScript, contentScriptFile }) { + return (isArray(contentScript) ? contentScript.length > 0 : + !!contentScript) || + (isArray(contentScriptFile) ? contentScriptFile.length > 0 : + !!contentScriptFile); +} +exports.hasContentScript = hasContentScript; + +function requiresAddonGlobal(model) { + return model.injectInDocument || (isAddonContent(model) && !hasContentScript(model)); +} +exports.requiresAddonGlobal = requiresAddonGlobal; + +function getAttachEventType(model) { + if (!model) return null; + let when = model.contentScriptWhen; + return requiresAddonGlobal(model) ? 'document-element-inserted' : + when === 'start' ? 'document-element-inserted' : + when === 'ready' ? 'DOMContentLoaded' : + when === 'end' ? 'load' : + null; +} +exports.getAttachEventType = getAttachEventType; + +var attach = method('worker-attach'); +exports.attach = attach; + +var connect = method('worker-connect'); +exports.connect = connect; + +var detach = method('worker-detach'); +exports.detach = detach; + +var destroy = method('worker-destroy'); +exports.destroy = destroy; + +function WorkerHost (workerFor) { + // Define worker properties that just proxy to underlying worker + return ['postMessage', 'port', 'url', 'tab'].reduce(function(proto, name) { + // Use descriptor properties instead so we can call + // the worker function in the context of the worker so we + // don't have to create new functions with `fn.bind(worker)` + let descriptorProp = { + value: function (...args) { + let worker = workerFor(this); + return worker[name].apply(worker, args); + } + }; + + let accessorProp = { + get: function () { return workerFor(this)[name]; }, + set: function (value) { workerFor(this)[name] = value; } + }; + + Object.defineProperty(proto, name, merge({ + enumerable: true, + configurable: false, + }, isDescriptor(name) ? descriptorProp : accessorProp)); + return proto; + }, {}); + + function isDescriptor (prop) { + return ~['postMessage'].indexOf(prop); + } +} +exports.WorkerHost = WorkerHost; + +function makeChildOptions(options) { + function makeStringArray(arrayOrValue) { + if (!arrayOrValue) + return []; + return [].concat(arrayOrValue).map(String); + } + + return { + id: String(uuid()), + contentScript: makeStringArray(options.contentScript), + contentScriptFile: makeStringArray(options.contentScriptFile), + contentScriptOptions: options.contentScriptOptions ? + JSON.stringify(options.contentScriptOptions) : + null, + } +} +exports.makeChildOptions = makeChildOptions; diff --git a/addon-sdk/source/lib/sdk/content/worker-child.js b/addon-sdk/source/lib/sdk/content/worker-child.js new file mode 100644 index 000000000..dbf65a933 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/worker-child.js @@ -0,0 +1,158 @@ +/* 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 { merge } = require('../util/object'); +const { Class } = require('../core/heritage'); +const { emit } = require('../event/core'); +const { EventTarget } = require('../event/target'); +const { getInnerId, getByInnerId } = require('../window/utils'); +const { instanceOf, isObject } = require('../lang/type'); +const system = require('../system/events'); +const { when } = require('../system/unload'); +const { WorkerSandbox } = require('./sandbox'); +const { Ci } = require('chrome'); +const { process, frames } = require('../remote/child'); + +const EVENTS = { + 'chrome-page-shown': 'pageshow', + 'content-page-shown': 'pageshow', + 'chrome-page-hidden': 'pagehide', + 'content-page-hidden': 'pagehide', + 'inner-window-destroyed': 'detach', +} + +// The parent Worker must have been created (or an async message sent to spawn +// its creation) before creating the WorkerChild or messages from the content +// script to the parent will get lost. +const WorkerChild = Class({ + implements: [EventTarget], + + initialize(options) { + merge(this, options); + keepAlive.set(this.id, this); + + this.windowId = getInnerId(this.window); + if (this.contentScriptOptions) + this.contentScriptOptions = JSON.parse(this.contentScriptOptions); + + this.port = EventTarget(); + this.port.on('*', this.send.bind(this, 'event')); + this.on('*', this.send.bind(this)); + + this.observe = this.observe.bind(this); + + for (let topic in EVENTS) + system.on(topic, this.observe); + + this.receive = this.receive.bind(this); + process.port.on('sdk/worker/message', this.receive); + + this.sandbox = WorkerSandbox(this, this.window); + + // If the document has an unexpected readyState, its worker-child instance is initialized + // as frozen until one of the known readyState is reached. + let initialDocumentReadyState = this.window.document.readyState; + this.frozen = [ + "loading", "interactive", "complete" + ].includes(initialDocumentReadyState) ? false : true; + + if (this.frozen) { + console.warn("SDK worker-child started as frozen on unexpected initial document.readyState", { + initialDocumentReadyState, windowLocation: this.window.location.href, + }); + } + + this.frozenMessages = []; + this.on('pageshow', () => { + this.frozen = false; + this.frozenMessages.forEach(args => this.sandbox.emit(...args)); + this.frozenMessages = []; + }); + this.on('pagehide', () => { + this.frozen = true; + }); + }, + + // messages + receive(process, id, args) { + if (id !== this.id) + return; + args = JSON.parse(args); + + if (this.frozen) + this.frozenMessages.push(args); + else + this.sandbox.emit(...args); + + if (args[0] === 'detach') + this.destroy(args[1]); + }, + + send(...args) { + process.port.emit('sdk/worker/event', this.id, JSON.stringify(args, exceptions)); + }, + + // notifications + observe({ type, subject }) { + if (!this.sandbox) + return; + + if (subject.defaultView && getInnerId(subject.defaultView) === this.windowId) { + this.sandbox.emitSync(EVENTS[type]); + emit(this, EVENTS[type]); + } + + if (type === 'inner-window-destroyed' && + subject.QueryInterface(Ci.nsISupportsPRUint64).data === this.windowId) { + this.destroy(); + } + }, + + get frame() { + return frames.getFrameForWindow(this.window.top); + }, + + // detach/destroy: unload and release the sandbox + destroy(reason) { + if (!this.sandbox) + return; + + for (let topic in EVENTS) + system.off(topic, this.observe); + process.port.off('sdk/worker/message', this.receive); + + this.sandbox.destroy(reason); + this.sandbox = null; + keepAlive.delete(this.id); + + this.send('detach'); + } +}) +exports.WorkerChild = WorkerChild; + +// Error instances JSON poorly +function exceptions(key, value) { + if (!isObject(value) || !instanceOf(value, Error)) + return value; + let _errorType = value.constructor.name; + let { message, fileName, lineNumber, stack, name } = value; + return { _errorType, message, fileName, lineNumber, stack, name }; +} + +// workers for windows in this tab +var keepAlive = new Map(); + +process.port.on('sdk/worker/create', (process, options, cpows) => { + options.window = cpows.window; + let worker = new WorkerChild(options); + + let frame = frames.getFrameForWindow(options.window.top); + frame.port.emit('sdk/worker/connect', options.id, options.window.location.href); +}); + +when(reason => { + for (let worker of keepAlive.values()) + worker.destroy(reason); +}); diff --git a/addon-sdk/source/lib/sdk/content/worker.js b/addon-sdk/source/lib/sdk/content/worker.js new file mode 100644 index 000000000..39b940a88 --- /dev/null +++ b/addon-sdk/source/lib/sdk/content/worker.js @@ -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/. */ +"use strict"; + +module.metadata = { + "stability": "unstable" +}; + +const { emit } = require('../event/core'); +const { omit, merge } = require('../util/object'); +const { Class } = require('../core/heritage'); +const { method } = require('../lang/functional'); +const { getInnerId } = require('../window/utils'); +const { EventTarget } = require('../event/target'); +const { isPrivate } = require('../private-browsing/utils'); +const { getTabForBrowser, getTabForContentWindowNoShim, getBrowserForTab } = require('../tabs/utils'); +const { attach, connect, detach, destroy, makeChildOptions } = require('./utils'); +const { ensure } = require('../system/unload'); +const { on: observe } = require('../system/events'); +const { Ci, Cu } = require('chrome'); +const { modelFor: tabFor } = require('sdk/model/core'); +const { remoteRequire, processes, frames } = require('../remote/parent'); +remoteRequire('sdk/content/worker-child'); + +const workers = new WeakMap(); +var modelFor = (worker) => workers.get(worker); + +const ERR_DESTROYED = "Couldn't find the worker to receive this message. " + + "The script may not be initialized yet, or may already have been unloaded."; + +// a handle for communication between content script and addon code +const Worker = Class({ + implements: [EventTarget], + + initialize(options = {}) { + ensure(this, 'detach'); + + let model = { + attached: false, + destroyed: false, + earlyEvents: [], // fired before worker was attached + frozen: true, // document is not yet active + options, + }; + workers.set(this, model); + + this.on('detach', this.detach); + EventTarget.prototype.initialize.call(this, options); + + this.receive = this.receive.bind(this); + + this.port = EventTarget(); + this.port.emit = this.send.bind(this, 'event'); + this.postMessage = this.send.bind(this, 'message'); + + if ('window' in options) { + let window = options.window; + delete options.window; + attach(this, window); + } + }, + + // messages + receive(process, id, args) { + let model = modelFor(this); + if (id !== model.id || !model.attached) + return; + args = JSON.parse(args); + if (model.destroyed && args[0] != 'detach') + return; + + if (args[0] === 'event') + emit(this.port, ...args.slice(1)) + else + emit(this, ...args); + }, + + send(...args) { + let model = modelFor(this); + if (model.destroyed && args[0] !== 'detach') + throw new Error(ERR_DESTROYED); + + if (!model.attached) { + model.earlyEvents.push(args); + return; + } + + processes.port.emit('sdk/worker/message', model.id, JSON.stringify(args)); + }, + + // properties + get url() { + let { url } = modelFor(this); + return url; + }, + + get contentURL() { + return this.url; + }, + + get tab() { + require('sdk/tabs'); + let { frame } = modelFor(this); + if (!frame) + return null; + let rawTab = getTabForBrowser(frame.frameElement); + return rawTab && tabFor(rawTab); + }, + + toString: () => '[object Worker]', + + detach: method(detach), + destroy: method(destroy), +}) +exports.Worker = Worker; + +attach.define(Worker, function(worker, window) { + let model = modelFor(worker); + if (model.attached) + detach(worker); + + let childOptions = makeChildOptions(model.options); + processes.port.emitCPOW('sdk/worker/create', [childOptions], { window }); + + let listener = (frame, id, url) => { + if (id != childOptions.id) + return; + frames.port.off('sdk/worker/connect', listener); + connect(worker, frame, { id, url }); + }; + frames.port.on('sdk/worker/connect', listener); +}); + +connect.define(Worker, function(worker, frame, { id, url }) { + let model = modelFor(worker); + if (model.attached) + detach(worker); + + model.id = id; + model.frame = frame; + model.url = url; + + // Messages from content -> chrome come through the process message manager + // since that lives longer than the frame message manager + processes.port.on('sdk/worker/event', worker.receive); + + model.attached = true; + model.destroyed = false; + model.frozen = false; + + model.earlyEvents.forEach(args => worker.send(...args)); + model.earlyEvents = []; + emit(worker, 'attach'); +}); + +// unload and release the child worker, release window reference +detach.define(Worker, function(worker) { + let model = modelFor(worker); + if (!model.attached) + return; + + processes.port.off('sdk/worker/event', worker.receive); + model.attached = false; + model.destroyed = true; + emit(worker, 'detach'); +}); + +isPrivate.define(Worker, ({ tab }) => isPrivate(tab)); + +// Something in the parent side has destroyed the worker, tell the child to +// detach, the child will respond when it has detached +destroy.define(Worker, function(worker, reason) { + let model = modelFor(worker); + model.destroyed = true; + if (!model.attached) + return; + + worker.send('detach', reason); +}); diff --git a/addon-sdk/source/lib/sdk/context-menu.js b/addon-sdk/source/lib/sdk/context-menu.js new file mode 100644 index 000000000..004c642d4 --- /dev/null +++ b/addon-sdk/source/lib/sdk/context-menu.js @@ -0,0 +1,1188 @@ +/* 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"; + +module.metadata = { + "stability": "stable", + "engines": { + // TODO Fennec support Bug 788334 + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Class, mix } = require("./core/heritage"); +const { addCollectionProperty } = require("./util/collection"); +const { ns } = require("./core/namespace"); +const { validateOptions, getTypeOf } = require("./deprecated/api-utils"); +const { URL, isValidURI } = require("./url"); +const { WindowTracker, browserWindowIterator } = require("./deprecated/window-utils"); +const { isBrowser, getInnerId } = require("./window/utils"); +const { MatchPattern } = require("./util/match-pattern"); +const { EventTarget } = require("./event/target"); +const { emit } = require('./event/core'); +const { when } = require('./system/unload'); +const { contract: loaderContract } = require('./content/loader'); +const { omit } = require('./util/object'); +const self = require('./self') +const { remoteRequire, processes } = require('./remote/parent'); +remoteRequire('sdk/content/context-menu'); + +// All user items we add have this class. +const ITEM_CLASS = "addon-context-menu-item"; + +// Items in the top-level context menu also have this class. +const TOPLEVEL_ITEM_CLASS = "addon-context-menu-item-toplevel"; + +// Items in the overflow submenu also have this class. +const OVERFLOW_ITEM_CLASS = "addon-context-menu-item-overflow"; + +// The class of the menu separator that separates standard context menu items +// from our user items. +const SEPARATOR_CLASS = "addon-context-menu-separator"; + +// If more than this number of items are added to the context menu, all items +// overflow into a "Jetpack" submenu. +const OVERFLOW_THRESH_DEFAULT = 10; +const OVERFLOW_THRESH_PREF = + "extensions.addon-sdk.context-menu.overflowThreshold"; + +// The label of the overflow sub-xul:menu. +// +// TODO: Localize these. +const OVERFLOW_MENU_LABEL = "Add-ons"; +const OVERFLOW_MENU_ACCESSKEY = "A"; + +// The class of the overflow sub-xul:menu. +const OVERFLOW_MENU_CLASS = "addon-content-menu-overflow-menu"; + +// The class of the overflow submenu's xul:menupopup. +const OVERFLOW_POPUP_CLASS = "addon-content-menu-overflow-popup"; + +// Holds private properties for API objects +var internal = ns(); + +// A little hacky but this is the last process ID that last opened the context +// menu +var lastContextProcessId = null; + +var uuidModule = require('./util/uuid'); +function uuid() { + return uuidModule.uuid().toString(); +} + +function getScheme(spec) { + try { + return URL(spec).scheme; + } + catch(e) { + return null; + } +} + +var Context = Class({ + initialize: function() { + internal(this).id = uuid(); + }, + + // Returns the node that made this context current + adjustPopupNode: function adjustPopupNode(popupNode) { + return popupNode; + }, + + // Returns whether this context is current for the current node + isCurrent: function isCurrent(state) { + return state; + } +}); + +// Matches when the context-clicked node doesn't have any of +// NON_PAGE_CONTEXT_ELTS in its ancestors +var PageContext = Class({ + extends: Context, + + serialize: function() { + return { + id: internal(this).id, + type: "PageContext", + args: [] + } + } +}); +exports.PageContext = PageContext; + +// Matches when there is an active selection in the window +var SelectionContext = Class({ + extends: Context, + + serialize: function() { + return { + id: internal(this).id, + type: "SelectionContext", + args: [] + } + } +}); +exports.SelectionContext = SelectionContext; + +// Matches when the context-clicked node or any of its ancestors matches the +// selector given +var SelectorContext = Class({ + extends: Context, + + initialize: function initialize(selector) { + Context.prototype.initialize.call(this); + let options = validateOptions({ selector: selector }, { + selector: { + is: ["string"], + msg: "selector must be a string." + } + }); + internal(this).selector = options.selector; + }, + + serialize: function() { + return { + id: internal(this).id, + type: "SelectorContext", + args: [internal(this).selector] + } + } +}); +exports.SelectorContext = SelectorContext; + +// Matches when the page url matches any of the patterns given +var URLContext = Class({ + extends: Context, + + initialize: function initialize(patterns) { + Context.prototype.initialize.call(this); + patterns = Array.isArray(patterns) ? patterns : [patterns]; + + try { + internal(this).patterns = patterns.map(p => new MatchPattern(p)); + } + catch (err) { + throw new Error("Patterns must be a string, regexp or an array of " + + "strings or regexps: " + err); + } + }, + + isCurrent: function isCurrent(url) { + return internal(this).patterns.some(p => p.test(url)); + }, + + serialize: function() { + return { + id: internal(this).id, + type: "URLContext", + args: [] + } + } +}); +exports.URLContext = URLContext; + +// Matches when the user-supplied predicate returns true +var PredicateContext = Class({ + extends: Context, + + initialize: function initialize(predicate) { + Context.prototype.initialize.call(this); + let options = validateOptions({ predicate: predicate }, { + predicate: { + is: ["function"], + msg: "predicate must be a function." + } + }); + internal(this).predicate = options.predicate; + }, + + isCurrent: function isCurrent(state) { + return internal(this).predicate(state); + }, + + serialize: function() { + return { + id: internal(this).id, + type: "PredicateContext", + args: [] + } + } +}); +exports.PredicateContext = PredicateContext; + +function removeItemFromArray(array, item) { + return array.filter(i => i !== item); +} + +// Converts anything that isn't false, null or undefined into a string +function stringOrNull(val) { + return val ? String(val) : val; +} + +// Shared option validation rules for Item, Menu, and Separator +var baseItemRules = { + parentMenu: { + is: ["object", "undefined"], + ok: function (v) { + if (!v) + return true; + return (v instanceof ItemContainer) || (v instanceof Menu); + }, + msg: "parentMenu must be a Menu or not specified." + }, + context: { + is: ["undefined", "object", "array"], + ok: function (v) { + if (!v) + return true; + let arr = Array.isArray(v) ? v : [v]; + return arr.every(o => o instanceof Context); + }, + msg: "The 'context' option must be a Context object or an array of " + + "Context objects." + }, + onMessage: { + is: ["function", "undefined"] + }, + contentScript: loaderContract.rules.contentScript, + contentScriptFile: loaderContract.rules.contentScriptFile +}; + +var labelledItemRules = mix(baseItemRules, { + label: { + map: stringOrNull, + is: ["string"], + ok: v => !!v, + msg: "The item must have a non-empty string label." + }, + accesskey: { + map: stringOrNull, + is: ["string", "undefined", "null"], + ok: (v) => { + if (!v) { + return true; + } + return typeof v == "string" && v.length === 1; + }, + msg: "The item must have a single character accesskey, or no accesskey." + }, + image: { + map: stringOrNull, + is: ["string", "undefined", "null"], + ok: function (url) { + if (!url) + return true; + return isValidURI(url); + }, + msg: "Image URL validation failed" + } +}); + +// Additional validation rules for Item +var itemRules = mix(labelledItemRules, { + data: { + map: stringOrNull, + is: ["string", "undefined", "null"] + } +}); + +// Additional validation rules for Menu +var menuRules = mix(labelledItemRules, { + items: { + is: ["array", "undefined"], + ok: function (v) { + if (!v) + return true; + return v.every(function (item) { + return item instanceof BaseItem; + }); + }, + msg: "items must be an array, and each element in the array must be an " + + "Item, Menu, or Separator." + } +}); + +// Returns true if any contexts match. If there are no contexts then a +// PageContext is tested instead +function hasMatchingContext(contexts, addonInfo) { + for (let context of contexts) { + if (!(internal(context).id in addonInfo.contextStates)) { + console.error("Missing state for context " + internal(context).id + " this is an error in the SDK modules."); + return false; + } + if (!context.isCurrent(addonInfo.contextStates[internal(context).id])) + return false; + } + + return true; +} + +// Tests whether an item should be visible or not based on its contexts and +// content scripts +function isItemVisible(item, addonInfo, usePageWorker) { + if (!item.context.length) { + if (!addonInfo.hasWorker) + return usePageWorker ? addonInfo.pageContext : true; + } + + if (!hasMatchingContext(item.context, addonInfo)) + return false; + + let context = addonInfo.workerContext; + if (typeof(context) === "string" && context != "") + item.label = context; + + return !!context; +} + +// Called when an item is clicked to send out click events to the content +// scripts +function itemActivated(item, clickedNode) { + let items = [internal(item).id]; + let data = item.data; + + while (item.parentMenu) { + item = item.parentMenu; + items.push(internal(item).id); + } + + let process = processes.getById(lastContextProcessId); + if (process) + process.port.emit('sdk/contextmenu/activateitems', items, data); +} + +function serializeItem(item) { + return { + id: internal(item).id, + contexts: item.context.map(c => c.serialize()), + contentScript: item.contentScript, + contentScriptFile: item.contentScriptFile, + }; +} + +// All things that appear in the context menu extend this +var BaseItem = Class({ + initialize: function initialize() { + internal(this).id = uuid(); + + internal(this).contexts = []; + if ("context" in internal(this).options && internal(this).options.context) { + let contexts = internal(this).options.context; + if (Array.isArray(contexts)) { + for (let context of contexts) + internal(this).contexts.push(context); + } + else { + internal(this).contexts.push(contexts); + } + } + + let parentMenu = internal(this).options.parentMenu; + if (!parentMenu) + parentMenu = contentContextMenu; + + parentMenu.addItem(this); + + Object.defineProperty(this, "contentScript", { + enumerable: true, + value: internal(this).options.contentScript + }); + + // Resolve URIs here as tests may have overriden self + let files = internal(this).options.contentScriptFile; + if (files) { + if (!Array.isArray(files)) + files = [files]; + files = files.map(self.data.url); + } + internal(this).options.contentScriptFile = files; + Object.defineProperty(this, "contentScriptFile", { + enumerable: true, + value: internal(this).options.contentScriptFile + }); + + // Notify all frames of this new item + sendItems([serializeItem(this)]); + }, + + destroy: function destroy() { + if (internal(this).destroyed) + return; + + // Tell all existing frames that this item has been destroyed + processes.port.emit("sdk/contextmenu/destroyitems", [internal(this).id]); + + if (this.parentMenu) + this.parentMenu.removeItem(this); + + internal(this).destroyed = true; + }, + + get context() { + let contexts = internal(this).contexts.slice(0); + contexts.add = (context) => { + internal(this).contexts.push(context); + // Notify all frames that this item has changed + sendItems([serializeItem(this)]); + }; + contexts.remove = (context) => { + internal(this).contexts = internal(this).contexts.filter(c => { + return c != context; + }); + // Notify all frames that this item has changed + sendItems([serializeItem(this)]); + }; + return contexts; + }, + + set context(val) { + internal(this).contexts = val.slice(0); + // Notify all frames that this item has changed + sendItems([serializeItem(this)]); + }, + + get parentMenu() { + return internal(this).parentMenu; + }, +}); + +function workerMessageReceived(process, id, args) { + if (internal(this).id != id) + return; + + emit(this, ...JSON.parse(args)); +} + +// All things that have a label on the context menu extend this +var LabelledItem = Class({ + extends: BaseItem, + implements: [ EventTarget ], + + initialize: function initialize(options) { + BaseItem.prototype.initialize.call(this); + EventTarget.prototype.initialize.call(this, options); + + internal(this).messageListener = workerMessageReceived.bind(this); + processes.port.on('sdk/worker/event', internal(this).messageListener); + }, + + destroy: function destroy() { + if (internal(this).destroyed) + return; + + processes.port.off('sdk/worker/event', internal(this).messageListener); + + BaseItem.prototype.destroy.call(this); + }, + + get label() { + return internal(this).options.label; + }, + + set label(val) { + internal(this).options.label = val; + + MenuManager.updateItem(this); + }, + + get accesskey() { + return internal(this).options.accesskey; + }, + + set accesskey(val) { + internal(this).options.accesskey = val; + + MenuManager.updateItem(this); + }, + + get image() { + return internal(this).options.image; + }, + + set image(val) { + internal(this).options.image = val; + + MenuManager.updateItem(this); + }, + + get data() { + return internal(this).options.data; + }, + + set data(val) { + internal(this).options.data = val; + } +}); + +var Item = Class({ + extends: LabelledItem, + + initialize: function initialize(options) { + internal(this).options = validateOptions(options, itemRules); + + LabelledItem.prototype.initialize.call(this, options); + }, + + toString: function toString() { + return "[object Item \"" + this.label + "\"]"; + }, + + get data() { + return internal(this).options.data; + }, + + set data(val) { + internal(this).options.data = val; + + MenuManager.updateItem(this); + }, +}); +exports.Item = Item; + +var ItemContainer = Class({ + initialize: function initialize() { + internal(this).children = []; + }, + + destroy: function destroy() { + // Destroys the entire hierarchy + for (let item of internal(this).children) + item.destroy(); + }, + + addItem: function addItem(item) { + let oldParent = item.parentMenu; + + // Don't just call removeItem here as that would remove the corresponding + // UI element which is more costly than just moving it to the right place + if (oldParent) + internal(oldParent).children = removeItemFromArray(internal(oldParent).children, item); + + let after = null; + let children = internal(this).children; + if (children.length > 0) + after = children[children.length - 1]; + + children.push(item); + internal(item).parentMenu = this; + + // If there was an old parent then we just have to move the item, otherwise + // it needs to be created + if (oldParent) + MenuManager.moveItem(item, after); + else + MenuManager.createItem(item, after); + }, + + removeItem: function removeItem(item) { + // If the item isn't a child of this menu then ignore this call + if (item.parentMenu !== this) + return; + + MenuManager.removeItem(item); + + internal(this).children = removeItemFromArray(internal(this).children, item); + internal(item).parentMenu = null; + }, + + get items() { + return internal(this).children.slice(0); + }, + + set items(val) { + // Validate the arguments before making any changes + if (!Array.isArray(val)) + throw new Error(menuOptionRules.items.msg); + + for (let item of val) { + if (!(item instanceof BaseItem)) + throw new Error(menuOptionRules.items.msg); + } + + // Remove the old items and add the new ones + for (let item of internal(this).children) + this.removeItem(item); + + for (let item of val) + this.addItem(item); + }, +}); + +var Menu = Class({ + extends: LabelledItem, + implements: [ItemContainer], + + initialize: function initialize(options) { + internal(this).options = validateOptions(options, menuRules); + + LabelledItem.prototype.initialize.call(this, options); + ItemContainer.prototype.initialize.call(this); + + if (internal(this).options.items) { + for (let item of internal(this).options.items) + this.addItem(item); + } + }, + + destroy: function destroy() { + ItemContainer.prototype.destroy.call(this); + LabelledItem.prototype.destroy.call(this); + }, + + toString: function toString() { + return "[object Menu \"" + this.label + "\"]"; + }, +}); +exports.Menu = Menu; + +var Separator = Class({ + extends: BaseItem, + + initialize: function initialize(options) { + internal(this).options = validateOptions(options, baseItemRules); + + BaseItem.prototype.initialize.call(this); + }, + + toString: function toString() { + return "[object Separator]"; + } +}); +exports.Separator = Separator; + +// Holds items for the content area context menu +var contentContextMenu = ItemContainer(); +exports.contentContextMenu = contentContextMenu; + +function getContainerItems(container) { + let items = []; + for (let item of internal(container).children) { + items.push(serializeItem(item)); + if (item instanceof Menu) + items = items.concat(getContainerItems(item)); + } + return items; +} + +// Notify all frames of these new or changed items +function sendItems(items) { + processes.port.emit("sdk/contextmenu/createitems", items); +} + +// Called when a new process is created and needs to get the current list of items +function remoteItemRequest(process) { + let items = getContainerItems(contentContextMenu); + if (items.length == 0) + return; + + process.port.emit("sdk/contextmenu/createitems", items); +} +processes.forEvery(remoteItemRequest); + +when(function() { + contentContextMenu.destroy(); +}); + +// App specific UI code lives here, it should handle populating the context +// menu and passing clicks etc. through to the items. + +function countVisibleItems(nodes) { + return Array.reduce(nodes, function(sum, node) { + return node.hidden ? sum : sum + 1; + }, 0); +} + +var MenuWrapper = Class({ + initialize: function initialize(winWrapper, items, contextMenu) { + this.winWrapper = winWrapper; + this.window = winWrapper.window; + this.items = items; + this.contextMenu = contextMenu; + this.populated = false; + this.menuMap = new Map(); + + // updateItemVisibilities will run first, updateOverflowState will run after + // all other instances of this module have run updateItemVisibilities + this._updateItemVisibilities = this.updateItemVisibilities.bind(this); + this.contextMenu.addEventListener("popupshowing", this._updateItemVisibilities, true); + this._updateOverflowState = this.updateOverflowState.bind(this); + this.contextMenu.addEventListener("popupshowing", this._updateOverflowState, false); + }, + + destroy: function destroy() { + this.contextMenu.removeEventListener("popupshowing", this._updateOverflowState, false); + this.contextMenu.removeEventListener("popupshowing", this._updateItemVisibilities, true); + + if (!this.populated) + return; + + // If we're getting unloaded at runtime then we must remove all the + // generated XUL nodes + let oldParent = null; + for (let item of internal(this.items).children) { + let xulNode = this.getXULNodeForItem(item); + oldParent = xulNode.parentNode; + oldParent.removeChild(xulNode); + } + + if (oldParent) + this.onXULRemoved(oldParent); + }, + + get separator() { + return this.contextMenu.querySelector("." + SEPARATOR_CLASS); + }, + + get overflowMenu() { + return this.contextMenu.querySelector("." + OVERFLOW_MENU_CLASS); + }, + + get overflowPopup() { + return this.contextMenu.querySelector("." + OVERFLOW_POPUP_CLASS); + }, + + get topLevelItems() { + return this.contextMenu.querySelectorAll("." + TOPLEVEL_ITEM_CLASS); + }, + + get overflowItems() { + return this.contextMenu.querySelectorAll("." + OVERFLOW_ITEM_CLASS); + }, + + getXULNodeForItem: function getXULNodeForItem(item) { + return this.menuMap.get(item); + }, + + // Recurses through the item hierarchy creating XUL nodes for everything + populate: function populate(menu) { + for (let i = 0; i < internal(menu).children.length; i++) { + let item = internal(menu).children[i]; + let after = i === 0 ? null : internal(menu).children[i - 1]; + this.createItem(item, after); + + if (item instanceof Menu) + this.populate(item); + } + }, + + // Recurses through the menu setting the visibility of items. Returns true + // if any of the items in this menu were visible + setVisibility: function setVisibility(menu, addonInfo, usePageWorker) { + let anyVisible = false; + + for (let item of internal(menu).children) { + let visible = isItemVisible(item, addonInfo[internal(item).id], usePageWorker); + + // Recurse through Menus, if none of the sub-items were visible then the + // menu is hidden too. + if (visible && (item instanceof Menu)) + visible = this.setVisibility(item, addonInfo, false); + + let xulNode = this.getXULNodeForItem(item); + xulNode.hidden = !visible; + + anyVisible = anyVisible || visible; + } + + return anyVisible; + }, + + // Works out where to insert a XUL node for an item in a browser window + insertIntoXUL: function insertIntoXUL(item, node, after) { + let menupopup = null; + let before = null; + + let menu = item.parentMenu; + if (menu === this.items) { + // Insert into the overflow popup if it exists, otherwise the normal + // context menu + menupopup = this.overflowPopup; + if (!menupopup) + menupopup = this.contextMenu; + } + else { + let xulNode = this.getXULNodeForItem(menu); + menupopup = xulNode.firstChild; + } + + if (after) { + let afterNode = this.getXULNodeForItem(after); + before = afterNode.nextSibling; + } + else if (menupopup === this.contextMenu) { + let topLevel = this.topLevelItems; + if (topLevel.length > 0) + before = topLevel[topLevel.length - 1].nextSibling; + else + before = this.separator.nextSibling; + } + + menupopup.insertBefore(node, before); + }, + + // Sets the right class for XUL nodes + updateXULClass: function updateXULClass(xulNode) { + if (xulNode.parentNode == this.contextMenu) + xulNode.classList.add(TOPLEVEL_ITEM_CLASS); + else + xulNode.classList.remove(TOPLEVEL_ITEM_CLASS); + + if (xulNode.parentNode == this.overflowPopup) + xulNode.classList.add(OVERFLOW_ITEM_CLASS); + else + xulNode.classList.remove(OVERFLOW_ITEM_CLASS); + }, + + // Creates a XUL node for an item + createItem: function createItem(item, after) { + if (!this.populated) + return; + + // Create the separator if it doesn't already exist + if (!this.separator) { + let separator = this.window.document.createElement("menuseparator"); + separator.setAttribute("class", SEPARATOR_CLASS); + + // Insert before the separator created by the old context-menu if it + // exists to avoid bug 832401 + let oldSeparator = this.window.document.getElementById("jetpack-context-menu-separator"); + if (oldSeparator && oldSeparator.parentNode != this.contextMenu) + oldSeparator = null; + this.contextMenu.insertBefore(separator, oldSeparator); + } + + let type = "menuitem"; + if (item instanceof Menu) + type = "menu"; + else if (item instanceof Separator) + type = "menuseparator"; + + let xulNode = this.window.document.createElement(type); + xulNode.setAttribute("class", ITEM_CLASS); + if (item instanceof LabelledItem) { + xulNode.setAttribute("label", item.label); + if (item.accesskey) + xulNode.setAttribute("accesskey", item.accesskey); + if (item.image) { + xulNode.setAttribute("image", item.image); + if (item instanceof Menu) + xulNode.classList.add("menu-iconic"); + else + xulNode.classList.add("menuitem-iconic"); + } + if (item.data) + xulNode.setAttribute("value", item.data); + + let self = this; + xulNode.addEventListener("command", function(event) { + // Only care about clicks directly on this item + if (event.target !== xulNode) + return; + + itemActivated(item, xulNode); + }, false); + } + + this.insertIntoXUL(item, xulNode, after); + this.updateXULClass(xulNode); + xulNode.data = item.data; + + if (item instanceof Menu) { + let menupopup = this.window.document.createElement("menupopup"); + xulNode.appendChild(menupopup); + } + + this.menuMap.set(item, xulNode); + }, + + // Updates the XUL node for an item in this window + updateItem: function updateItem(item) { + if (!this.populated) + return; + + let xulNode = this.getXULNodeForItem(item); + + // TODO figure out why this requires setAttribute + xulNode.setAttribute("label", item.label); + xulNode.setAttribute("accesskey", item.accesskey || ""); + + if (item.image) { + xulNode.setAttribute("image", item.image); + if (item instanceof Menu) + xulNode.classList.add("menu-iconic"); + else + xulNode.classList.add("menuitem-iconic"); + } + else { + xulNode.removeAttribute("image"); + xulNode.classList.remove("menu-iconic"); + xulNode.classList.remove("menuitem-iconic"); + } + + if (item.data) + xulNode.setAttribute("value", item.data); + else + xulNode.removeAttribute("value"); + }, + + // Moves the XUL node for an item in this window to its new place in the + // hierarchy + moveItem: function moveItem(item, after) { + if (!this.populated) + return; + + let xulNode = this.getXULNodeForItem(item); + let oldParent = xulNode.parentNode; + + this.insertIntoXUL(item, xulNode, after); + this.updateXULClass(xulNode); + this.onXULRemoved(oldParent); + }, + + // Removes the XUL nodes for an item in every window we've ever populated. + removeItem: function removeItem(item) { + if (!this.populated) + return; + + let xulItem = this.getXULNodeForItem(item); + + let oldParent = xulItem.parentNode; + + oldParent.removeChild(xulItem); + this.menuMap.delete(item); + + this.onXULRemoved(oldParent); + }, + + // Called when any XUL nodes have been removed from a menupopup. This handles + // making sure the separator and overflow are correct + onXULRemoved: function onXULRemoved(parent) { + if (parent == this.contextMenu) { + let toplevel = this.topLevelItems; + + // If there are no more items then remove the separator + if (toplevel.length == 0) { + let separator = this.separator; + if (separator) + separator.parentNode.removeChild(separator); + } + } + else if (parent == this.overflowPopup) { + // If there are no more items then remove the overflow menu and separator + if (parent.childNodes.length == 0) { + let separator = this.separator; + separator.parentNode.removeChild(separator); + this.contextMenu.removeChild(parent.parentNode); + } + } + }, + + // Recurses through all the items owned by this module and sets their hidden + // state + updateItemVisibilities: function updateItemVisibilities(event) { + try { + if (event.type != "popupshowing") + return; + if (event.target != this.contextMenu) + return; + + if (internal(this.items).children.length == 0) + return; + + if (!this.populated) { + this.populated = true; + this.populate(this.items); + } + + let mainWindow = event.target.ownerDocument.defaultView; + this.contextMenuContentData = mainWindow.gContextMenuContentData + if (!(self.id in this.contextMenuContentData.addonInfo)) { + console.warn("No context menu state data was provided."); + return; + } + let addonInfo = this.contextMenuContentData.addonInfo[self.id]; + lastContextProcessId = addonInfo.processID; + this.setVisibility(this.items, addonInfo.items, true); + } + catch (e) { + console.exception(e); + } + }, + + // Counts the number of visible items across all modules and makes sure they + // are in the right place between the top level context menu and the overflow + // menu + updateOverflowState: function updateOverflowState(event) { + try { + if (event.type != "popupshowing") + return; + if (event.target != this.contextMenu) + return; + + // The main items will be in either the top level context menu or the + // overflow menu at this point. Count the visible ones and if they are in + // the wrong place move them + let toplevel = this.topLevelItems; + let overflow = this.overflowItems; + let visibleCount = countVisibleItems(toplevel) + + countVisibleItems(overflow); + + if (visibleCount == 0) { + let separator = this.separator; + if (separator) + separator.hidden = true; + let overflowMenu = this.overflowMenu; + if (overflowMenu) + overflowMenu.hidden = true; + } + else if (visibleCount > MenuManager.overflowThreshold) { + this.separator.hidden = false; + let overflowPopup = this.overflowPopup; + if (overflowPopup) + overflowPopup.parentNode.hidden = false; + + if (toplevel.length > 0) { + // The overflow menu shouldn't exist here but let's play it safe + if (!overflowPopup) { + let overflowMenu = this.window.document.createElement("menu"); + overflowMenu.setAttribute("class", OVERFLOW_MENU_CLASS); + overflowMenu.setAttribute("label", OVERFLOW_MENU_LABEL); + overflowMenu.setAttribute("accesskey", OVERFLOW_MENU_ACCESSKEY); + this.contextMenu.insertBefore(overflowMenu, this.separator.nextSibling); + + overflowPopup = this.window.document.createElement("menupopup"); + overflowPopup.setAttribute("class", OVERFLOW_POPUP_CLASS); + overflowMenu.appendChild(overflowPopup); + } + + for (let xulNode of toplevel) { + overflowPopup.appendChild(xulNode); + this.updateXULClass(xulNode); + } + } + } + else { + this.separator.hidden = false; + + if (overflow.length > 0) { + // Move all the overflow nodes out of the overflow menu and position + // them immediately before it + for (let xulNode of overflow) { + this.contextMenu.insertBefore(xulNode, xulNode.parentNode.parentNode); + this.updateXULClass(xulNode); + } + this.contextMenu.removeChild(this.overflowMenu); + } + } + } + catch (e) { + console.exception(e); + } + } +}); + +// This wraps every window that we've seen +var WindowWrapper = Class({ + initialize: function initialize(window) { + this.window = window; + this.menus = [ + new MenuWrapper(this, contentContextMenu, window.document.getElementById("contentAreaContextMenu")), + ]; + }, + + destroy: function destroy() { + for (let menuWrapper of this.menus) + menuWrapper.destroy(); + }, + + getMenuWrapperForItem: function getMenuWrapperForItem(item) { + let root = item.parentMenu; + while (root.parentMenu) + root = root.parentMenu; + + for (let wrapper of this.menus) { + if (wrapper.items === root) + return wrapper; + } + + return null; + } +}); + +var MenuManager = { + windowMap: new Map(), + + get overflowThreshold() { + let prefs = require("./preferences/service"); + return prefs.get(OVERFLOW_THRESH_PREF, OVERFLOW_THRESH_DEFAULT); + }, + + // When a new window is added start watching it for context menu shows + onTrack: function onTrack(window) { + if (!isBrowser(window)) + return; + + // Generally shouldn't happen, but just in case + if (this.windowMap.has(window)) { + console.warn("Already seen this window"); + return; + } + + let winWrapper = WindowWrapper(window); + this.windowMap.set(window, winWrapper); + }, + + onUntrack: function onUntrack(window) { + if (!isBrowser(window)) + return; + + let winWrapper = this.windowMap.get(window); + // This shouldn't happen but protect against it anyway + if (!winWrapper) + return; + winWrapper.destroy(); + + this.windowMap.delete(window); + }, + + // Creates a XUL node for an item in every window we've already populated + createItem: function createItem(item, after) { + for (let [window, winWrapper] of this.windowMap) { + let menuWrapper = winWrapper.getMenuWrapperForItem(item); + if (menuWrapper) + menuWrapper.createItem(item, after); + } + }, + + // Updates the XUL node for an item in every window we've already populated + updateItem: function updateItem(item) { + for (let [window, winWrapper] of this.windowMap) { + let menuWrapper = winWrapper.getMenuWrapperForItem(item); + if (menuWrapper) + menuWrapper.updateItem(item); + } + }, + + // Moves the XUL node for an item in every window we've ever populated to its + // new place in the hierarchy + moveItem: function moveItem(item, after) { + for (let [window, winWrapper] of this.windowMap) { + let menuWrapper = winWrapper.getMenuWrapperForItem(item); + if (menuWrapper) + menuWrapper.moveItem(item, after); + } + }, + + // Removes the XUL nodes for an item in every window we've ever populated. + removeItem: function removeItem(item) { + for (let [window, winWrapper] of this.windowMap) { + let menuWrapper = winWrapper.getMenuWrapperForItem(item); + if (menuWrapper) + menuWrapper.removeItem(item); + } + } +}; + +WindowTracker(MenuManager); diff --git a/addon-sdk/source/lib/sdk/context-menu/context.js b/addon-sdk/source/lib/sdk/context-menu/context.js new file mode 100644 index 000000000..fc5aea500 --- /dev/null +++ b/addon-sdk/source/lib/sdk/context-menu/context.js @@ -0,0 +1,147 @@ +/* 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 { Class } = require("../core/heritage"); +const { extend } = require("../util/object"); +const { MatchPattern } = require("../util/match-pattern"); +const readers = require("./readers"); + +// Context class is required to implement a single `isCurrent(target)` method +// that must return boolean value indicating weather given target matches a +// context or not. Most context implementations below will have an associated +// reader that way context implementation can setup a reader to extract necessary +// information to make decision if target is matching a context. +const Context = Class({ + isRequired: false, + isCurrent(target) { + throw Error("Context class must implement isCurrent(target) method"); + }, + get required() { + Object.defineProperty(this, "required", { + value: Object.assign(Object.create(Object.getPrototypeOf(this)), + this, + {isRequired: true}) + }); + return this.required; + } +}); +Context.required = function(...params) { + return Object.assign(new this(...params), {isRequired: true}); +}; +exports.Context = Context; + + +// Next few context implementations use an associated reader to extract info +// from the context target and story it to a private symbol associtaed with +// a context implementation. That way name collisions are avoided while required +// information is still carried along. +const isPage = Symbol("context/page?") +const PageContext = Class({ + extends: Context, + read: {[isPage]: new readers.isPage()}, + isCurrent: target => target[isPage] +}); +exports.Page = PageContext; + +const isFrame = Symbol("context/frame?"); +const FrameContext = Class({ + extends: Context, + read: {[isFrame]: new readers.isFrame()}, + isCurrent: target => target[isFrame] +}); +exports.Frame = FrameContext; + +const selection = Symbol("context/selection") +const SelectionContext = Class({ + read: {[selection]: new readers.Selection()}, + isCurrent: target => !!target[selection] +}); +exports.Selection = SelectionContext; + +const link = Symbol("context/link"); +const LinkContext = Class({ + extends: Context, + read: {[link]: new readers.LinkURL()}, + isCurrent: target => !!target[link] +}); +exports.Link = LinkContext; + +const isEditable = Symbol("context/editable?") +const EditableContext = Class({ + extends: Context, + read: {[isEditable]: new readers.isEditable()}, + isCurrent: target => target[isEditable] +}); +exports.Editable = EditableContext; + + +const mediaType = Symbol("context/mediaType") + +const ImageContext = Class({ + extends: Context, + read: {[mediaType]: new readers.MediaType()}, + isCurrent: target => target[mediaType] === "image" +}); +exports.Image = ImageContext; + + +const VideoContext = Class({ + extends: Context, + read: {[mediaType]: new readers.MediaType()}, + isCurrent: target => target[mediaType] === "video" +}); +exports.Video = VideoContext; + + +const AudioContext = Class({ + extends: Context, + read: {[mediaType]: new readers.MediaType()}, + isCurrent: target => target[mediaType] === "audio" +}); +exports.Audio = AudioContext; + +const isSelectorMatch = Symbol("context/selector/mathches?") +const SelectorContext = Class({ + extends: Context, + initialize(selector) { + this.selector = selector; + // Each instance of selector context will need to store read + // data into different field, so that case with multilpe selector + // contexts won't cause a conflicts. + this[isSelectorMatch] = Symbol(selector); + this.read = {[this[isSelectorMatch]]: new readers.SelectorMatch(selector)}; + }, + isCurrent(target) { + return target[this[isSelectorMatch]]; + } +}); +exports.Selector = SelectorContext; + +const url = Symbol("context/url"); +const URLContext = Class({ + extends: Context, + initialize(pattern) { + this.pattern = new MatchPattern(pattern); + }, + read: {[url]: new readers.PageURL()}, + isCurrent(target) { + return this.pattern.test(target[url]); + } +}); +exports.URL = URLContext; + +var PredicateContext = Class({ + extends: Context, + initialize(isMatch) { + if (typeof(isMatch) !== "function") { + throw TypeError("Predicate context mus be passed a function"); + } + + this.isMatch = isMatch + }, + isCurrent(target) { + return this.isMatch(target); + } +}); +exports.Predicate = PredicateContext; diff --git a/addon-sdk/source/lib/sdk/context-menu/core.js b/addon-sdk/source/lib/sdk/context-menu/core.js new file mode 100644 index 000000000..c64cddfe8 --- /dev/null +++ b/addon-sdk/source/lib/sdk/context-menu/core.js @@ -0,0 +1,384 @@ +/* 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 Contexts = require("./context"); +const Readers = require("./readers"); +const Component = require("../ui/component"); +const { Class } = require("../core/heritage"); +const { map, filter, object, reduce, keys, symbols, + pairs, values, each, some, isEvery, count } = require("../util/sequence"); +const { loadModule } = require("framescript/manager"); +const { Cu, Cc, Ci } = require("chrome"); +const prefs = require("sdk/preferences/service"); + +const globalMessageManager = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); +const preferencesService = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefService). + getBranch(null); + + +const readTable = Symbol("context-menu/read-table"); +const nameTable = Symbol("context-menu/name-table"); +const onContext = Symbol("context-menu/on-context"); +const isMatching = Symbol("context-menu/matching-handler?"); + +exports.onContext = onContext; +exports.readTable = readTable; +exports.nameTable = nameTable; + + +const propagateOnContext = (item, data) => + each(child => child[onContext](data), item.state.children); + +const isContextMatch = item => !item[isMatching] || item[isMatching](); + +// For whatever reason addWeakMessageListener does not seems to work as our +// instance seems to dropped even though it's alive. This is simple workaround +// to avoid dead object excetptions. +const WeakMessageListener = function(receiver, handler="receiveMessage") { + this.receiver = receiver + this.handler = handler +}; +WeakMessageListener.prototype = { + constructor: WeakMessageListener, + receiveMessage(message) { + if (Cu.isDeadWrapper(this.receiver)) { + message.target.messageManager.removeMessageListener(message.name, this); + } + else { + this.receiver[this.handler](message); + } + } +}; + +const OVERFLOW_THRESH = "extensions.addon-sdk.context-menu.overflowThreshold"; +const onMessage = Symbol("context-menu/message-listener"); +const onPreferceChange = Symbol("context-menu/preference-change"); +const ContextMenuExtension = Class({ + extends: Component, + initialize: Component, + setup() { + const messageListener = new WeakMessageListener(this, onMessage); + loadModule(globalMessageManager, "framescript/context-menu", true, "onContentFrame"); + globalMessageManager.addMessageListener("sdk/context-menu/read", messageListener); + globalMessageManager.addMessageListener("sdk/context-menu/readers?", messageListener); + + preferencesService.addObserver(OVERFLOW_THRESH, this, false); + }, + observe(_, __, name) { + if (name === OVERFLOW_THRESH) { + const overflowThreshold = prefs.get(OVERFLOW_THRESH, 10); + this[Component.patch]({overflowThreshold}); + } + }, + [onMessage]({name, data, target}) { + if (name === "sdk/context-menu/read") + this[onContext]({target, data}); + if (name === "sdk/context-menu/readers?") + target.messageManager.sendAsyncMessage("sdk/context-menu/readers", + JSON.parse(JSON.stringify(this.state.readers))); + }, + [Component.initial](options={}, children) { + const element = options.element || null; + const target = options.target || null; + const readers = Object.create(null); + const users = Object.create(null); + const registry = new WeakSet(); + const overflowThreshold = prefs.get(OVERFLOW_THRESH, 10); + + return { target, children: [], readers, users, element, + registry, overflowThreshold }; + }, + [Component.isUpdated](before, after) { + // Update only if target changed, since there is no point in re-rendering + // when children are. Also new items added won't be in sync with a latest + // context target so we should really just render before drawing context + // menu. + return before.target !== after.target; + }, + [Component.render]({element, children, overflowThreshold}) { + if (!element) return null; + + const items = children.filter(isContextMatch); + const body = items.length === 0 ? items : + items.length < overflowThreshold ? [new Separator(), + ...items] : + [{tagName: "menu", + className: "sdk-context-menu-overflow-menu", + label: "Add-ons", + accesskey: "A", + children: [{tagName: "menupopup", + children: items}]}]; + return { + element: element, + tagName: "menugroup", + style: "-moz-box-orient: vertical;", + className: "sdk-context-menu-extension", + children: body + } + }, + // Adds / remove child to it's own list. + add(item) { + this[Component.patch]({children: this.state.children.concat(item)}); + }, + remove(item) { + this[Component.patch]({ + children: this.state.children.filter(x => x !== item) + }); + }, + register(item) { + const { users, registry } = this.state; + if (registry.has(item)) return; + registry.add(item); + + // Each (ContextHandler) item has a readTable that is a + // map of keys to readers extracting them from the content. + // During the registraction we update intrnal record of unique + // readers and users per reader. Most context will have a reader + // shared across all instances there for map of users per reader + // is stored separately from the reader so that removing reader + // will occur only when no users remain. + const table = item[readTable]; + // Context readers store data in private symbols so we need to + // collect both table keys and private symbols. + const names = [...keys(table), ...symbols(table)]; + const readers = map(name => table[name], names); + // Create delta for registered readers that will be merged into + // internal readers table. + const added = filter(x => !users[x.id], readers); + const delta = object(...map(x => [x.id, x], added)); + + const update = reduce((update, reader) => { + const n = update[reader.id] || 0; + update[reader.id] = n + 1; + return update; + }, Object.assign({}, users), readers); + + // Patch current state with a changes that registered item caused. + this[Component.patch]({users: update, + readers: Object.assign(this.state.readers, delta)}); + + if (count(added)) { + globalMessageManager.broadcastAsyncMessage("sdk/context-menu/readers", + JSON.parse(JSON.stringify(delta))); + } + }, + unregister(item) { + const { users, registry } = this.state; + if (!registry.has(item)) return; + registry.delete(item); + + const table = item[readTable]; + const names = [...keys(table), ...symbols(table)]; + const readers = map(name => table[name], names); + const update = reduce((update, reader) => { + update[reader.id] = update[reader.id] - 1; + return update; + }, Object.assign({}, users), readers); + const removed = filter(id => !update[id], keys(update)); + const delta = object(...map(x => [x, null], removed)); + + this[Component.patch]({users: update, + readers: Object.assign(this.state.readers, delta)}); + + if (count(removed)) { + globalMessageManager.broadcastAsyncMessage("sdk/context-menu/readers", + JSON.parse(JSON.stringify(delta))); + } + }, + + [onContext]({data, target}) { + propagateOnContext(this, data); + const document = target.ownerDocument; + const element = document.getElementById("contentAreaContextMenu"); + + this[Component.patch]({target: data, element: element}); + } +});this, +exports.ContextMenuExtension = ContextMenuExtension; + +// Takes an item options and +const makeReadTable = ({context, read}) => { + // Result of this function is a tuple of all readers & + // name, reader id pairs. + + // Filter down to contexts that have a reader associated. + const contexts = filter(context => context.read, context); + // Merge all contexts read maps to a single hash, note that there should be + // no name collisions as context implementations expect to use private + // symbols for storing it's read data. + return Object.assign({}, ...map(({read}) => read, contexts), read); +} + +const readTarget = (nameTable, data) => + object(...map(([name, id]) => [name, data[id]], nameTable)) + +const ContextHandler = Class({ + extends: Component, + initialize: Component, + get context() { + return this.state.options.context; + }, + get read() { + return this.state.options.read; + }, + [Component.initial](options) { + return { + table: makeReadTable(options), + requiredContext: filter(context => context.isRequired, options.context), + optionalContext: filter(context => !context.isRequired, options.context) + } + }, + [isMatching]() { + const {target, requiredContext, optionalContext} = this.state; + return isEvery(context => context.isCurrent(target), requiredContext) && + (count(optionalContext) === 0 || + some(context => context.isCurrent(target), optionalContext)); + }, + setup() { + const table = makeReadTable(this.state.options); + this[readTable] = table; + this[nameTable] = [...map(symbol => [symbol, table[symbol].id], symbols(table)), + ...map(name => [name, table[name].id], keys(table))]; + + + contextMenu.register(this); + + each(child => contextMenu.remove(child), this.state.children); + contextMenu.add(this); + }, + dispose() { + contextMenu.remove(this); + + each(child => contextMenu.unregister(child), this.state.children); + contextMenu.unregister(this); + }, + // Internal `Symbol("onContext")` method is invoked when "contextmenu" event + // occurs in content process. Context handles with children delegate to each + // child and patch it's internal state to reflect new contextmenu target. + [onContext](data) { + propagateOnContext(this, data); + this[Component.patch]({target: readTarget(this[nameTable], data)}); + } +}); +const isContextHandler = item => item instanceof ContextHandler; + +exports.ContextHandler = ContextHandler; + +const Menu = Class({ + extends: ContextHandler, + [isMatching]() { + return ContextHandler.prototype[isMatching].call(this) && + this.state.children.filter(isContextHandler) + .some(isContextMatch); + }, + [Component.render]({children, options}) { + const items = children.filter(isContextMatch); + return {tagName: "menu", + className: "sdk-context-menu menu-iconic", + label: options.label, + accesskey: options.accesskey, + image: options.icon, + children: [{tagName: "menupopup", + children: items}]}; + } +}); +exports.Menu = Menu; + +const onCommand = Symbol("context-menu/item/onCommand"); +const Item = Class({ + extends: ContextHandler, + get onClick() { + return this.state.options.onClick; + }, + [Component.render]({options}) { + const {label, icon, accesskey} = options; + return {tagName: "menuitem", + className: "sdk-context-menu-item menuitem-iconic", + label, + accesskey, + image: icon, + oncommand: this}; + }, + handleEvent(event) { + if (this.onClick) + this.onClick(this.state.target); + } +}); +exports.Item = Item; + +var Separator = Class({ + extends: Component, + initialize: Component, + [Component.render]() { + return {tagName: "menuseparator", + className: "sdk-context-menu-separator"} + }, + [onContext]() { + + } +}); +exports.Separator = Separator; + +exports.Contexts = Contexts; +exports.Readers = Readers; + +const createElement = (vnode, {document}) => { + const node = vnode.namespace ? + document.createElementNS(vnode.namespace, vnode.tagName) : + document.createElement(vnode.tagName); + + node.setAttribute("data-component-path", vnode[Component.path]); + + each(([key, value]) => { + if (key === "tagName") { + return; + } + if (key === "children") { + return; + } + + if (key.startsWith("on")) { + node.addEventListener(key.substr(2), value) + return; + } + + if (typeof(value) !== "object" && + typeof(value) !== "function" && + value !== void(0) && + value !== null) + { + if (key === "className") { + node[key] = value; + } + else { + node.setAttribute(key, value); + } + return; + } + }, pairs(vnode)); + + each(child => node.appendChild(createElement(child, {document})), vnode.children); + return node; +}; + +const htmlWriter = tree => { + if (tree !== null) { + const root = tree.element; + const node = createElement(tree, {document: root.ownerDocument}); + const before = root.querySelector("[data-component-path='/']"); + if (before) { + root.replaceChild(node, before); + } else { + root.appendChild(node); + } + } +}; + + +const contextMenu = ContextMenuExtension(); +exports.contextMenu = contextMenu; +Component.mount(contextMenu, htmlWriter); diff --git a/addon-sdk/source/lib/sdk/context-menu/readers.js b/addon-sdk/source/lib/sdk/context-menu/readers.js new file mode 100644 index 000000000..5078f8f29 --- /dev/null +++ b/addon-sdk/source/lib/sdk/context-menu/readers.js @@ -0,0 +1,112 @@ +/* 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 { Class } = require("../core/heritage"); +const { extend } = require("../util/object"); +const { memoize, method, identity } = require("../lang/functional"); + +const serializeCategory = ({type}) => ({ category: `reader/${type}()` }); + +const Reader = Class({ + initialize() { + this.id = `reader/${this.type}()` + }, + toJSON() { + return serializeCategory(this); + } +}); + + +const MediaTypeReader = Class({ extends: Reader, type: "MediaType" }); +exports.MediaType = MediaTypeReader; + +const LinkURLReader = Class({ extends: Reader, type: "LinkURL" }); +exports.LinkURL = LinkURLReader; + +const SelectionReader = Class({ extends: Reader, type: "Selection" }); +exports.Selection = SelectionReader; + +const isPageReader = Class({ extends: Reader, type: "isPage" }); +exports.isPage = isPageReader; + +const isFrameReader = Class({ extends: Reader, type: "isFrame" }); +exports.isFrame = isFrameReader; + +const isEditable = Class({ extends: Reader, type: "isEditable"}); +exports.isEditable = isEditable; + + + +const ParameterizedReader = Class({ + extends: Reader, + readParameter: function(value) { + return value; + }, + toJSON: function() { + var json = serializeCategory(this); + json[this.parameter] = this[this.parameter]; + return json; + }, + initialize(...params) { + if (params.length) { + this[this.parameter] = this.readParameter(...params); + } + this.id = `reader/${this.type}(${JSON.stringify(this[this.parameter])})`; + } +}); +exports.ParameterizedReader = ParameterizedReader; + + +const QueryReader = Class({ + extends: ParameterizedReader, + type: "Query", + parameter: "path" +}); +exports.Query = QueryReader; + + +const AttributeReader = Class({ + extends: ParameterizedReader, + type: "Attribute", + parameter: "name" +}); +exports.Attribute = AttributeReader; + +const SrcURLReader = Class({ + extends: AttributeReader, + name: "src", +}); +exports.SrcURL = SrcURLReader; + +const PageURLReader = Class({ + extends: QueryReader, + path: "ownerDocument.URL", +}); +exports.PageURL = PageURLReader; + +const SelectorMatchReader = Class({ + extends: ParameterizedReader, + type: "SelectorMatch", + parameter: "selector" +}); +exports.SelectorMatch = SelectorMatchReader; + +const extractors = new WeakMap(); +extractors.id = 0; + + +var Extractor = Class({ + extends: ParameterizedReader, + type: "Extractor", + parameter: "source", + initialize: function(f) { + this[this.parameter] = String(f); + if (!extractors.has(f)) { + extractors.id = extractors.id + 1; + extractors.set(f, extractors.id); + } + + this.id = `reader/${this.type}.for(${extractors.get(f)})` + } +}); +exports.Extractor = Extractor; diff --git a/addon-sdk/source/lib/sdk/context-menu@2.js b/addon-sdk/source/lib/sdk/context-menu@2.js new file mode 100644 index 000000000..45ad804e9 --- /dev/null +++ b/addon-sdk/source/lib/sdk/context-menu@2.js @@ -0,0 +1,32 @@ +/* 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 shared = require("toolkit/require"); +const { Item, Separator, Menu, Contexts, Readers } = shared.require("sdk/context-menu/core"); +const { setupDisposable, disposeDisposable, Disposable } = require("sdk/core/disposable") +const { Class } = require("sdk/core/heritage") + +const makeDisposable = Type => Class({ + extends: Type, + implements: [Disposable], + initialize: Type.prototype.initialize, + setup(...params) { + Type.prototype.setup.call(this, ...params); + setupDisposable(this); + }, + dispose(...params) { + disposeDisposable(this); + Type.prototype.dispose.call(this, ...params); + } +}); + +exports.Separator = Separator; +exports.Contexts = Contexts; +exports.Readers = Readers; + +// Subclass Item & Menu shared classes so their items +// will be unloaded when add-on is unloaded. +exports.Item = makeDisposable(Item); +exports.Menu = makeDisposable(Menu); diff --git a/addon-sdk/source/lib/sdk/core/disposable.js b/addon-sdk/source/lib/sdk/core/disposable.js new file mode 100644 index 000000000..19f7eaa9f --- /dev/null +++ b/addon-sdk/source/lib/sdk/core/disposable.js @@ -0,0 +1,186 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Class } = require("./heritage"); +const { Observer, subscribe, unsubscribe, observe } = require("./observer"); +const { isWeak } = require("./reference"); +const SDKWeakSet = require("../lang/weak-set"); + +const method = require("../../method/core"); + +const unloadSubject = require('@loader/unload'); +const addonUnloadTopic = "sdk:loader:destroy"; + +const uninstall = method("disposable/uninstall"); +exports.uninstall = uninstall; + +const shutdown = method("disposable/shutdown"); +exports.shutdown = shutdown; + +const disable = method("disposable/disable"); +exports.disable = disable; + +const upgrade = method("disposable/upgrade"); +exports.upgrade = upgrade; + +const downgrade = method("disposable/downgrade"); +exports.downgrade = downgrade; + +const unload = method("disposable/unload"); +exports.unload = unload; + +const dispose = method("disposable/dispose"); +exports.dispose = dispose; +dispose.define(Object, object => object.dispose()); + +const setup = method("disposable/setup"); +exports.setup = setup; +setup.define(Object, (object, ...args) => object.setup(...args)); + +// DisposablesUnloadObserver is the class which subscribe the +// Observer Service to be notified when the add-on loader is +// unloading to be able to dispose all the existent disposables. +const DisposablesUnloadObserver = Class({ + implements: [Observer], + initialize: function(...args) { + // Set of the non-weak disposables registered to be disposed. + this.disposables = new Set(); + // Target of the weak disposables registered to be disposed + // (and tracked on this target using the SDK weak-set module). + this.weakDisposables = {}; + }, + subscribe(disposable) { + if (isWeak(disposable)) { + SDKWeakSet.add(this.weakDisposables, disposable); + } else { + this.disposables.add(disposable); + } + }, + unsubscribe(disposable) { + if (isWeak(disposable)) { + SDKWeakSet.remove(this.weakDisposables, disposable); + } else { + this.disposables.delete(disposable); + } + }, + tryUnloadDisposable(disposable) { + try { + if (disposable) { + unload(disposable); + } + } catch(e) { + console.error("Error unloading a", + isWeak(disposable) ? "weak disposable" : "disposable", + disposable, e); + } + }, + unloadAll() { + // Remove all the subscribed disposables. + for (let disposable of this.disposables) { + this.tryUnloadDisposable(disposable); + } + + this.disposables.clear(); + + // Remove all the subscribed weak disposables. + for (let disposable of SDKWeakSet.iterator(this.weakDisposables)) { + this.tryUnloadDisposable(disposable); + } + + SDKWeakSet.clear(this.weakDisposables); + } +}); +const disposablesUnloadObserver = new DisposablesUnloadObserver(); + +// The DisposablesUnloadObserver instance is the only object which subscribes +// the Observer Service directly, it observes add-on unload notifications in +// order to trigger `unload` on all its subscribed disposables. +observe.define(DisposablesUnloadObserver, (obj, subject, topic, data) => { + const isUnloadTopic = topic === addonUnloadTopic; + const isUnloadSubject = subject.wrappedJSObject === unloadSubject; + if (isUnloadTopic && isUnloadSubject) { + unsubscribe(disposablesUnloadObserver, addonUnloadTopic); + disposablesUnloadObserver.unloadAll(); + } +}); + +subscribe(disposablesUnloadObserver, addonUnloadTopic, false); + +// Set's up disposable instance. +const setupDisposable = disposable => { + disposablesUnloadObserver.subscribe(disposable); +}; +exports.setupDisposable = setupDisposable; + +// Tears down disposable instance. +const disposeDisposable = disposable => { + disposablesUnloadObserver.unsubscribe(disposable); +}; +exports.disposeDisposable = disposeDisposable; + +// Base type that takes care of disposing it's instances on add-on unload. +// Also makes sure to remove unload listener if it's already being disposed. +const Disposable = Class({ + initialize: function(...args) { + // First setup instance before initializing it's disposal. If instance + // fails to initialize then there is no instance to be disposed at the + // unload. + setup(this, ...args); + setupDisposable(this); + }, + destroy: function(reason) { + // Destroying disposable removes unload handler so that attempt to dispose + // won't be made at unload & delegates to dispose. + disposeDisposable(this); + unload(this, reason); + }, + setup: function() { + // Implement your initialize logic here. + }, + dispose: function() { + // Implement your cleanup logic here. + } +}); +exports.Disposable = Disposable; + +const unloaders = { + destroy: dispose, + uninstall: uninstall, + shutdown: shutdown, + disable: disable, + upgrade: upgrade, + downgrade: downgrade +}; + +const unloaded = new WeakMap(); +unload.define(Disposable, (disposable, reason) => { + if (!unloaded.get(disposable)) { + unloaded.set(disposable, true); + // Pick an unload handler associated with an unload + // reason (falling back to destroy if not found) and + // delegate unloading to it. + const unload = unloaders[reason] || unloaders.destroy; + unload(disposable); + } +}); + +// If add-on is disabled manually, it's being upgraded, downgraded +// or uninstalled `dispose` is invoked to undo any changes that +// has being done by it in this session. +disable.define(Disposable, dispose); +downgrade.define(Disposable, dispose); +upgrade.define(Disposable, dispose); +uninstall.define(Disposable, dispose); + +// If application is shut down no dispose is invoked as undo-ing +// changes made by instance is likely to just waste of resources & +// increase shutdown time. Although specefic components may choose +// to implement shutdown handler that does something better. +shutdown.define(Disposable, disposable => {}); diff --git a/addon-sdk/source/lib/sdk/core/heritage.js b/addon-sdk/source/lib/sdk/core/heritage.js new file mode 100644 index 000000000..fc87ba1f5 --- /dev/null +++ b/addon-sdk/source/lib/sdk/core/heritage.js @@ -0,0 +1,184 @@ +/* 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'; + +module.metadata = { + "stability": "unstable" +}; + +var getPrototypeOf = Object.getPrototypeOf; +var getNames = x => [...Object.getOwnPropertyNames(x), + ...Object.getOwnPropertySymbols(x)]; +var getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +var create = Object.create; +var freeze = Object.freeze; +var unbind = Function.call.bind(Function.bind, Function.call); + +// This shortcut makes sure that we do perform desired operations, even if +// associated methods have being overridden on the used object. +var owns = unbind(Object.prototype.hasOwnProperty); +var apply = unbind(Function.prototype.apply); +var slice = Array.slice || unbind(Array.prototype.slice); +var reduce = Array.reduce || unbind(Array.prototype.reduce); +var map = Array.map || unbind(Array.prototype.map); +var concat = Array.concat || unbind(Array.prototype.concat); + +// Utility function to get own properties descriptor map. +function getOwnPropertyDescriptors(object) { + return reduce(getNames(object), function(descriptor, name) { + descriptor[name] = getOwnPropertyDescriptor(object, name); + return descriptor; + }, {}); +} + +function isDataProperty(property) { + var value = property.value; + var type = typeof(property.value); + return "value" in property && + (type !== "object" || value === null) && + type !== "function"; +} + +function getDataProperties(object) { + var properties = getOwnPropertyDescriptors(object); + return getNames(properties).reduce(function(result, name) { + var property = properties[name]; + if (isDataProperty(property)) { + result[name] = { + value: property.value, + writable: true, + configurable: true, + enumerable: false + }; + } + return result; + }, {}) +} + +/** + * Takes `source` object as an argument and returns identical object + * with the difference that all own properties will be non-enumerable + */ +function obscure(source) { + var descriptor = reduce(getNames(source), function(descriptor, name) { + var property = getOwnPropertyDescriptor(source, name); + property.enumerable = false; + descriptor[name] = property; + return descriptor; + }, {}); + return create(getPrototypeOf(source), descriptor); +} +exports.obscure = obscure; + +/** + * Takes arbitrary number of source objects and returns fresh one, that + * inherits from the same prototype as a first argument and implements all + * own properties of all argument objects. If two or more argument objects + * have own properties with the same name, the property is overridden, with + * precedence from right to left, implying, that properties of the object on + * the left are overridden by a same named property of the object on the right. + */ +var mix = function(source) { + var descriptor = reduce(slice(arguments), function(descriptor, source) { + return reduce(getNames(source), function(descriptor, name) { + descriptor[name] = getOwnPropertyDescriptor(source, name); + return descriptor; + }, descriptor); + }, {}); + + return create(getPrototypeOf(source), descriptor); +}; +exports.mix = mix; + +/** + * Returns a frozen object with that inherits from the given `prototype` and + * implements all own properties of the given `properties` object. + */ +function extend(prototype, properties) { + return create(prototype, getOwnPropertyDescriptors(properties)); +} +exports.extend = extend; + +/** + * Returns a constructor function with a proper `prototype` setup. Returned + * constructor's `prototype` inherits from a given `options.extends` or + * `Class.prototype` if omitted and implements all the properties of the + * given `option`. If `options.implemens` array is passed, it's elements + * will be mixed into prototype as well. Also, `options.extends` can be + * a function or a prototype. If function than it's prototype is used as + * an ancestor of the prototype, if it's an object that it's used directly. + * Also `options.implements` may contain functions or objects, in case of + * functions their prototypes are used for mixing. + */ +var Class = new function() { + function prototypeOf(input) { + return typeof(input) === 'function' ? input.prototype : input; + } + var none = freeze([]); + + return function Class(options) { + // Create descriptor with normalized `options.extends` and + // `options.implements`. + var descriptor = { + // Normalize extends property of `options.extends` to a prototype object + // in case it's constructor. If property is missing that fallback to + // `Type.prototype`. + extends: owns(options, 'extends') ? + prototypeOf(options.extends) : Class.prototype, + // Normalize `options.implements` to make sure that it's array of + // prototype objects instead of constructor functions. + implements: owns(options, 'implements') ? + freeze(map(options.implements, prototypeOf)) : none + }; + + // Create array of property descriptors who's properties will be defined + // on the resulting prototype. Note: Using reflection `concat` instead of + // method as it may be overridden. + var descriptors = concat(descriptor.implements, options, descriptor, { + constructor: constructor + }); + + // Note: we use reflection `apply` in the constructor instead of method + // call since later may be overridden. + function constructor() { + var instance = create(prototype, attributes); + if (initialize) apply(initialize, instance, arguments); + return instance; + } + // Create `prototype` that inherits from given ancestor passed as + // `options.extends`, falling back to `Type.prototype`, implementing all + // properties of given `options.implements` and `options` itself. + var prototype = extend(descriptor.extends, mix.apply(mix, descriptors)); + var initialize = prototype.initialize; + + // Combine ancestor attributes with prototype's attributes so that + // ancestors attributes also become initializeable. + var attributes = mix(descriptor.extends.constructor.attributes || {}, + getDataProperties(prototype)); + + constructor.attributes = attributes; + Object.defineProperty(constructor, 'prototype', { + configurable: false, + writable: false, + value: prototype + }); + return constructor; + }; +} +Class.prototype = extend(null, obscure({ + constructor: function constructor() { + this.initialize.apply(this, arguments); + return this; + }, + initialize: function initialize() { + // Do your initialization logic here + }, + // Copy useful properties from `Object.prototype`. + toString: Object.prototype.toString, + toLocaleString: Object.prototype.toLocaleString, + toSource: Object.prototype.toSource, + valueOf: Object.prototype.valueOf, + isPrototypeOf: Object.prototype.isPrototypeOf +})); +exports.Class = freeze(Class); diff --git a/addon-sdk/source/lib/sdk/core/namespace.js b/addon-sdk/source/lib/sdk/core/namespace.js new file mode 100644 index 000000000..3ceb73b72 --- /dev/null +++ b/addon-sdk/source/lib/sdk/core/namespace.js @@ -0,0 +1,43 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const create = Object.create; +const prototypeOf = Object.getPrototypeOf; + +/** + * Returns a new namespace, function that may can be used to access an + * namespaced object of the argument argument. Namespaced object are associated + * with owner objects via weak references. Namespaced objects inherit from the + * owners ancestor namespaced object. If owner's ancestor is `null` then + * namespaced object inherits from given `prototype`. Namespaces can be used + * to define internal APIs that can be shared via enclosing `namespace` + * function. + * @examples + * const internals = ns(); + * internals(object).secret = secret; + */ +function ns() { + const map = new WeakMap(); + return function namespace(target) { + if (!target) // If `target` is not an object return `target` itself. + return target; + // If target has no namespaced object yet, create one that inherits from + // the target prototype's namespaced object. + if (!map.has(target)) + map.set(target, create(namespace(prototypeOf(target) || null))); + + return map.get(target); + }; +}; + +// `Namespace` is a e4x function in the scope, so we export the function also as +// `ns` as alias to avoid clashing. +exports.ns = ns; +exports.Namespace = ns; diff --git a/addon-sdk/source/lib/sdk/core/observer.js b/addon-sdk/source/lib/sdk/core/observer.js new file mode 100644 index 000000000..7e11bf8f9 --- /dev/null +++ b/addon-sdk/source/lib/sdk/core/observer.js @@ -0,0 +1,89 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + + +const { Cc, Ci, Cr, Cu } = require("chrome"); +const { Class } = require("./heritage"); +const { isWeak } = require("./reference"); +const method = require("../../method/core"); + +const observerService = Cc['@mozilla.org/observer-service;1']. + getService(Ci.nsIObserverService); + +const { ShimWaiver } = Cu.import("resource://gre/modules/ShimWaiver.jsm"); +const addObserver = ShimWaiver.getProperty(observerService, "addObserver"); +const removeObserver = ShimWaiver.getProperty(observerService, "removeObserver"); + +// This is a method that will be invoked when notification observer +// subscribed to occurs. +const observe = method("observer/observe"); +exports.observe = observe; + +// Method to subscribe to the observer notification. +const subscribe = method("observe/subscribe"); +exports.subscribe = subscribe; + + +// Method to unsubscribe from the observer notifications. +const unsubscribe = method("observer/unsubscribe"); +exports.unsubscribe = unsubscribe; + + +// This is wrapper class that takes a `delegate` and produces +// instance of `nsIObserver` which will delegate to a given +// object when observer notification occurs. +const ObserverDelegee = Class({ + initialize: function(delegate) { + this.delegate = delegate; + }, + QueryInterface: function(iid) { + if (!iid.equals(Ci.nsIObserver) && + !iid.equals(Ci.nsISupportsWeakReference) && + !iid.equals(Ci.nsISupports)) + throw Cr.NS_ERROR_NO_INTERFACE; + + return this; + }, + observe: function(subject, topic, data) { + observe(this.delegate, subject, topic, data); + } +}); + + +// Class that can be either mixed in or inherited from in +// order to subscribe / unsubscribe for observer notifications. +const Observer = Class({}); +exports.Observer = Observer; + +// Weak maps that associates instance of `ObserverDelegee` with +// an actual observer. It ensures that `ObserverDelegee` instance +// won't be GC-ed until given `observer` is. +const subscribers = new WeakMap(); + +// Implementation of `subscribe` for `Observer` type just registers +// observer for an observer service. If `isWeak(observer)` is `true` +// observer service won't hold strong reference to a given `observer`. +subscribe.define(Observer, (observer, topic) => { + if (!subscribers.has(observer)) { + const delegee = new ObserverDelegee(observer); + subscribers.set(observer, delegee); + addObserver(delegee, topic, isWeak(observer)); + } +}); + +// Unsubscribes `observer` from observer notifications for the +// given `topic`. +unsubscribe.define(Observer, (observer, topic) => { + const delegee = subscribers.get(observer); + if (delegee) { + subscribers.delete(observer); + removeObserver(delegee, topic); + } +}); diff --git a/addon-sdk/source/lib/sdk/core/promise.js b/addon-sdk/source/lib/sdk/core/promise.js new file mode 100644 index 000000000..f4bd7b0f5 --- /dev/null +++ b/addon-sdk/source/lib/sdk/core/promise.js @@ -0,0 +1,118 @@ +/* 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'; + +/* + * Uses `Promise.jsm` as a core implementation, with additional sugar + * from previous implementation, with inspiration from `Q` and `when` + * + * https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/Promise.jsm + * https://github.com/cujojs/when + * https://github.com/kriskowal/q + */ +const PROMISE_URI = 'resource://gre/modules/Promise.jsm'; + +getEnvironment.call(this, function ({ require, exports, module, Cu }) { + +const Promise = Cu.import(PROMISE_URI, {}).Promise; +const { Debugging, defer, resolve, all, reject, race } = Promise; + +module.metadata = { + 'stability': 'unstable' +}; + +var promised = (function() { + // Note: Define shortcuts and utility functions here in order to avoid + // slower property accesses and unnecessary closure creations on each + // call of this popular function. + + var call = Function.call; + var concat = Array.prototype.concat; + + // Utility function that does following: + // execute([ f, self, args...]) => f.apply(self, args) + function execute (args) { + return call.apply(call, args); + } + + // Utility function that takes promise of `a` array and maybe promise `b` + // as arguments and returns promise for `a.concat(b)`. + function promisedConcat(promises, unknown) { + return promises.then(function (values) { + return resolve(unknown) + .then(value => values.concat([value])); + }); + } + + return function promised(f, prototype) { + /** + Returns a wrapped `f`, which when called returns a promise that resolves to + `f(...)` passing all the given arguments to it, which by the way may be + promises. Optionally second `prototype` argument may be provided to be used + a prototype for a returned promise. + + ## Example + + var promise = promised(Array)(1, promise(2), promise(3)) + promise.then(console.log) // => [ 1, 2, 3 ] + **/ + + return function promised(...args) { + // create array of [ f, this, args... ] + return [f, this, ...args]. + // reduce it via `promisedConcat` to get promised array of fulfillments + reduce(promisedConcat, resolve([], prototype)). + // finally map that to promise of `f.apply(this, args...)` + then(execute); + }; + }; +})(); + +exports.promised = promised; +exports.all = all; +exports.defer = defer; +exports.resolve = resolve; +exports.reject = reject; +exports.race = race; +exports.Promise = Promise; +exports.Debugging = Debugging; +}); + +function getEnvironment (callback) { + let Cu, _exports, _module, _require; + + // CommonJS / SDK + if (typeof(require) === 'function') { + Cu = require('chrome').Cu; + _exports = exports; + _module = module; + _require = require; + } + // JSM + else if (String(this).indexOf('BackstagePass') >= 0) { + Cu = this['Components'].utils; + _exports = this.Promise = {}; + _module = { uri: __URI__, id: 'promise/core' }; + _require = uri => { + let imports = {}; + Cu.import(uri, imports); + return imports; + }; + this.EXPORTED_SYMBOLS = ['Promise']; + // mozIJSSubScriptLoader.loadSubscript + } else if (~String(this).indexOf('Sandbox')) { + Cu = this['Components'].utils; + _exports = this; + _module = { id: 'promise/core' }; + _require = uri => {}; + } + + callback({ + Cu: Cu, + exports: _exports, + module: _module, + require: _require + }); +} + diff --git a/addon-sdk/source/lib/sdk/core/reference.js b/addon-sdk/source/lib/sdk/core/reference.js new file mode 100644 index 000000000..04549cd0f --- /dev/null +++ b/addon-sdk/source/lib/sdk/core/reference.js @@ -0,0 +1,29 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const method = require("../../method/core"); +const { Class } = require("./heritage"); + +// Object that inherit or mix WeakRefence inn will register +// weak observes for system notifications. +const WeakReference = Class({}); +exports.WeakReference = WeakReference; + + +// If `isWeak(object)` is `true` observer installed +// for such `object` will be weak, meaning that it will +// be GC-ed if nothing else but observer is observing it. +// By default everything except `WeakReference` will return +// `false`. +const isWeak = method("reference/weak?"); +exports.isWeak = isWeak; + +isWeak.define(Object, _ => false); +isWeak.define(WeakReference, _ => true); diff --git a/addon-sdk/source/lib/sdk/deprecated/api-utils.js b/addon-sdk/source/lib/sdk/deprecated/api-utils.js new file mode 100644 index 000000000..856fc50cb --- /dev/null +++ b/addon-sdk/source/lib/sdk/deprecated/api-utils.js @@ -0,0 +1,197 @@ +/* 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"; + +module.metadata = { + "stability": "deprecated" +}; + +const { merge } = require("../util/object"); +const { union } = require("../util/array"); +const { isNil, isRegExp } = require("../lang/type"); + +// The possible return values of getTypeOf. +const VALID_TYPES = [ + "array", + "boolean", + "function", + "null", + "number", + "object", + "string", + "undefined", + "regexp" +]; + +const { isArray } = Array; + +/** + * Returns a validated options dictionary given some requirements. If any of + * the requirements are not met, an exception is thrown. + * + * @param options + * An object, the options dictionary to validate. It's not modified. + * If it's null or otherwise falsey, an empty object is assumed. + * @param requirements + * An object whose keys are the expected keys in options. Any key in + * options that is not present in requirements is ignored. Each value + * in requirements is itself an object describing the requirements of + * its key. There are four optional keys in this object: + * map: A function that's passed the value of the key in options. + * map's return value is taken as the key's value in the final + * validated options, is, and ok. If map throws an exception + * it's caught and discarded, and the key's value is its value in + * options. + * is: An array containing any number of the typeof type names. If + * the key's value is none of these types, it fails validation. + * Arrays, null and regexps are identified by the special type names + * "array", "null", "regexp"; "object" will not match either. No type + * coercion is done. + * ok: A function that's passed the key's value. If it returns + * false, the value fails validation. + * msg: If the key's value fails validation, an exception is thrown. + * This string will be used as its message. If undefined, a + * generic message is used, unless is is defined, in which case + * the message will state that the value needs to be one of the + * given types. + * @return An object whose keys are those keys in requirements that are also in + * options and whose values are the corresponding return values of map + * or the corresponding values in options. Note that any keys not + * shared by both requirements and options are not in the returned + * object. + */ +exports.validateOptions = function validateOptions(options, requirements) { + options = options || {}; + let validatedOptions = {}; + + for (let key in requirements) { + let isOptional = false; + let mapThrew = false; + let req = requirements[key]; + let [optsVal, keyInOpts] = (key in options) ? + [options[key], true] : + [undefined, false]; + if (req.map) { + try { + optsVal = req.map(optsVal); + } + catch (err) { + if (err instanceof RequirementError) + throw err; + + mapThrew = true; + } + } + if (req.is) { + let types = req.is; + + if (!isArray(types) && isArray(types.is)) + types = types.is; + + if (isArray(types)) { + isOptional = ['undefined', 'null'].every(v => ~types.indexOf(v)); + + // Sanity check the caller's type names. + types.forEach(function (typ) { + if (VALID_TYPES.indexOf(typ) < 0) { + let msg = 'Internal error: invalid requirement type "' + typ + '".'; + throw new Error(msg); + } + }); + if (types.indexOf(getTypeOf(optsVal)) < 0) + throw new RequirementError(key, req); + } + } + + if (req.ok && ((!isOptional || !isNil(optsVal)) && !req.ok(optsVal))) + throw new RequirementError(key, req); + + if (keyInOpts || (req.map && !mapThrew && optsVal !== undefined)) + validatedOptions[key] = optsVal; + } + + return validatedOptions; +}; + +exports.addIterator = function addIterator(obj, keysValsGenerator) { + obj.__iterator__ = function(keysOnly, keysVals) { + let keysValsIterator = keysValsGenerator.call(this); + + // "for (.. in ..)" gets only keys, "for each (.. in ..)" gets values, + // and "for (.. in Iterator(..))" gets [key, value] pairs. + let index = keysOnly ? 0 : 1; + while (true) + yield keysVals ? keysValsIterator.next() : keysValsIterator.next()[index]; + }; +}; + +// Similar to typeof, except arrays, null and regexps are identified by "array" and +// "null" and "regexp", not "object". +var getTypeOf = exports.getTypeOf = function getTypeOf(val) { + let typ = typeof(val); + if (typ === "object") { + if (!val) + return "null"; + if (isArray(val)) + return "array"; + if (isRegExp(val)) + return "regexp"; + } + return typ; +} + +function RequirementError(key, requirement) { + Error.call(this); + + this.name = "RequirementError"; + + let msg = requirement.msg; + if (!msg) { + msg = 'The option "' + key + '" '; + msg += requirement.is ? + "must be one of the following types: " + requirement.is.join(", ") : + "is invalid."; + } + + this.message = msg; +} +RequirementError.prototype = Object.create(Error.prototype); + +var string = { is: ['string', 'undefined', 'null'] }; +exports.string = string; + +var number = { is: ['number', 'undefined', 'null'] }; +exports.number = number; + +var boolean = { is: ['boolean', 'undefined', 'null'] }; +exports.boolean = boolean; + +var object = { is: ['object', 'undefined', 'null'] }; +exports.object = object; + +var array = { is: ['array', 'undefined', 'null'] }; +exports.array = array; + +var isTruthyType = type => !(type === 'undefined' || type === 'null'); +var findTypes = v => { while (!isArray(v) && v.is) v = v.is; return v }; + +function required(req) { + let types = (findTypes(req) || VALID_TYPES).filter(isTruthyType); + + return merge({}, req, {is: types}); +} +exports.required = required; + +function optional(req) { + req = merge({is: []}, req); + req.is = findTypes(req).filter(isTruthyType).concat('undefined', 'null'); + + return req; +} +exports.optional = optional; + +function either(...types) { + return union.apply(null, types.map(findTypes)); +} +exports.either = either; diff --git a/addon-sdk/source/lib/sdk/deprecated/events/assembler.js b/addon-sdk/source/lib/sdk/deprecated/events/assembler.js new file mode 100644 index 000000000..bb297c24f --- /dev/null +++ b/addon-sdk/source/lib/sdk/deprecated/events/assembler.js @@ -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"; + +const { Class } = require("../../core/heritage"); +const { removeListener, on } = require("../../dom/events"); + +/** + * Event targets + * can be added / removed by calling `observe / ignore` methods. Composer should + * provide array of event types it wishes to handle as property + * `supportedEventsTypes` and function for handling all those events as + * `handleEvent` property. + */ +exports.DOMEventAssembler = Class({ + /** + * Function that is supposed to handle all the supported events (that are + * present in the `supportedEventsTypes`) from all the observed + * `eventTargets`. + * @param {Event} event + * Event being dispatched. + */ + handleEvent() { + throw new TypeError("Instance of DOMEventAssembler must implement `handleEvent` method"); + }, + /** + * Array of supported event names. + * @type {String[]} + */ + get supportedEventsTypes() { + throw new TypeError("Instance of DOMEventAssembler must implement `handleEvent` field"); + }, + /** + * Adds `eventTarget` to the list of observed `eventTarget`s. Listeners for + * supported events will be registered on the given `eventTarget`. + * @param {EventTarget} eventTarget + */ + observe: function observe(eventTarget) { + this.supportedEventsTypes.forEach(function(eventType) { + on(eventTarget, eventType, this); + }, this); + }, + /** + * Removes `eventTarget` from the list of observed `eventTarget`s. Listeners + * for all supported events will be unregistered from the given `eventTarget`. + * @param {EventTarget} eventTarget + */ + ignore: function ignore(eventTarget) { + this.supportedEventsTypes.forEach(function(eventType) { + removeListener(eventTarget, eventType, this); + }, this); + } +}); diff --git a/addon-sdk/source/lib/sdk/deprecated/sync-worker.js b/addon-sdk/source/lib/sdk/deprecated/sync-worker.js new file mode 100644 index 000000000..71cadac36 --- /dev/null +++ b/addon-sdk/source/lib/sdk/deprecated/sync-worker.js @@ -0,0 +1,288 @@ +/* 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/. */ + +/** + * + * `deprecated/sync-worker` was previously `content/worker`, that was + * incompatible with e10s. we are in the process of switching to the new + * asynchronous `Worker`, which behaves slightly differently in some edge + * cases, so we are keeping this one around for a short period. + * try to switch to the new one as soon as possible.. + * + */ + +"use strict"; + +module.metadata = { + "stability": "unstable" +}; + +const { Class } = require('../core/heritage'); +const { EventTarget } = require('../event/target'); +const { on, off, emit, setListeners } = require('../event/core'); +const { + attach, detach, destroy +} = require('../content/utils'); +const { method } = require('../lang/functional'); +const { Ci, Cu, Cc } = require('chrome'); +const unload = require('../system/unload'); +const events = require('../system/events'); +const { getInnerId } = require("../window/utils"); +const { WorkerSandbox } = require('../content/sandbox'); +const { isPrivate } = require('../private-browsing/utils'); + +// A weak map of workers to hold private attributes that +// should not be exposed +const workers = new WeakMap(); + +var modelFor = (worker) => workers.get(worker); + +const ERR_DESTROYED = + "Couldn't find the worker to receive this message. " + + "The script may not be initialized yet, or may already have been unloaded."; + +const ERR_FROZEN = "The page is currently hidden and can no longer be used " + + "until it is visible again."; + +/** + * Message-passing facility for communication between code running + * in the content and add-on process. + * @see https://developer.mozilla.org/en-US/Add-ons/SDK/Low-Level_APIs/content_worker + */ +const Worker = Class({ + implements: [EventTarget], + initialize: function WorkerConstructor (options) { + // Save model in weak map to not expose properties + let model = createModel(); + workers.set(this, model); + + options = options || {}; + + if ('contentScriptFile' in options) + this.contentScriptFile = options.contentScriptFile; + if ('contentScriptOptions' in options) + this.contentScriptOptions = options.contentScriptOptions; + if ('contentScript' in options) + this.contentScript = options.contentScript; + if ('injectInDocument' in options) + this.injectInDocument = !!options.injectInDocument; + + setListeners(this, options); + + unload.ensure(this, "destroy"); + + // Ensure that worker.port is initialized for contentWorker to be able + // to send events during worker initialization. + this.port = createPort(this); + + model.documentUnload = documentUnload.bind(this); + model.pageShow = pageShow.bind(this); + model.pageHide = pageHide.bind(this); + + if ('window' in options) + attach(this, options.window); + }, + + /** + * Sends a message to the worker's global scope. Method takes single + * argument, which represents data to be sent to the worker. The data may + * be any primitive type value or `JSON`. Call of this method asynchronously + * emits `message` event with data value in the global scope of this + * worker. + * + * `message` event listeners can be set either by calling + * `self.on` with a first argument string `"message"` or by + * implementing `onMessage` function in the global scope of this worker. + * @param {Number|String|JSON} data + */ + postMessage: function (...data) { + let model = modelFor(this); + let args = ['message'].concat(data); + if (!model.inited) { + model.earlyEvents.push(args); + return; + } + processMessage.apply(null, [this].concat(args)); + }, + + get url () { + let model = modelFor(this); + // model.window will be null after detach + return model.window ? model.window.document.location.href : null; + }, + + get contentURL () { + let model = modelFor(this); + return model.window ? model.window.document.URL : null; + }, + + // Implemented to provide some of the previous features of exposing sandbox + // so that Worker can be extended + getSandbox: function () { + return modelFor(this).contentWorker; + }, + + toString: function () { return '[object Worker]'; }, + attach: method(attach), + detach: method(detach), + destroy: method(destroy) +}); +exports.Worker = Worker; + +attach.define(Worker, function (worker, window) { + let model = modelFor(worker); + model.window = window; + // Track document unload to destroy this worker. + // We can't watch for unload event on page's window object as it + // prevents bfcache from working: + // https://developer.mozilla.org/En/Working_with_BFCache + model.windowID = getInnerId(model.window); + events.on("inner-window-destroyed", model.documentUnload); + + // will set model.contentWorker pointing to the private API: + model.contentWorker = WorkerSandbox(worker, model.window); + + // Listen to pagehide event in order to freeze the content script + // while the document is frozen in bfcache: + model.window.addEventListener("pageshow", model.pageShow, true); + model.window.addEventListener("pagehide", model.pageHide, true); + + // Mainly enable worker.port.emit to send event to the content worker + model.inited = true; + model.frozen = false; + + // Fire off `attach` event + emit(worker, 'attach', window); + + // Process all events and messages that were fired before the + // worker was initialized. + model.earlyEvents.forEach(args => processMessage.apply(null, [worker].concat(args))); +}); + +/** + * Remove all internal references to the attached document + * Tells _port to unload itself and removes all the references from itself. + */ +detach.define(Worker, function (worker, reason) { + let model = modelFor(worker); + + // maybe unloaded before content side is created + if (model.contentWorker) { + model.contentWorker.destroy(reason); + } + + model.contentWorker = null; + if (model.window) { + model.window.removeEventListener("pageshow", model.pageShow, true); + model.window.removeEventListener("pagehide", model.pageHide, true); + } + model.window = null; + // This method may be called multiple times, + // avoid dispatching `detach` event more than once + if (model.windowID) { + model.windowID = null; + events.off("inner-window-destroyed", model.documentUnload); + model.earlyEvents.length = 0; + emit(worker, 'detach'); + } + model.inited = false; +}); + +isPrivate.define(Worker, ({ tab }) => isPrivate(tab)); + +/** + * Tells content worker to unload itself and + * removes all the references from itself. + */ +destroy.define(Worker, function (worker, reason) { + detach(worker, reason); + modelFor(worker).inited = true; + // Specifying no type or listener removes all listeners + // from target + off(worker); + off(worker.port); +}); + +/** + * Events fired by workers + */ +function documentUnload ({ subject, data }) { + let model = modelFor(this); + let innerWinID = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + if (innerWinID != model.windowID) return false; + detach(this); + return true; +} + +function pageShow () { + let model = modelFor(this); + model.contentWorker.emitSync('pageshow'); + emit(this, 'pageshow'); + model.frozen = false; +} + +function pageHide () { + let model = modelFor(this); + model.contentWorker.emitSync('pagehide'); + emit(this, 'pagehide'); + model.frozen = true; +} + +/** + * Fired from postMessage and emitEventToContent, or from the earlyMessage + * queue when fired before the content is loaded. Sends arguments to + * contentWorker if able + */ + +function processMessage (worker, ...args) { + let model = modelFor(worker) || {}; + if (!model.contentWorker) + throw new Error(ERR_DESTROYED); + if (model.frozen) + throw new Error(ERR_FROZEN); + model.contentWorker.emit.apply(null, args); +} + +function createModel () { + return { + // List of messages fired before worker is initialized + earlyEvents: [], + // Is worker connected to the content worker sandbox ? + inited: false, + // Is worker being frozen? i.e related document is frozen in bfcache. + // Content script should not be reachable if frozen. + frozen: true, + /** + * Reference to the content side of the worker. + * @type {WorkerGlobalScope} + */ + contentWorker: null, + /** + * Reference to the window that is accessible from + * the content scripts. + * @type {Object} + */ + window: null + }; +} + +function createPort (worker) { + let port = EventTarget(); + port.emit = emitEventToContent.bind(null, worker); + return port; +} + +/** + * Emit a custom event to the content script, + * i.e. emit this event on `self.port` + */ +function emitEventToContent (worker, ...eventArgs) { + let model = modelFor(worker); + let args = ['event'].concat(eventArgs); + if (!model.inited) { + model.earlyEvents.push(args); + return; + } + processMessage.apply(null, [worker].concat(args)); +} diff --git a/addon-sdk/source/lib/sdk/deprecated/unit-test-finder.js b/addon-sdk/source/lib/sdk/deprecated/unit-test-finder.js new file mode 100644 index 000000000..e38629f45 --- /dev/null +++ b/addon-sdk/source/lib/sdk/deprecated/unit-test-finder.js @@ -0,0 +1,199 @@ +/* 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"; + +module.metadata = { + "stability": "deprecated" +}; + +const file = require("../io/file"); +const { Loader } = require("../test/loader"); + +const { isNative } = require('@loader/options'); + +const cuddlefish = isNative ? require("toolkit/loader") : require("../loader/cuddlefish"); + +const { defer, resolve } = require("../core/promise"); +const { getAddon } = require("../addon/installer"); +const { id } = require("sdk/self"); +const { newURI } = require('sdk/url/utils'); +const { getZipReader } = require("../zip/utils"); + +const { Cc, Ci, Cu } = require("chrome"); +const { AddonManager } = Cu.import("resource://gre/modules/AddonManager.jsm", {}); +var ios = Cc['@mozilla.org/network/io-service;1'] + .getService(Ci.nsIIOService); + +const CFX_TEST_REGEX = /(([^\/]+\/)(?:lib\/)?)?(tests?\/test-[^\.\/]+)\.js$/; +const JPM_TEST_REGEX = /^()(tests?\/test-[^\.\/]+)\.js$/; + +const { mapcat, map, filter, fromEnumerator } = require("sdk/util/sequence"); + +const toFile = x => x.QueryInterface(Ci.nsIFile); +const isTestFile = ({leafName}) => leafName.substr(0, 5) == "test-" && leafName.substr(-3, 3) == ".js"; +const getFileURI = x => ios.newFileURI(x).spec; + +const getDirectoryEntries = file => map(toFile, fromEnumerator(_ => file.directoryEntries)); +const getTestFiles = directory => filter(isTestFile, getDirectoryEntries(directory)); +const getTestURIs = directory => map(getFileURI, getTestFiles(directory)); + +const isDirectory = x => x.isDirectory(); +const getTestEntries = directory => mapcat(entry => + /^tests?$/.test(entry.leafName) ? getTestURIs(entry) : getTestEntries(entry), + filter(isDirectory, getDirectoryEntries(directory))); + +const removeDups = (array) => array.reduce((result, value) => { + if (value != result[result.length - 1]) { + result.push(value); + } + return result; +}, []); + +const getSuites = function getSuites({ id, filter }) { + const TEST_REGEX = isNative ? JPM_TEST_REGEX : CFX_TEST_REGEX; + + return getAddon(id).then(addon => { + let fileURI = addon.getResourceURI("tests/"); + let isPacked = fileURI.scheme == "jar"; + let xpiURI = addon.getResourceURI(); + let file = xpiURI.QueryInterface(Ci.nsIFileURL).file; + let suites = []; + let addEntry = (entry) => { + if (filter(entry) && TEST_REGEX.test(entry)) { + let suite = (isNative ? "./" : "") + (RegExp.$2 || "") + RegExp.$3; + suites.push(suite); + } + } + + if (isPacked) { + return getZipReader(file).then(zip => { + let entries = zip.findEntries(null); + while (entries.hasMore()) { + let entry = entries.getNext(); + addEntry(entry); + } + zip.close(); + + // sort and remove dups + suites = removeDups(suites.sort()); + return suites; + }) + } + else { + let tests = [...getTestEntries(file)]; + let rootURI = addon.getResourceURI("/"); + tests.forEach((entry) => { + addEntry(entry.replace(rootURI.spec, "")); + }); + } + + // sort and remove dups + suites = removeDups(suites.sort()); + return suites; + }); +} +exports.getSuites = getSuites; + +const makeFilters = function makeFilters(options) { + options = options || {}; + + // A filter string is {fileNameRegex}[:{testNameRegex}] - ie, a colon + // optionally separates a regex for the test fileName from a regex for the + // testName. + if (options.filter) { + let colonPos = options.filter.indexOf(':'); + let filterFileRegex, filterNameRegex; + + if (colonPos === -1) { + filterFileRegex = new RegExp(options.filter); + filterNameRegex = { test: () => true } + } + else { + filterFileRegex = new RegExp(options.filter.substr(0, colonPos)); + filterNameRegex = new RegExp(options.filter.substr(colonPos + 1)); + } + + return { + fileFilter: (name) => filterFileRegex.test(name), + testFilter: (name) => filterNameRegex.test(name) + } + } + + return { + fileFilter: () => true, + testFilter: () => true + }; +} +exports.makeFilters = makeFilters; + +var loader = Loader(module); +const NOT_TESTS = ['setup', 'teardown']; + +var TestFinder = exports.TestFinder = function TestFinder(options) { + this.filter = options.filter; + this.testInProcess = options.testInProcess === false ? false : true; + this.testOutOfProcess = options.testOutOfProcess === true ? true : false; +}; + +TestFinder.prototype = { + findTests: function findTests() { + let { fileFilter, testFilter } = makeFilters({ filter: this.filter }); + + return getSuites({ id: id, filter: fileFilter }).then(suites => { + let testsRemaining = []; + + let getNextTest = () => { + if (testsRemaining.length) { + return testsRemaining.shift(); + } + + if (!suites.length) { + return null; + } + + let suite = suites.shift(); + + // Load each test file as a main module in its own loader instance + // `suite` is defined by cuddlefish/manifest.py:ManifestBuilder.build + let suiteModule; + + try { + suiteModule = cuddlefish.main(loader, suite); + } + catch (e) { + if (/Unsupported Application/i.test(e.message)) { + // If `Unsupported Application` error thrown during test, + // skip the test suite + suiteModule = { + 'test suite skipped': assert => assert.pass(e.message) + }; + } + else { + console.exception(e); + throw e; + } + } + + if (this.testInProcess) { + for (let name of Object.keys(suiteModule).sort()) { + if (NOT_TESTS.indexOf(name) === -1 && testFilter(name)) { + testsRemaining.push({ + setup: suiteModule.setup, + teardown: suiteModule.teardown, + testFunction: suiteModule[name], + name: suite + "." + name + }); + } + } + } + + return getNextTest(); + }; + + return { + getNext: () => resolve(getNextTest()) + }; + }); + } +}; diff --git a/addon-sdk/source/lib/sdk/deprecated/unit-test.js b/addon-sdk/source/lib/sdk/deprecated/unit-test.js new file mode 100644 index 000000000..32bba8f6b --- /dev/null +++ b/addon-sdk/source/lib/sdk/deprecated/unit-test.js @@ -0,0 +1,584 @@ +/* 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"; + +module.metadata = { + "stability": "deprecated" +}; + +const timer = require("../timers"); +const cfxArgs = require("../test/options"); +const { getTabs, closeTab, getURI, getTabId, getSelectedTab } = require("../tabs/utils"); +const { windows, isBrowser, getMostRecentBrowserWindow } = require("../window/utils"); +const { defer, all, Debugging: PromiseDebugging, resolve } = require("../core/promise"); +const { getInnerId } = require("../window/utils"); +const { cleanUI } = require("../test/utils"); + +const findAndRunTests = function findAndRunTests(options) { + var TestFinder = require("./unit-test-finder").TestFinder; + var finder = new TestFinder({ + filter: options.filter, + testInProcess: options.testInProcess, + testOutOfProcess: options.testOutOfProcess + }); + var runner = new TestRunner({fs: options.fs}); + finder.findTests().then(tests => { + runner.startMany({ + tests: tests, + stopOnError: options.stopOnError, + onDone: options.onDone + }); + }); +}; +exports.findAndRunTests = findAndRunTests; + +var runnerWindows = new WeakMap(); +var runnerTabs = new WeakMap(); + +const TestRunner = function TestRunner(options) { + options = options || {}; + + // remember the id's for the open window and tab + let window = getMostRecentBrowserWindow(); + runnerWindows.set(this, getInnerId(window)); + runnerTabs.set(this, getTabId(getSelectedTab(window))); + + this.fs = options.fs; + this.console = options.console || console; + this.passed = 0; + this.failed = 0; + this.testRunSummary = []; + this.expectFailNesting = 0; + this.done = TestRunner.prototype.done.bind(this); +}; + +TestRunner.prototype = { + toString: function toString() { + return "[object TestRunner]"; + }, + + DEFAULT_PAUSE_TIMEOUT: (cfxArgs.parseable ? 300000 : 15000), //Five minutes (5*60*1000ms) + PAUSE_DELAY: 500, + + _logTestFailed: function _logTestFailed(why) { + if (!(why in this.test.errors)) + this.test.errors[why] = 0; + this.test.errors[why]++; + }, + + _uncaughtErrorObserver: function({message, date, fileName, stack, lineNumber}) { + this.fail("There was an uncaught Promise rejection: " + message + " @ " + + fileName + ":" + lineNumber + "\n" + stack); + }, + + pass: function pass(message) { + if(!this.expectFailure) { + if ("testMessage" in this.console) + this.console.testMessage(true, true, this.test.name, message); + else + this.console.info("pass:", message); + this.passed++; + this.test.passed++; + this.test.last = message; + } + else { + this.expectFailure = false; + this._logTestFailed("failure"); + if ("testMessage" in this.console) { + this.console.testMessage(true, false, this.test.name, message); + } + else { + this.console.error("fail:", 'Failure Expected: ' + message) + this.console.trace(); + } + this.failed++; + this.test.failed++; + } + }, + + fail: function fail(message) { + if(!this.expectFailure) { + this._logTestFailed("failure"); + if ("testMessage" in this.console) { + this.console.testMessage(false, false, this.test.name, message); + } + else { + this.console.error("fail:", message) + this.console.trace(); + } + this.failed++; + this.test.failed++; + } + else { + this.expectFailure = false; + if ("testMessage" in this.console) + this.console.testMessage(false, true, this.test.name, message); + else + this.console.info("pass:", message); + this.passed++; + this.test.passed++; + this.test.last = message; + } + }, + + expectFail: function(callback) { + this.expectFailure = true; + callback(); + this.expectFailure = false; + }, + + exception: function exception(e) { + this._logTestFailed("exception"); + if (cfxArgs.parseable) + this.console.print("TEST-UNEXPECTED-FAIL | " + this.test.name + " | " + e + "\n"); + this.console.exception(e); + this.failed++; + this.test.failed++; + }, + + assertMatches: function assertMatches(string, regexp, message) { + if (regexp.test(string)) { + if (!message) + message = uneval(string) + " matches " + uneval(regexp); + this.pass(message); + } else { + var no = uneval(string) + " doesn't match " + uneval(regexp); + if (!message) + message = no; + else + message = message + " (" + no + ")"; + this.fail(message); + } + }, + + assertRaises: function assertRaises(func, predicate, message) { + try { + func(); + if (message) + this.fail(message + " (no exception thrown)"); + else + this.fail("function failed to throw exception"); + } catch (e) { + var errorMessage; + if (typeof(e) == "string") + errorMessage = e; + else + errorMessage = e.message; + if (typeof(predicate) == "string") + this.assertEqual(errorMessage, predicate, message); + else + this.assertMatches(errorMessage, predicate, message); + } + }, + + assert: function assert(a, message) { + if (!a) { + if (!message) + message = "assertion failed, value is " + a; + this.fail(message); + } else + this.pass(message || "assertion successful"); + }, + + assertNotEqual: function assertNotEqual(a, b, message) { + if (a != b) { + if (!message) + message = "a != b != " + uneval(a); + this.pass(message); + } else { + var equality = uneval(a) + " == " + uneval(b); + if (!message) + message = equality; + else + message += " (" + equality + ")"; + this.fail(message); + } + }, + + assertEqual: function assertEqual(a, b, message) { + if (a == b) { + if (!message) + message = "a == b == " + uneval(a); + this.pass(message); + } else { + var inequality = uneval(a) + " != " + uneval(b); + if (!message) + message = inequality; + else + message += " (" + inequality + ")"; + this.fail(message); + } + }, + + assertNotStrictEqual: function assertNotStrictEqual(a, b, message) { + if (a !== b) { + if (!message) + message = "a !== b !== " + uneval(a); + this.pass(message); + } else { + var equality = uneval(a) + " === " + uneval(b); + if (!message) + message = equality; + else + message += " (" + equality + ")"; + this.fail(message); + } + }, + + assertStrictEqual: function assertStrictEqual(a, b, message) { + if (a === b) { + if (!message) + message = "a === b === " + uneval(a); + this.pass(message); + } else { + var inequality = uneval(a) + " !== " + uneval(b); + if (!message) + message = inequality; + else + message += " (" + inequality + ")"; + this.fail(message); + } + }, + + assertFunction: function assertFunction(a, message) { + this.assertStrictEqual('function', typeof a, message); + }, + + assertUndefined: function(a, message) { + this.assertStrictEqual('undefined', typeof a, message); + }, + + assertNotUndefined: function(a, message) { + this.assertNotStrictEqual('undefined', typeof a, message); + }, + + assertNull: function(a, message) { + this.assertStrictEqual(null, a, message); + }, + + assertNotNull: function(a, message) { + this.assertNotStrictEqual(null, a, message); + }, + + assertObject: function(a, message) { + this.assertStrictEqual('[object Object]', Object.prototype.toString.apply(a), message); + }, + + assertString: function(a, message) { + this.assertStrictEqual('[object String]', Object.prototype.toString.apply(a), message); + }, + + assertArray: function(a, message) { + this.assertStrictEqual('[object Array]', Object.prototype.toString.apply(a), message); + }, + + assertNumber: function(a, message) { + this.assertStrictEqual('[object Number]', Object.prototype.toString.apply(a), message); + }, + + done: function done() { + if (this.isDone) { + return resolve(); + } + + this.isDone = true; + this.pass("This test is done."); + + if (this.test.teardown) { + this.test.teardown(this); + } + + if (this.waitTimeout !== null) { + timer.clearTimeout(this.waitTimeout); + this.waitTimeout = null; + } + + // Do not leave any callback set when calling to `waitUntil` + this.waitUntilCallback = null; + if (this.test.passed == 0 && this.test.failed == 0) { + this._logTestFailed("empty test"); + + if ("testMessage" in this.console) { + this.console.testMessage(false, false, this.test.name, "Empty test"); + } + else { + this.console.error("fail:", "Empty test") + } + + this.failed++; + this.test.failed++; + } + + let wins = windows(null, { includePrivate: true }); + let winPromises = wins.map(win => { + return new Promise(resolve => { + if (["interactive", "complete"].indexOf(win.document.readyState) >= 0) { + resolve() + } + else { + win.addEventListener("DOMContentLoaded", function onLoad() { + win.removeEventListener("DOMContentLoaded", onLoad, false); + resolve(); + }, false); + } + }); + }); + + PromiseDebugging.flushUncaughtErrors(); + PromiseDebugging.removeUncaughtErrorObserver(this._uncaughtErrorObserver); + + + return all(winPromises).then(() => { + let browserWins = wins.filter(isBrowser); + let tabs = browserWins.reduce((tabs, window) => tabs.concat(getTabs(window)), []); + let newTabID = getTabId(getSelectedTab(wins[0])); + let oldTabID = runnerTabs.get(this); + let hasMoreTabsOpen = browserWins.length && tabs.length != 1; + let failure = false; + + if (wins.length != 1 || getInnerId(wins[0]) !== runnerWindows.get(this)) { + failure = true; + this.fail("Should not be any unexpected windows open"); + } + else if (hasMoreTabsOpen) { + failure = true; + this.fail("Should not be any unexpected tabs open"); + } + else if (oldTabID != newTabID) { + failure = true; + runnerTabs.set(this, newTabID); + this.fail("Should not be any new tabs left open, old id: " + oldTabID + " new id: " + newTabID); + } + + if (failure) { + console.log("Windows open:"); + for (let win of wins) { + if (isBrowser(win)) { + tabs = getTabs(win); + console.log(win.location + " - " + tabs.map(getURI).join(", ")); + } + else { + console.log(win.location); + } + } + } + + return failure; + }). + then(failure => { + if (!failure) { + this.pass("There was a clean UI."); + return null; + } + return cleanUI().then(() => { + this.pass("There is a clean UI."); + }); + }). + then(() => { + this.testRunSummary.push({ + name: this.test.name, + passed: this.test.passed, + failed: this.test.failed, + errors: Object.keys(this.test.errors).join(", ") + }); + + if (this.onDone !== null) { + let onDone = this.onDone; + this.onDone = null; + timer.setTimeout(_ => onDone(this)); + } + }). + catch(console.exception); + }, + + // Set of assertion functions to wait for an assertion to become true + // These functions take the same arguments as the TestRunner.assert* methods. + waitUntil: function waitUntil() { + return this._waitUntil(this.assert, arguments); + }, + + waitUntilNotEqual: function waitUntilNotEqual() { + return this._waitUntil(this.assertNotEqual, arguments); + }, + + waitUntilEqual: function waitUntilEqual() { + return this._waitUntil(this.assertEqual, arguments); + }, + + waitUntilMatches: function waitUntilMatches() { + return this._waitUntil(this.assertMatches, arguments); + }, + + /** + * Internal function that waits for an assertion to become true. + * @param {Function} assertionMethod + * Reference to a TestRunner assertion method like test.assert, + * test.assertEqual, ... + * @param {Array} args + * List of arguments to give to the previous assertion method. + * All functions in this list are going to be called to retrieve current + * assertion values. + */ + _waitUntil: function waitUntil(assertionMethod, args) { + let { promise, resolve } = defer(); + let count = 0; + let maxCount = this.DEFAULT_PAUSE_TIMEOUT / this.PAUSE_DELAY; + + // We need to ensure that test is asynchronous + if (!this.waitTimeout) + this.waitUntilDone(this.DEFAULT_PAUSE_TIMEOUT); + + let finished = false; + let test = this; + + // capture a traceback before we go async. + let traceback = require("../console/traceback"); + let stack = traceback.get(); + stack.splice(-2, 2); + let currentWaitStack = traceback.format(stack); + let timeout = null; + + function loop(stopIt) { + timeout = null; + + // Build a mockup object to fake TestRunner API and intercept calls to + // pass and fail methods, in order to retrieve nice error messages + // and assertion result + let mock = { + pass: function (msg) { + test.pass(msg); + test.waitUntilCallback = null; + if (!stopIt) + resolve(); + }, + fail: function (msg) { + // If we are called on test timeout, we stop the loop + // and print which test keeps failing: + if (stopIt) { + test.console.error("test assertion never became true:\n", + msg + "\n", + currentWaitStack); + if (timeout) + timer.clearTimeout(timeout); + return; + } + timeout = timer.setTimeout(loop, test.PAUSE_DELAY); + } + }; + + // Automatically call args closures in order to build arguments for + // assertion function + let appliedArgs = []; + for (let i = 0, l = args.length; i < l; i++) { + let a = args[i]; + if (typeof a == "function") { + try { + a = a(); + } + catch(e) { + test.fail("Exception when calling asynchronous assertion: " + e + + "\n" + e.stack); + return resolve(); + } + } + appliedArgs.push(a); + } + + // Finally call assertion function with current assertion values + assertionMethod.apply(mock, appliedArgs); + } + loop(); + this.waitUntilCallback = loop; + + return promise; + }, + + waitUntilDone: function waitUntilDone(ms) { + if (ms === undefined) + ms = this.DEFAULT_PAUSE_TIMEOUT; + + var self = this; + + function tiredOfWaiting() { + self._logTestFailed("timed out"); + if ("testMessage" in self.console) { + self.console.testMessage(false, false, self.test.name, + `Test timed out (after: ${self.test.last})`); + } + else { + self.console.error("fail:", `Timed out (after: ${self.test.last})`) + } + if (self.waitUntilCallback) { + self.waitUntilCallback(true); + self.waitUntilCallback = null; + } + self.failed++; + self.test.failed++; + self.done(); + } + + // We may already have registered a timeout callback + if (this.waitTimeout) + timer.clearTimeout(this.waitTimeout); + + this.waitTimeout = timer.setTimeout(tiredOfWaiting, ms); + }, + + startMany: function startMany(options) { + function runNextTest(self) { + let { tests, onDone } = options; + + return tests.getNext().then((test) => { + if (options.stopOnError && self.test && self.test.failed) { + self.console.error("aborted: test failed and --stop-on-error was specified"); + onDone(self); + } + else if (test) { + self.start({test: test, onDone: runNextTest}); + } + else { + onDone(self); + } + }); + } + + return runNextTest(this).catch(console.exception); + }, + + start: function start(options) { + this.test = options.test; + this.test.passed = 0; + this.test.failed = 0; + this.test.errors = {}; + this.test.last = 'START'; + PromiseDebugging.clearUncaughtErrorObservers(); + this._uncaughtErrorObserver = this._uncaughtErrorObserver.bind(this); + PromiseDebugging.addUncaughtErrorObserver(this._uncaughtErrorObserver); + + this.isDone = false; + this.onDone = function(self) { + if (cfxArgs.parseable) + self.console.print("TEST-END | " + self.test.name + "\n"); + options.onDone(self); + } + this.waitTimeout = null; + + try { + if (cfxArgs.parseable) + this.console.print("TEST-START | " + this.test.name + "\n"); + else + this.console.info("executing '" + this.test.name + "'"); + + if(this.test.setup) { + this.test.setup(this); + } + this.test.testFunction(this); + } catch (e) { + this.exception(e); + } + if (this.waitTimeout === null) + this.done(); + } +}; +exports.TestRunner = TestRunner; diff --git a/addon-sdk/source/lib/sdk/deprecated/window-utils.js b/addon-sdk/source/lib/sdk/deprecated/window-utils.js new file mode 100644 index 000000000..93c0ab7b8 --- /dev/null +++ b/addon-sdk/source/lib/sdk/deprecated/window-utils.js @@ -0,0 +1,193 @@ +/* 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'; + +module.metadata = { + 'stability': 'deprecated' +}; + +const { Cc, Ci } = require('chrome'); +const events = require('../system/events'); +const { getInnerId, getOuterId, windows, isDocumentLoaded, isBrowser, + getMostRecentBrowserWindow, getToplevelWindow, getMostRecentWindow } = require('../window/utils'); +const { deprecateFunction } = require('../util/deprecate'); +const { ignoreWindow } = require('sdk/private-browsing/utils'); +const { isPrivateBrowsingSupported } = require('../self'); + +const windowWatcher = Cc['@mozilla.org/embedcomp/window-watcher;1']. + getService(Ci.nsIWindowWatcher); +const appShellService = Cc['@mozilla.org/appshell/appShellService;1']. + getService(Ci.nsIAppShellService); + +// Bug 834961: ignore private windows when they are not supported +function getWindows() { + return windows(null, { includePrivate: isPrivateBrowsingSupported }); +} + +/** + * An iterator for XUL windows currently in the application. + * + * @return A generator that yields XUL windows exposing the + * nsIDOMWindow interface. + */ +function windowIterator() { + // Bug 752631: We only pass already loaded window in order to avoid + // breaking XUL windows DOM. DOM is broken when some JS code try + // to access DOM during "uninitialized" state of the related document. + let list = getWindows().filter(isDocumentLoaded); + for (let i = 0, l = list.length; i < l; i++) { + yield list[i]; + } +}; +exports.windowIterator = windowIterator; + +/** + * An iterator for browser windows currently open in the application. + * @returns {Function} + * A generator that yields browser windows exposing the `nsIDOMWindow` + * interface. + */ +function browserWindowIterator() { + for (let window of windowIterator()) { + if (isBrowser(window)) + yield window; + } +} +exports.browserWindowIterator = browserWindowIterator; + +function WindowTracker(delegate) { + if (!(this instanceof WindowTracker)) { + return new WindowTracker(delegate); + } + + this._delegate = delegate; + + for (let window of getWindows()) + this._regWindow(window); + windowWatcher.registerNotification(this); + this._onToplevelWindowReady = this._onToplevelWindowReady.bind(this); + events.on('toplevel-window-ready', this._onToplevelWindowReady); + + require('../system/unload').ensure(this); + + return this; +}; + +WindowTracker.prototype = { + _regLoadingWindow: function _regLoadingWindow(window) { + // Bug 834961: ignore private windows when they are not supported + if (ignoreWindow(window)) + return; + + window.addEventListener('load', this, true); + }, + + _unregLoadingWindow: function _unregLoadingWindow(window) { + // This may have no effect if we ignored the window in _regLoadingWindow(). + window.removeEventListener('load', this, true); + }, + + _regWindow: function _regWindow(window) { + // Bug 834961: ignore private windows when they are not supported + if (ignoreWindow(window)) + return; + + if (window.document.readyState == 'complete') { + this._unregLoadingWindow(window); + this._delegate.onTrack(window); + } else + this._regLoadingWindow(window); + }, + + _unregWindow: function _unregWindow(window) { + if (window.document.readyState == 'complete') { + if (this._delegate.onUntrack) + this._delegate.onUntrack(window); + } else { + this._unregLoadingWindow(window); + } + }, + + unload: function unload() { + windowWatcher.unregisterNotification(this); + events.off('toplevel-window-ready', this._onToplevelWindowReady); + for (let window of getWindows()) + this._unregWindow(window); + }, + + handleEvent: function handleEvent(event) { + try { + if (event.type == 'load' && event.target) { + var window = event.target.defaultView; + if (window) + this._regWindow(getToplevelWindow(window)); + } + } + catch(e) { + console.exception(e); + } + }, + + _onToplevelWindowReady: function _onToplevelWindowReady({subject}) { + let window = getToplevelWindow(subject); + // ignore private windows if they are not supported + if (ignoreWindow(window)) + return; + this._regWindow(window); + }, + + observe: function observe(subject, topic, data) { + try { + var window = subject.QueryInterface(Ci.nsIDOMWindow); + // ignore private windows if they are not supported + if (ignoreWindow(window)) + return; + if (topic == 'domwindowclosed') + this._unregWindow(window); + } + catch(e) { + console.exception(e); + } + } +}; +exports.WindowTracker = WindowTracker; + +Object.defineProperties(exports, { + activeWindow: { + enumerable: true, + get: function() { + return getMostRecentWindow(null); + }, + set: function(window) { + try { + window.focus(); + } catch (e) {} + } + }, + activeBrowserWindow: { + enumerable: true, + get: getMostRecentBrowserWindow + } +}); + + +/** + * Returns the ID of the window's current inner window. + */ +exports.getInnerId = deprecateFunction(getInnerId, + 'require("window-utils").getInnerId is deprecated, ' + + 'please use require("sdk/window/utils").getInnerId instead' +); + +exports.getOuterId = deprecateFunction(getOuterId, + 'require("window-utils").getOuterId is deprecated, ' + + 'please use require("sdk/window/utils").getOuterId instead' +); + +exports.isBrowser = deprecateFunction(isBrowser, + 'require("window-utils").isBrowser is deprecated, ' + + 'please use require("sdk/window/utils").isBrowser instead' +); + +exports.hiddenWindow = appShellService.hiddenDOMWindow; diff --git a/addon-sdk/source/lib/sdk/dom/events-shimmed.js b/addon-sdk/source/lib/sdk/dom/events-shimmed.js new file mode 100644 index 000000000..7a1727681 --- /dev/null +++ b/addon-sdk/source/lib/sdk/dom/events-shimmed.js @@ -0,0 +1,18 @@ +/* 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'; + +module.metadata = { + 'stability': 'unstable' +}; + +const events = require('./events.js'); + +exports.emit = (element, type, obj) => events.emit(element, type, obj, true); +exports.on = (element, type, listener, capture) => events.on(element, type, listener, capture, true); +exports.once = (element, type, listener, capture) => events.once(element, type, listener, capture, true); +exports.removeListener = (element, type, listener, capture) => events.removeListener(element, type, listener, capture, true); +exports.removed = events.removed; +exports.when = (element, eventName, capture) => events.when(element, eventName, capture ? capture : false, true); diff --git a/addon-sdk/source/lib/sdk/dom/events.js b/addon-sdk/source/lib/sdk/dom/events.js new file mode 100644 index 000000000..502d2350f --- /dev/null +++ b/addon-sdk/source/lib/sdk/dom/events.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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cu } = require("chrome"); +const { ShimWaiver } = Cu.import("resource://gre/modules/ShimWaiver.jsm"); + +// Utility function that returns copy of the given `text` with last character +// removed if it is `"s"`. +function singularify(text) { + return text[text.length - 1] === "s" ? text.substr(0, text.length - 1) : text; +} + +// Utility function that takes event type, argument is passed to +// `document.createEvent` and returns name of the initializer method of the +// given event. Please note that there are some event types whose initializer +// methods can't be guessed by this function. For more details see following +// link: https://developer.mozilla.org/En/DOM/Document.createEvent +function getInitializerName(category) { + return "init" + singularify(category); +} + +/** + * Registers an event `listener` on a given `element`, that will be called + * when events of specified `type` is dispatched on the `element`. + * @param {Element} element + * Dom element to register listener on. + * @param {String} type + * A string representing the + * [event type](https://developer.mozilla.org/en/DOM/event.type) to + * listen for. + * @param {Function} listener + * Function that is called whenever an event of the specified `type` + * occurs. + * @param {Boolean} capture + * If true, indicates that the user wishes to initiate capture. After + * initiating capture, all events of the specified type will be dispatched + * to the registered listener before being dispatched to any `EventTarget`s + * beneath it in the DOM tree. Events which are bubbling upward through + * the tree will not trigger a listener designated to use capture. + * See [DOM Level 3 Events](http://www.w3.org/TR/DOM-Level-3-Events/#event-flow) + * for a detailed explanation. + */ +function on(element, type, listener, capture, shimmed = false) { + // `capture` defaults to `false`. + capture = capture || false; + if (shimmed) { + element.addEventListener(type, listener, capture); + } else { + ShimWaiver.getProperty(element, "addEventListener")(type, listener, capture); + } +} +exports.on = on; + +/** + * Registers an event `listener` on a given `element`, that will be called + * only once, next time event of specified `type` is dispatched on the + * `element`. + * @param {Element} element + * Dom element to register listener on. + * @param {String} type + * A string representing the + * [event type](https://developer.mozilla.org/en/DOM/event.type) to + * listen for. + * @param {Function} listener + * Function that is called whenever an event of the specified `type` + * occurs. + * @param {Boolean} capture + * If true, indicates that the user wishes to initiate capture. After + * initiating capture, all events of the specified type will be dispatched + * to the registered listener before being dispatched to any `EventTarget`s + * beneath it in the DOM tree. Events which are bubbling upward through + * the tree will not trigger a listener designated to use capture. + * See [DOM Level 3 Events](http://www.w3.org/TR/DOM-Level-3-Events/#event-flow) + * for a detailed explanation. + */ +function once(element, type, listener, capture, shimmed = false) { + on(element, type, function selfRemovableListener(event) { + removeListener(element, type, selfRemovableListener, capture, shimmed); + listener.apply(this, arguments); + }, capture, shimmed); +} +exports.once = once; + +/** + * Unregisters an event `listener` on a given `element` for the events of the + * specified `type`. + * + * @param {Element} element + * Dom element to unregister listener from. + * @param {String} type + * A string representing the + * [event type](https://developer.mozilla.org/en/DOM/event.type) to + * listen for. + * @param {Function} listener + * Function that is called whenever an event of the specified `type` + * occurs. + * @param {Boolean} capture + * If true, indicates that the user wishes to initiate capture. After + * initiating capture, all events of the specified type will be dispatched + * to the registered listener before being dispatched to any `EventTarget`s + * beneath it in the DOM tree. Events which are bubbling upward through + * the tree will not trigger a listener designated to use capture. + * See [DOM Level 3 Events](http://www.w3.org/TR/DOM-Level-3-Events/#event-flow) + * for a detailed explanation. + */ +function removeListener(element, type, listener, capture, shimmed = false) { + if (shimmed) { + element.removeEventListener(type, listener, capture); + } else { + ShimWaiver.getProperty(element, "removeEventListener")(type, listener, capture); + } +} +exports.removeListener = removeListener; + +/** + * Emits event of the specified `type` and `category` on the given `element`. + * Specified `settings` are used to initialize event before dispatching it. + * @param {Element} element + * Dom element to dispatch event on. + * @param {String} type + * A string representing the + * [event type](https://developer.mozilla.org/en/DOM/event.type). + * @param {Object} options + * Options object containing following properties: + * - `category`: String passed to the `document.createEvent`. Option is + * optional and defaults to "UIEvents". + * - `initializer`: If passed it will be used as name of the method used + * to initialize event. If omitted name will be generated from the + * `category` field by prefixing it with `"init"` and removing last + * character if it matches `"s"`. + * - `settings`: Array of settings that are forwarded to the event + * initializer after firs `type` argument. + * @see https://developer.mozilla.org/En/DOM/Document.createEvent + */ +function emit(element, type, { category, initializer, settings }, shimmed = false) { + category = category || "UIEvents"; + initializer = initializer || getInitializerName(category); + let document = element.ownerDocument; + let event = document.createEvent(category); + event[initializer].apply(event, [type].concat(settings)); + if (shimmed) { + element.dispatchEvent(event); + } else { + ShimWaiver.getProperty(element, "dispatchEvent")(event); + } +}; +exports.emit = emit; + +// Takes DOM `element` and returns promise which is resolved +// when given element is removed from it's parent node. +const removed = element => { + return new Promise(resolve => { + const { MutationObserver } = element.ownerDocument.defaultView; + const observer = new MutationObserver(mutations => { + for (let mutation of mutations) { + for (let node of mutation.removedNodes || []) { + if (node === element) { + observer.disconnect(); + resolve(element); + } + } + } + }); + observer.observe(element.parentNode, {childList: true}); + }); +}; +exports.removed = removed; + +const when = (element, eventName, capture=false, shimmed=false) => new Promise(resolve => { + const listener = event => { + if (shimmed) { + element.removeEventListener(eventName, listener, capture); + } else { + ShimWaiver.getProperty(element, "removeEventListener")(eventName, listener, capture); + } + resolve(event); + }; + + if (shimmed) { + element.addEventListener(eventName, listener, capture); + } else { + ShimWaiver.getProperty(element, "addEventListener")(eventName, listener, capture); + } +}); +exports.when = when; diff --git a/addon-sdk/source/lib/sdk/dom/events/keys.js b/addon-sdk/source/lib/sdk/dom/events/keys.js new file mode 100644 index 000000000..e6f1483a2 --- /dev/null +++ b/addon-sdk/source/lib/sdk/dom/events/keys.js @@ -0,0 +1,63 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { emit } = require("../events"); +const { getCodeForKey, toJSON } = require("../../keyboard/utils"); +const { has } = require("../../util/array"); +const { isString } = require("../../lang/type"); + +const INITIALIZER = "initKeyEvent"; +const CATEGORY = "KeyboardEvent"; + +function Options(options) { + if (!isString(options)) + return options; + + var { key, modifiers } = toJSON(options); + return { + key: key, + control: has(modifiers, "control"), + alt: has(modifiers, "alt"), + shift: has(modifiers, "shift"), + meta: has(modifiers, "meta") + }; +} + +var keyEvent = exports.keyEvent = function keyEvent(element, type, options) { + + emit(element, type, { + initializer: INITIALIZER, + category: CATEGORY, + settings: [ + !("bubbles" in options) || options.bubbles !== false, + !("cancelable" in options) || options.cancelable !== false, + "window" in options && options.window ? options.window : null, + "control" in options && !!options.control, + "alt" in options && !!options.alt, + "shift" in options && !!options.shift, + "meta" in options && !!options.meta, + getCodeForKey(options.key) || 0, + options.key.length === 1 ? options.key.charCodeAt(0) : 0 + ] + }); +} + +exports.keyDown = function keyDown(element, options) { + keyEvent(element, "keydown", Options(options)); +}; + +exports.keyUp = function keyUp(element, options) { + keyEvent(element, "keyup", Options(options)); +}; + +exports.keyPress = function keyPress(element, options) { + keyEvent(element, "keypress", Options(options)); +}; + diff --git a/addon-sdk/source/lib/sdk/event/chrome.js b/addon-sdk/source/lib/sdk/event/chrome.js new file mode 100644 index 000000000..9044fef99 --- /dev/null +++ b/addon-sdk/source/lib/sdk/event/chrome.js @@ -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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci, Cr, Cu } = require("chrome"); +const { emit, on, off } = require("./core"); +var observerService = Cc["@mozilla.org/observer-service;1"] + .getService(Ci.nsIObserverService); + +const { ShimWaiver } = Cu.import("resource://gre/modules/ShimWaiver.jsm"); +const addObserver = ShimWaiver.getProperty(observerService, "addObserver"); +const removeObserver = ShimWaiver.getProperty(observerService, "removeObserver"); + +const { when: unload } = require("../system/unload"); + +// Simple class that can be used to instantiate event channel that +// implements `nsIObserver` interface. It's will is used by `observe` +// function as observer + event target. It basically proxies observer +// notifications as to it's registered listeners. +function ObserverChannel() {} +Object.freeze(Object.defineProperties(ObserverChannel.prototype, { + QueryInterface: { + value: function(iid) { + if (!iid.equals(Ci.nsIObserver) && + !iid.equals(Ci.nsISupportsWeakReference) && + !iid.equals(Ci.nsISupports)) + throw Cr.NS_ERROR_NO_INTERFACE; + return this; + } + }, + observe: { + value: function(subject, topic, data) { + emit(this, "data", { + type: topic, + target: subject, + data: data + }); + } + } +})); + +function observe(topic) { + let observerChannel = new ObserverChannel(); + + // Note: `nsIObserverService` will not hold a weak reference to a + // observerChannel (since third argument is `true`). There for if it + // will be GC-ed with all it's event listeners once no other references + // will be held. + addObserver(observerChannel, topic, true); + + // We need to remove any observer added once the add-on is unloaded; + // otherwise we'll get a "dead object" exception. + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1001833 + unload(() => removeObserver(observerChannel, topic)); + + return observerChannel; +} + +exports.observe = observe; diff --git a/addon-sdk/source/lib/sdk/event/core.js b/addon-sdk/source/lib/sdk/event/core.js new file mode 100644 index 000000000..c16dd2df5 --- /dev/null +++ b/addon-sdk/source/lib/sdk/event/core.js @@ -0,0 +1,193 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const UNCAUGHT_ERROR = 'An error event was emitted for which there was no listener.'; +const BAD_LISTENER = 'The event listener must be a function.'; + +const { ns } = require('../core/namespace'); + +const event = ns(); + +const EVENT_TYPE_PATTERN = /^on([A-Z]\w+$)/; +exports.EVENT_TYPE_PATTERN = EVENT_TYPE_PATTERN; + +// Utility function to access given event `target` object's event listeners for +// the specific event `type`. If listeners for this type does not exists they +// will be created. +const observers = function observers(target, type) { + if (!target) throw TypeError("Event target must be an object"); + let listeners = event(target); + return type in listeners ? listeners[type] : listeners[type] = []; +}; + +/** + * Registers an event `listener` that is called every time events of + * specified `type` is emitted on the given event `target`. + * @param {Object} target + * Event target object. + * @param {String} type + * The type of event. + * @param {Function} listener + * The listener function that processes the event. + */ +function on(target, type, listener) { + if (typeof(listener) !== 'function') + throw new Error(BAD_LISTENER); + + let listeners = observers(target, type); + if (!~listeners.indexOf(listener)) + listeners.push(listener); +} +exports.on = on; + + +var onceWeakMap = new WeakMap(); + + +/** + * Registers an event `listener` that is called only the next time an event + * of the specified `type` is emitted on the given event `target`. + * @param {Object} target + * Event target object. + * @param {String} type + * The type of the event. + * @param {Function} listener + * The listener function that processes the event. + */ +function once(target, type, listener) { + let replacement = function observer(...args) { + off(target, type, observer); + onceWeakMap.delete(listener); + listener.apply(target, args); + }; + onceWeakMap.set(listener, replacement); + on(target, type, replacement); +} +exports.once = once; + +/** + * Execute each of the listeners in order with the supplied arguments. + * All the exceptions that are thrown by listeners during the emit + * are caught and can be handled by listeners of 'error' event. Thrown + * exceptions are passed as an argument to an 'error' event listener. + * If no 'error' listener is registered exception will be logged into an + * error console. + * @param {Object} target + * Event target object. + * @param {String} type + * The type of event. + * @params {Object|Number|String|Boolean} args + * Arguments that will be passed to listeners. + */ +function emit (target, type, ...args) { + emitOnObject(target, type, target, ...args); +} +exports.emit = emit; + +/** + * A variant of emit that allows setting the this property for event listeners + */ +function emitOnObject(target, type, thisArg, ...args) { + let all = observers(target, '*').length; + let state = observers(target, type); + let listeners = state.slice(); + let count = listeners.length; + let index = 0; + + // If error event and there are no handlers (explicit or catch-all) + // then print error message to the console. + if (count === 0 && type === 'error' && all === 0) + console.exception(args[0]); + while (index < count) { + try { + let listener = listeners[index]; + // Dispatch only if listener is still registered. + if (~state.indexOf(listener)) + listener.apply(thisArg, args); + } + catch (error) { + // If exception is not thrown by a error listener and error listener is + // registered emit `error` event. Otherwise dump exception to the console. + if (type !== 'error') emit(target, 'error', error); + else console.exception(error); + } + index++; + } + // Also emit on `"*"` so that one could listen for all events. + if (type !== '*') emit(target, '*', type, ...args); +} +exports.emitOnObject = emitOnObject; + +/** + * Removes an event `listener` for the given event `type` on the given event + * `target`. If no `listener` is passed removes all listeners of the given + * `type`. If `type` is not passed removes all the listeners of the given + * event `target`. + * @param {Object} target + * The event target object. + * @param {String} type + * The type of event. + * @param {Function} listener + * The listener function that processes the event. + */ +function off(target, type, listener) { + let length = arguments.length; + if (length === 3) { + if (onceWeakMap.has(listener)) { + listener = onceWeakMap.get(listener); + onceWeakMap.delete(listener); + } + + let listeners = observers(target, type); + let index = listeners.indexOf(listener); + if (~index) + listeners.splice(index, 1); + } + else if (length === 2) { + observers(target, type).splice(0); + } + else if (length === 1) { + let listeners = event(target); + Object.keys(listeners).forEach(type => delete listeners[type]); + } +} +exports.off = off; + +/** + * Returns a number of event listeners registered for the given event `type` + * on the given event `target`. + */ +function count(target, type) { + return observers(target, type).length; +} +exports.count = count; + +/** + * Registers listeners on the given event `target` from the given `listeners` + * dictionary. Iterates over the listeners and if property name matches name + * pattern `onEventType` and property is a function, then registers it as + * an `eventType` listener on `target`. + * + * @param {Object} target + * The type of event. + * @param {Object} listeners + * Dictionary of listeners. + */ +function setListeners(target, listeners) { + Object.keys(listeners || {}).forEach(key => { + let match = EVENT_TYPE_PATTERN.exec(key); + let type = match && match[1].toLowerCase(); + if (!type) return; + + let listener = listeners[key]; + if (typeof(listener) === 'function') + on(target, type, listener); + }); +} +exports.setListeners = setListeners; diff --git a/addon-sdk/source/lib/sdk/event/dom.js b/addon-sdk/source/lib/sdk/event/dom.js new file mode 100644 index 000000000..da99dec7a --- /dev/null +++ b/addon-sdk/source/lib/sdk/event/dom.js @@ -0,0 +1,78 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Ci } = require("chrome"); + +var { emit } = require("./core"); +var { when: unload } = require("../system/unload"); +var listeners = new WeakMap(); + +const { Cu } = require("chrome"); +const { ShimWaiver } = Cu.import("resource://gre/modules/ShimWaiver.jsm"); +const { ThreadSafeChromeUtils } = Cu.import("resource://gre/modules/Services.jsm", {}); + +var getWindowFrom = x => + x instanceof Ci.nsIDOMWindow ? x : + x instanceof Ci.nsIDOMDocument ? x.defaultView : + x instanceof Ci.nsIDOMNode ? x.ownerDocument.defaultView : + null; + +function removeFromListeners() { + ShimWaiver.getProperty(this, "removeEventListener")("DOMWindowClose", removeFromListeners); + for (let cleaner of listeners.get(this)) + cleaner(); + + listeners.delete(this); +} + +// Simple utility function takes event target, event type and optional +// `options.capture` and returns node style event stream that emits "data" +// events every time event of that type occurs on the given `target`. +function open(target, type, options) { + let output = {}; + let capture = options && options.capture ? true : false; + let listener = (event) => emit(output, "data", event); + + // `open` is currently used only on DOM Window objects, however it was made + // to be used to any kind of `target` that supports `addEventListener`, + // therefore is safer get the `window` from the `target` instead assuming + // that `target` is the `window`. + let window = getWindowFrom(target); + + // If we're not able to get a `window` from `target`, there is something + // wrong. We cannot add listeners that can leak later, or results in + // "dead object" exception. + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1001833 + if (!window) + throw new Error("Unable to obtain the owner window from the target given."); + + let cleaners = listeners.get(window); + if (!cleaners) { + cleaners = []; + listeners.set(window, cleaners); + + // We need to remove from our map the `window` once is closed, to prevent + // memory leak + ShimWaiver.getProperty(window, "addEventListener")("DOMWindowClose", removeFromListeners); + } + + cleaners.push(() => ShimWaiver.getProperty(target, "removeEventListener")(type, listener, capture)); + ShimWaiver.getProperty(target, "addEventListener")(type, listener, capture); + + return output; +} + +unload(() => { + let keys = ThreadSafeChromeUtils.nondeterministicGetWeakMapKeys(listeners) + for (let window of keys) + removeFromListeners.call(window); +}); + +exports.open = open; diff --git a/addon-sdk/source/lib/sdk/event/target.js b/addon-sdk/source/lib/sdk/event/target.js new file mode 100644 index 000000000..3a1f5e5f0 --- /dev/null +++ b/addon-sdk/source/lib/sdk/event/target.js @@ -0,0 +1,74 @@ +/* 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'; + +module.metadata = { + "stability": "stable" +}; + +const { on, once, off, setListeners } = require('./core'); +const { method, chainable } = require('../lang/functional/core'); +const { Class } = require('../core/heritage'); + +/** + * `EventTarget` is an exemplar for creating an objects that can be used to + * add / remove event listeners on them. Events on these objects may be emitted + * via `emit` function exported by 'event/core' module. + */ +const EventTarget = Class({ + /** + * Method initializes `this` event source. It goes through properties of a + * given `options` and registers listeners for the ones that look like an + * event listeners. + */ + /** + * Method initializes `this` event source. It goes through properties of a + * given `options` and registers listeners for the ones that look like an + * event listeners. + */ + initialize: function initialize(options) { + setListeners(this, options); + }, + /** + * Registers an event `listener` that is called every time events of + * specified `type` are emitted. + * @param {String} type + * The type of event. + * @param {Function} listener + * The listener function that processes the event. + * @example + * worker.on('message', function (data) { + * console.log('data received: ' + data) + * }) + */ + on: chainable(method(on)), + /** + * Registers an event `listener` that is called once the next time an event + * of the specified `type` is emitted. + * @param {String} type + * The type of the event. + * @param {Function} listener + * The listener function that processes the event. + */ + once: chainable(method(once)), + /** + * Removes an event `listener` for the given event `type`. + * @param {String} type + * The type of event. + * @param {Function} listener + * The listener function that processes the event. + */ + removeListener: function removeListener(type, listener) { + // Note: We can't just wrap `off` in `method` as we do it for other methods + // cause skipping a second or third argument will behave very differently + // than intended. This way we make sure all arguments are passed and only + // one listener is removed at most. + off(this, type, listener); + return this; + }, + // but we can wrap `off` here, as the semantics are the same + off: chainable(method(off)) + +}); +exports.EventTarget = EventTarget; diff --git a/addon-sdk/source/lib/sdk/event/utils.js b/addon-sdk/source/lib/sdk/event/utils.js new file mode 100644 index 000000000..f193b6785 --- /dev/null +++ b/addon-sdk/source/lib/sdk/event/utils.js @@ -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/. */ +"use strict"; + +module.metadata = { + "stability": "unstable" +}; + +var { emit, on, once, off, EVENT_TYPE_PATTERN } = require("./core"); +const { Cu } = require("chrome"); + +// This module provides set of high order function for working with event +// streams (streams in a NodeJS style that dispatch data, end and error +// events). + +// Function takes a `target` object and returns set of implicit references +// (non property references) it keeps. This basically allows defining +// references between objects without storing the explicitly. See transform for +// more details. +var refs = (function() { + let refSets = new WeakMap(); + return function refs(target) { + if (!refSets.has(target)) refSets.set(target, new Set()); + return refSets.get(target); + }; +})(); + +function transform(input, f) { + let output = new Output(); + + // Since event listeners don't prevent `input` to be GC-ed we wanna presrve + // it until `output` can be GC-ed. There for we add implicit reference which + // is removed once `input` ends. + refs(output).add(input); + + const next = data => receive(output, data); + once(output, "start", () => start(input)); + on(input, "error", error => emit(output, "error", error)); + on(input, "end", function() { + refs(output).delete(input); + end(output); + }); + on(input, "data", data => f(data, next)); + return output; +} + +// High order event transformation function that takes `input` event channel +// and returns transformation containing only events on which `p` predicate +// returns `true`. +function filter(input, predicate) { + return transform(input, function(data, next) { + if (predicate(data)) + next(data); + }); +} +exports.filter = filter; + +// High order function that takes `input` and returns input of it's values +// mapped via given `f` function. +const map = (input, f) => transform(input, (data, next) => next(f(data))); +exports.map = map; + +// High order function that takes `input` stream of streams and merges them +// into single event stream. Like flatten but time based rather than order +// based. +function merge(inputs) { + let output = new Output(); + let open = 1; + let state = []; + output.state = state; + refs(output).add(inputs); + + function end(input) { + open = open - 1; + refs(output).delete(input); + if (open === 0) emit(output, "end"); + } + const error = e => emit(output, "error", e); + function forward(input) { + state.push(input); + open = open + 1; + on(input, "end", () => end(input)); + on(input, "error", error); + on(input, "data", data => emit(output, "data", data)); + } + + // If `inputs` is an array treat it as a stream. + if (Array.isArray(inputs)) { + inputs.forEach(forward); + end(inputs); + } + else { + on(inputs, "end", () => end(inputs)); + on(inputs, "error", error); + on(inputs, "data", forward); + } + + return output; +} +exports.merge = merge; + +const expand = (inputs, f) => merge(map(inputs, f)); +exports.expand = expand; + +const pipe = (from, to) => on(from, "*", emit.bind(emit, to)); +exports.pipe = pipe; + + +// Shim signal APIs so other modules can be used as is. +const receive = (input, message) => { + if (input[receive]) + input[receive](input, message); + else + emit(input, "data", message); + + // Ideally our input will extend Input and already provide a weak value + // getter. If not, opportunistically shim the weak value getter on + // other types passed as the input. + if (!("value" in input)) { + Object.defineProperty(input, "value", WeakValueGetterSetter); + } + input.value = message; +}; +receive.toString = () => "@@receive"; +exports.receive = receive; +exports.send = receive; + +const end = input => { + if (input[end]) + input[end](input); + else + emit(input, "end", input); +}; +end.toString = () => "@@end"; +exports.end = end; + +const stop = input => { + if (input[stop]) + input[stop](input); + else + emit(input, "stop", input); +}; +stop.toString = () => "@@stop"; +exports.stop = stop; + +const start = input => { + if (input[start]) + input[start](input); + else + emit(input, "start", input); +}; +start.toString = () => "@@start"; +exports.start = start; + +const lift = (step, ...inputs) => { + let args = null; + let opened = inputs.length; + let started = false; + const output = new Output(); + const init = () => { + args = [...inputs.map(input => input.value)]; + output.value = step(...args); + }; + + inputs.forEach((input, index) => { + on(input, "data", data => { + args[index] = data; + receive(output, step(...args)); + }); + on(input, "end", () => { + opened = opened - 1; + if (opened <= 0) + end(output); + }); + }); + + once(output, "start", () => { + inputs.forEach(start); + init(); + }); + + init(); + + return output; +}; +exports.lift = lift; + +const merges = inputs => { + let opened = inputs.length; + let output = new Output(); + output.value = inputs[0].value; + inputs.forEach((input, index) => { + on(input, "data", data => receive(output, data)); + on(input, "end", () => { + opened = opened - 1; + if (opened <= 0) + end(output); + }); + }); + + once(output, "start", () => { + inputs.forEach(start); + output.value = inputs[0].value; + }); + + return output; +}; +exports.merges = merges; + +const foldp = (step, initial, input) => { + let output = map(input, x => step(output.value, x)); + output.value = initial; + return output; +}; +exports.foldp = foldp; + +const keepIf = (p, base, input) => { + let output = filter(input, p); + output.value = base; + return output; +}; +exports.keepIf = keepIf; + +function Input() {} +Input.start = input => emit(input, "start", input); +Input.prototype.start = Input.start; + +Input.end = input => { + emit(input, "end", input); + stop(input); +}; +Input.prototype[end] = Input.end; + +// The event channel system caches the last event seen as input.value. +// Unfortunately, if the last event is a DOM object this is a great way +// leak windows. Mitigate this by storing input.value using a weak +// reference. This allows the system to work for normal event processing +// while also allowing the objects to be reclaimed. It means, however, +// input.value cannot be accessed long after the event was dispatched. +const WeakValueGetterSetter = { + get: function() { + return this._weakValue ? this._weakValue.get() : this._simpleValue + }, + set: function(v) { + if (v && typeof v === "object") { + try { + // Try to set a weak reference. This can throw for some values. + // For example, if the value is a native object that does not + // implement nsISupportsWeakReference. + this._weakValue = Cu.getWeakReference(v) + this._simpleValue = undefined; + return; + } catch (e) { + // Do nothing. Fall through to setting _simpleValue below. + } + } + this._simpleValue = v; + this._weakValue = undefined; + }, +} +Object.defineProperty(Input.prototype, "value", WeakValueGetterSetter); + +exports.Input = Input; + +// Define an Output type with a weak value getter for the transformation +// functions that produce new channels. +function Output() { } +Object.defineProperty(Output.prototype, "value", WeakValueGetterSetter); +exports.Output = Output; + +const $source = "@@source"; +const $outputs = "@@outputs"; +exports.outputs = $outputs; + +// NOTE: Passing DOM objects through a Reactor can cause them to leak +// when they get cached in this.value. We cannot use a weak reference +// in this case because the Reactor design expects to always have both the +// past and present value. If we allow past values to be collected the +// system breaks. + +function Reactor(options={}) { + const {onStep, onStart, onEnd} = options; + if (onStep) + this.onStep = onStep; + if (onStart) + this.onStart = onStart; + if (onEnd) + this.onEnd = onEnd; +} +Reactor.prototype.onStep = _ => void(0); +Reactor.prototype.onStart = _ => void(0); +Reactor.prototype.onEnd = _ => void(0); +Reactor.prototype.onNext = function(present, past) { + this.value = present; + this.onStep(present, past); +}; +Reactor.prototype.run = function(input) { + on(input, "data", message => this.onNext(message, input.value)); + on(input, "end", () => this.onEnd(input.value)); + start(input); + this.value = input.value; + this.onStart(input.value); +}; +exports.Reactor = Reactor; + +/** + * Takes an object used as options with potential keys like 'onMessage', + * used to be called `require('sdk/event/core').setListeners` on. + * This strips all keys that would trigger a listener to be set. + * + * @params {Object} object + * @return {Object} + */ + +function stripListeners (object) { + return Object.keys(object || {}).reduce((agg, key) => { + if (!EVENT_TYPE_PATTERN.test(key)) + agg[key] = object[key]; + return agg; + }, {}); +} +exports.stripListeners = stripListeners; + +const when = (target, type) => new Promise(resolve => { + once(target, type, resolve); +}); +exports.when = when; diff --git a/addon-sdk/source/lib/sdk/frame/hidden-frame.js b/addon-sdk/source/lib/sdk/frame/hidden-frame.js new file mode 100644 index 000000000..97e0b7974 --- /dev/null +++ b/addon-sdk/source/lib/sdk/frame/hidden-frame.js @@ -0,0 +1,115 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Cc, Ci } = require("chrome"); +const { Class } = require("../core/heritage"); +const { List, addListItem, removeListItem } = require("../util/list"); +const { EventTarget } = require("../event/target"); +const { emit } = require("../event/core"); +const { create: makeFrame } = require("./utils"); +const { defer } = require("../core/promise"); +const { when: unload } = require("../system/unload"); +const { validateOptions, getTypeOf } = require("../deprecated/api-utils"); +const { window } = require("../addon/window"); +const { fromIterator } = require("../util/array"); + +// This cache is used to access friend properties between functions +// without exposing them on the public API. +var cache = new Set(); +var elements = new WeakMap(); + +function contentLoaded(target) { + var deferred = defer(); + target.addEventListener("DOMContentLoaded", function DOMContentLoaded(event) { + // "DOMContentLoaded" events from nested frames propagate up to target, + // ignore events unless it's DOMContentLoaded for the given target. + if (event.target === target || event.target === target.contentDocument) { + target.removeEventListener("DOMContentLoaded", DOMContentLoaded, false); + deferred.resolve(target); + } + }, false); + return deferred.promise; +} + +function FrameOptions(options) { + options = options || {} + return validateOptions(options, FrameOptions.validator); +} +FrameOptions.validator = { + onReady: { + is: ["undefined", "function", "array"], + ok: function(v) { + if (getTypeOf(v) === "array") { + // make sure every item is a function + return v.every(item => typeof(item) === "function") + } + return true; + } + }, + onUnload: { + is: ["undefined", "function"] + } +}; + +var HiddenFrame = Class({ + extends: EventTarget, + initialize: function initialize(options) { + options = FrameOptions(options); + EventTarget.prototype.initialize.call(this, options); + }, + get element() { + return elements.get(this); + }, + toString: function toString() { + return "[object Frame]" + } +}); +exports.HiddenFrame = HiddenFrame + +function addHidenFrame(frame) { + if (!(frame instanceof HiddenFrame)) + throw Error("The object to be added must be a HiddenFrame."); + + // This instance was already added. + if (cache.has(frame)) return frame; + else cache.add(frame); + + let element = makeFrame(window.document, { + nodeName: "iframe", + type: "content", + allowJavascript: true, + allowPlugins: true, + allowAuth: true, + }); + elements.set(frame, element); + + contentLoaded(element).then(function onFrameReady(element) { + emit(frame, "ready"); + }, console.exception); + + return frame; +} +exports.add = addHidenFrame + +function removeHiddenFrame(frame) { + if (!(frame instanceof HiddenFrame)) + throw Error("The object to be removed must be a HiddenFrame."); + + if (!cache.has(frame)) return; + + // Remove from cache before calling in order to avoid loop + cache.delete(frame); + emit(frame, "unload") + let element = frame.element + if (element) element.parentNode.removeChild(element) +} +exports.remove = removeHiddenFrame; + +unload(() => fromIterator(cache).forEach(removeHiddenFrame)); diff --git a/addon-sdk/source/lib/sdk/frame/utils.js b/addon-sdk/source/lib/sdk/frame/utils.js new file mode 100644 index 000000000..d9fccec4d --- /dev/null +++ b/addon-sdk/source/lib/sdk/frame/utils.js @@ -0,0 +1,94 @@ +/* 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'; + +module.metadata = { + "stability": "experimental" +}; + +const { Ci } = require("chrome"); +const XUL = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; + +function eventTarget(frame) { + return getDocShell(frame).chromeEventHandler; +} +exports.eventTarget = eventTarget; + +function getDocShell(frame) { + let { frameLoader } = frame.QueryInterface(Ci.nsIFrameLoaderOwner); + return frameLoader && frameLoader.docShell; +} +exports.getDocShell = getDocShell; + +/** + * Creates a XUL `browser` element in a privileged document. + * @params {nsIDOMDocument} document + * @params {String} options.type + * By default is 'content' for possible values see: + * https://developer.mozilla.org/en/XUL/iframe#a-browser.type + * @params {String} options.uri + * URI of the document to be loaded into created frame. + * @params {Boolean} options.remote + * If `true` separate process will be used for this frame, also in such + * case all the following options are ignored. + * @params {Boolean} options.allowAuth + * Whether to allow auth dialogs. Defaults to `false`. + * @params {Boolean} options.allowJavascript + * Whether to allow Javascript execution. Defaults to `false`. + * @params {Boolean} options.allowPlugins + * Whether to allow plugin execution. Defaults to `false`. + */ +function create(target, options) { + target = target instanceof Ci.nsIDOMDocument ? target.documentElement : + target instanceof Ci.nsIDOMWindow ? target.document.documentElement : + target; + options = options || {}; + let remote = options.remote || false; + let namespaceURI = options.namespaceURI || XUL; + let isXUL = namespaceURI === XUL; + let nodeName = isXUL && options.browser ? 'browser' : 'iframe'; + let document = target.ownerDocument; + + let frame = document.createElementNS(namespaceURI, nodeName); + // Type="content" is mandatory to enable stuff here: + // http://mxr.mozilla.org/mozilla-central/source/content/base/src/nsFrameLoader.cpp#1776 + frame.setAttribute('type', options.type || 'content'); + frame.setAttribute('src', options.uri || 'about:blank'); + + // Must set the remote attribute before attaching the frame to the document + if (remote && isXUL) { + // We remove XBL binding to avoid execution of code that is not going to + // work because browser has no docShell attribute in remote mode + // (for example) + frame.setAttribute('style', '-moz-binding: none;'); + frame.setAttribute('remote', 'true'); + } + + target.appendChild(frame); + + // Load in separate process if `options.remote` is `true`. + // http://mxr.mozilla.org/mozilla-central/source/content/base/src/nsFrameLoader.cpp#1347 + if (remote && !isXUL) { + frame.QueryInterface(Ci.nsIMozBrowserFrame); + frame.createRemoteFrameLoader(null); + } + + // If browser is remote it won't have a `docShell`. + if (!remote) { + let docShell = getDocShell(frame); + docShell.allowAuth = options.allowAuth || false; + docShell.allowJavascript = options.allowJavascript || false; + docShell.allowPlugins = options.allowPlugins || false; + docShell.allowWindowControl = options.allowWindowControl || false; + } + + return frame; +} +exports.create = create; + +function swapFrameLoaders(from, to) { + return from.QueryInterface(Ci.nsIFrameLoaderOwner).swapFrameLoaders(to); +} +exports.swapFrameLoaders = swapFrameLoaders; diff --git a/addon-sdk/source/lib/sdk/fs/path.js b/addon-sdk/source/lib/sdk/fs/path.js new file mode 100644 index 000000000..4474b2b4a --- /dev/null +++ b/addon-sdk/source/lib/sdk/fs/path.js @@ -0,0 +1,500 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// 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. + +// Adapted version of: +// https://github.com/joyent/node/blob/v0.11.3/lib/path.js + +// Shim process global from node. +var process = Object.create(require('../system')); +process.cwd = process.pathFor.bind(process, 'CurProcD'); + +// Update original check in node `process.platform === 'win32'` since in SDK it's `winnt`. +var isWindows = process.platform.indexOf('win') === 0; + + + +// resolves . and .. elements in a path array with directory names there +// must be no slashes, empty elements, or device names (c:\) in the array +// (so also no leading and trailing slashes - it does not distinguish +// relative and absolute paths) +function normalizeArray(parts, allowAboveRoot) { + // if the path tries to go above the root, `up` ends up > 0 + var up = 0; + for (var i = parts.length - 1; i >= 0; i--) { + var last = parts[i]; + if (last === '.') { + parts.splice(i, 1); + } else if (last === '..') { + parts.splice(i, 1); + up++; + } else if (up) { + parts.splice(i, 1); + up--; + } + } + + // if the path is allowed to go above the root, restore leading ..s + if (allowAboveRoot) { + for (; up--; up) { + parts.unshift('..'); + } + } + + return parts; +} + + +if (isWindows) { + // Regex to split a windows path into three parts: [*, device, slash, + // tail] windows-only + var splitDeviceRe = + /^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/; + + // Regex to split the tail part of the above into [*, dir, basename, ext] + var splitTailRe = + /^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/; + + // Function to split a filename into [root, dir, basename, ext] + // windows version + var splitPath = function(filename) { + // Separate device+slash from tail + var result = splitDeviceRe.exec(filename), + device = (result[1] || '') + (result[2] || ''), + tail = result[3] || ''; + // Split the tail into dir, basename and extension + var result2 = splitTailRe.exec(tail), + dir = result2[1], + basename = result2[2], + ext = result2[3]; + return [device, dir, basename, ext]; + }; + + var normalizeUNCRoot = function(device) { + return '\\\\' + device.replace(/^[\\\/]+/, '').replace(/[\\\/]+/g, '\\'); + }; + + // path.resolve([from ...], to) + // windows version + exports.resolve = function() { + var resolvedDevice = '', + resolvedTail = '', + resolvedAbsolute = false; + + for (var i = arguments.length - 1; i >= -1; i--) { + var path; + if (i >= 0) { + path = arguments[i]; + } else if (!resolvedDevice) { + path = process.cwd(); + } else { + // Windows has the concept of drive-specific current working + // directories. If we've resolved a drive letter but not yet an + // absolute path, get cwd for that drive. We're sure the device is not + // an unc path at this points, because unc paths are always absolute. + path = process.env['=' + resolvedDevice]; + // Verify that a drive-local cwd was found and that it actually points + // to our drive. If not, default to the drive's root. + if (!path || path.substr(0, 3).toLowerCase() !== + resolvedDevice.toLowerCase() + '\\') { + path = resolvedDevice + '\\'; + } + } + + // Skip empty and invalid entries + if (typeof path !== 'string') { + throw new TypeError('Arguments to path.resolve must be strings'); + } else if (!path) { + continue; + } + + var result = splitDeviceRe.exec(path), + device = result[1] || '', + isUnc = device && device.charAt(1) !== ':', + isAbsolute = exports.isAbsolute(path), + tail = result[3]; + + if (device && + resolvedDevice && + device.toLowerCase() !== resolvedDevice.toLowerCase()) { + // This path points to another device so it is not applicable + continue; + } + + if (!resolvedDevice) { + resolvedDevice = device; + } + if (!resolvedAbsolute) { + resolvedTail = tail + '\\' + resolvedTail; + resolvedAbsolute = isAbsolute; + } + + if (resolvedDevice && resolvedAbsolute) { + break; + } + } + + // Convert slashes to backslashes when `resolvedDevice` points to an UNC + // root. Also squash multiple slashes into a single one where appropriate. + if (isUnc) { + resolvedDevice = normalizeUNCRoot(resolvedDevice); + } + + // At this point the path should be resolved to a full absolute path, + // but handle relative paths to be safe (might happen when process.cwd() + // fails) + + // Normalize the tail path + + function f(p) { + return !!p; + } + + resolvedTail = normalizeArray(resolvedTail.split(/[\\\/]+/).filter(f), + !resolvedAbsolute).join('\\'); + + return (resolvedDevice + (resolvedAbsolute ? '\\' : '') + resolvedTail) || + '.'; + }; + + // windows version + exports.normalize = function(path) { + var result = splitDeviceRe.exec(path), + device = result[1] || '', + isUnc = device && device.charAt(1) !== ':', + isAbsolute = exports.isAbsolute(path), + tail = result[3], + trailingSlash = /[\\\/]$/.test(tail); + + // If device is a drive letter, we'll normalize to lower case. + if (device && device.charAt(1) === ':') { + device = device[0].toLowerCase() + device.substr(1); + } + + // Normalize the tail path + tail = normalizeArray(tail.split(/[\\\/]+/).filter(function(p) { + return !!p; + }), !isAbsolute).join('\\'); + + if (!tail && !isAbsolute) { + tail = '.'; + } + if (tail && trailingSlash) { + tail += '\\'; + } + + // Convert slashes to backslashes when `device` points to an UNC root. + // Also squash multiple slashes into a single one where appropriate. + if (isUnc) { + device = normalizeUNCRoot(device); + } + + return device + (isAbsolute ? '\\' : '') + tail; + }; + + // windows version + exports.isAbsolute = function(path) { + var result = splitDeviceRe.exec(path), + device = result[1] || '', + isUnc = device && device.charAt(1) !== ':'; + // UNC paths are always absolute + return !!result[2] || isUnc; + }; + + // windows version + exports.join = function() { + function f(p) { + if (typeof p !== 'string') { + throw new TypeError('Arguments to path.join must be strings'); + } + return p; + } + + var paths = Array.prototype.filter.call(arguments, f); + var joined = paths.join('\\'); + + // Make sure that the joined path doesn't start with two slashes, because + // normalize() will mistake it for an UNC path then. + // + // This step is skipped when it is very clear that the user actually + // intended to point at an UNC path. This is assumed when the first + // non-empty string arguments starts with exactly two slashes followed by + // at least one more non-slash character. + // + // Note that for normalize() to treat a path as an UNC path it needs to + // have at least 2 components, so we don't filter for that here. + // This means that the user can use join to construct UNC paths from + // a server name and a share name; for example: + // path.join('//server', 'share') -> '\\\\server\\share\') + if (!/^[\\\/]{2}[^\\\/]/.test(paths[0])) { + joined = joined.replace(/^[\\\/]{2,}/, '\\'); + } + + return exports.normalize(joined); + }; + + // path.relative(from, to) + // it will solve the relative path from 'from' to 'to', for instance: + // from = 'C:\\orandea\\test\\aaa' + // to = 'C:\\orandea\\impl\\bbb' + // The output of the function should be: '..\\..\\impl\\bbb' + // windows version + exports.relative = function(from, to) { + from = exports.resolve(from); + to = exports.resolve(to); + + // windows is not case sensitive + var lowerFrom = from.toLowerCase(); + var lowerTo = to.toLowerCase(); + + function trim(arr) { + var start = 0; + for (; start < arr.length; start++) { + if (arr[start] !== '') break; + } + + var end = arr.length - 1; + for (; end >= 0; end--) { + if (arr[end] !== '') break; + } + + if (start > end) return []; + return arr.slice(start, end - start + 1); + } + + var toParts = trim(to.split('\\')); + + var lowerFromParts = trim(lowerFrom.split('\\')); + var lowerToParts = trim(lowerTo.split('\\')); + + var length = Math.min(lowerFromParts.length, lowerToParts.length); + var samePartsLength = length; + for (var i = 0; i < length; i++) { + if (lowerFromParts[i] !== lowerToParts[i]) { + samePartsLength = i; + break; + } + } + + if (samePartsLength == 0) { + return to; + } + + var outputParts = []; + for (var i = samePartsLength; i < lowerFromParts.length; i++) { + outputParts.push('..'); + } + + outputParts = outputParts.concat(toParts.slice(samePartsLength)); + + return outputParts.join('\\'); + }; + + exports.sep = '\\'; + exports.delimiter = ';'; + +} else /* posix */ { + + // Split a filename into [root, dir, basename, ext], unix version + // 'root' is just a slash, or nothing. + var splitPathRe = + /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/; + var splitPath = function(filename) { + return splitPathRe.exec(filename).slice(1); + }; + + // path.resolve([from ...], to) + // posix version + exports.resolve = function() { + var resolvedPath = '', + resolvedAbsolute = false; + + for (var i = arguments.length - 1; i >= -1 && !resolvedAbsolute; i--) { + var path = (i >= 0) ? arguments[i] : process.cwd(); + + // Skip empty and invalid entries + if (typeof path !== 'string') { + throw new TypeError('Arguments to path.resolve must be strings'); + } else if (!path) { + continue; + } + + resolvedPath = path + '/' + resolvedPath; + resolvedAbsolute = path.charAt(0) === '/'; + } + + // At this point the path should be resolved to a full absolute path, but + // handle relative paths to be safe (might happen when process.cwd() fails) + + // Normalize the path + resolvedPath = normalizeArray(resolvedPath.split('/').filter(function(p) { + return !!p; + }), !resolvedAbsolute).join('/'); + + return ((resolvedAbsolute ? '/' : '') + resolvedPath) || '.'; + }; + + // path.normalize(path) + // posix version + exports.normalize = function(path) { + var isAbsolute = exports.isAbsolute(path), + trailingSlash = path.substr(-1) === '/'; + + // Normalize the path + path = normalizeArray(path.split('/').filter(function(p) { + return !!p; + }), !isAbsolute).join('/'); + + if (!path && !isAbsolute) { + path = '.'; + } + if (path && trailingSlash) { + path += '/'; + } + + return (isAbsolute ? '/' : '') + path; + }; + + // posix version + exports.isAbsolute = function(path) { + return path.charAt(0) === '/'; + }; + + // posix version + exports.join = function() { + var paths = Array.prototype.slice.call(arguments, 0); + return exports.normalize(paths.filter(function(p, index) { + if (typeof p !== 'string') { + throw new TypeError('Arguments to path.join must be strings'); + } + return p; + }).join('/')); + }; + + + // path.relative(from, to) + // posix version + exports.relative = function(from, to) { + from = exports.resolve(from).substr(1); + to = exports.resolve(to).substr(1); + + function trim(arr) { + var start = 0; + for (; start < arr.length; start++) { + if (arr[start] !== '') break; + } + + var end = arr.length - 1; + for (; end >= 0; end--) { + if (arr[end] !== '') break; + } + + if (start > end) return []; + return arr.slice(start, end - start + 1); + } + + var fromParts = trim(from.split('/')); + var toParts = trim(to.split('/')); + + var length = Math.min(fromParts.length, toParts.length); + var samePartsLength = length; + for (var i = 0; i < length; i++) { + if (fromParts[i] !== toParts[i]) { + samePartsLength = i; + break; + } + } + + var outputParts = []; + for (var i = samePartsLength; i < fromParts.length; i++) { + outputParts.push('..'); + } + + outputParts = outputParts.concat(toParts.slice(samePartsLength)); + + return outputParts.join('/'); + }; + + exports.sep = '/'; + exports.delimiter = ':'; +} + +exports.dirname = function(path) { + var result = splitPath(path), + root = result[0], + dir = result[1]; + + if (!root && !dir) { + // No dirname whatsoever + return '.'; + } + + if (dir) { + // It has a dirname, strip trailing slash + dir = dir.substr(0, dir.length - 1); + } + + return root + dir; +}; + + +exports.basename = function(path, ext) { + var f = splitPath(path)[2]; + // TODO: make this comparison case-insensitive on windows? + if (ext && f.substr(-1 * ext.length) === ext) { + f = f.substr(0, f.length - ext.length); + } + return f; +}; + + +exports.extname = function(path) { + return splitPath(path)[3]; +}; + +if (isWindows) { + exports._makeLong = function(path) { + // Note: this will *probably* throw somewhere. + if (typeof path !== 'string') + return path; + + if (!path) { + return ''; + } + + var resolvedPath = exports.resolve(path); + + if (/^[a-zA-Z]\:\\/.test(resolvedPath)) { + // path is local filesystem path, which needs to be converted + // to long UNC path. + return '\\\\?\\' + resolvedPath; + } else if (/^\\\\[^?.]/.test(resolvedPath)) { + // path is network UNC path, which needs to be converted + // to long UNC path. + return '\\\\?\\UNC\\' + resolvedPath.substring(2); + } + + return path; + }; +} else { + exports._makeLong = function(path) { + return path; + }; +}
\ No newline at end of file diff --git a/addon-sdk/source/lib/sdk/hotkeys.js b/addon-sdk/source/lib/sdk/hotkeys.js new file mode 100644 index 000000000..00081455e --- /dev/null +++ b/addon-sdk/source/lib/sdk/hotkeys.js @@ -0,0 +1,40 @@ +/* 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"; + +module.metadata = { + "stability": "stable" +}; + +const INVALID_HOTKEY = "Hotkey must have at least one modifier."; + +const { toJSON: jsonify, toString: stringify, + isFunctionKey } = require("./keyboard/utils"); +const { register, unregister } = require("./keyboard/hotkeys"); + +const Hotkey = exports.Hotkey = function Hotkey(options) { + if (!(this instanceof Hotkey)) + return new Hotkey(options); + + // Parsing key combination string. + let hotkey = jsonify(options.combo); + if (!isFunctionKey(hotkey.key) && !hotkey.modifiers.length) { + throw new TypeError(INVALID_HOTKEY); + } + + this.onPress = options.onPress && options.onPress.bind(this); + this.toString = stringify.bind(null, hotkey); + // Registering listener on keyboard combination enclosed by this hotkey. + // Please note that `this.toString()` is a normalized version of + // `options.combination` where order of modifiers is sorted and `accel` is + // replaced with platform specific key. + register(this.toString(), this.onPress); + // We freeze instance before returning it in order to make it's properties + // read-only. + return Object.freeze(this); +}; +Hotkey.prototype.destroy = function destroy() { + unregister(this.toString(), this.onPress); +}; diff --git a/addon-sdk/source/lib/sdk/indexed-db.js b/addon-sdk/source/lib/sdk/indexed-db.js new file mode 100644 index 000000000..d4d166c02 --- /dev/null +++ b/addon-sdk/source/lib/sdk/indexed-db.js @@ -0,0 +1,79 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Cc, Ci } = require("chrome"); +const { id } = require("./self"); + +// placeholder, copied from bootstrap.js +var sanitizeId = function(id){ + let uuidRe = + /^\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\}$/; + + let domain = id. + toLowerCase(). + replace(/@/g, "-at-"). + replace(/\./g, "-dot-"). + replace(uuidRe, "$1"); + + return domain +}; + +const PSEUDOURI = "indexeddb://" + sanitizeId(id) // https://bugzilla.mozilla.org/show_bug.cgi?id=779197 + +// Use XPCOM because `require("./url").URL` doesn't expose the raw uri object. +var principaluri = Cc["@mozilla.org/network/io-service;1"]. + getService(Ci.nsIIOService). + newURI(PSEUDOURI, null, null); + +var ssm = Cc["@mozilla.org/scriptsecuritymanager;1"] + .getService(Ci.nsIScriptSecurityManager); +var principal = ssm.createCodebasePrincipal(principaluri, {}); + +function toArray(args) { + return Array.prototype.slice.call(args); +} + +function openInternal(args, forPrincipal, deleting) { + if (forPrincipal) { + args = toArray(args); + } else { + args = [principal].concat(toArray(args)); + } + if (args.length == 2) { + args.push({ storage: "persistent" }); + } else if (!deleting && args.length >= 3 && typeof args[2] === "number") { + args[2] = { version: args[2], storage: "persistent" }; + } + + if (deleting) { + return indexedDB.deleteForPrincipal.apply(indexedDB, args); + } + + return indexedDB.openForPrincipal.apply(indexedDB, args); +} + +exports.indexedDB = Object.freeze({ + open: function () { + return openInternal(arguments, false, false); + }, + deleteDatabase: function () { + return openInternal(arguments, false, true); + }, + openForPrincipal: function () { + return openInternal(arguments, true, false); + }, + deleteForPrincipal: function () { + return openInternal(arguments, true, true); + }, + cmp: indexedDB.cmp.bind(indexedDB) +}); + +exports.IDBKeyRange = IDBKeyRange; +exports.DOMException = Ci.nsIDOMDOMException; diff --git a/addon-sdk/source/lib/sdk/input/browser.js b/addon-sdk/source/lib/sdk/input/browser.js new file mode 100644 index 000000000..daea875bf --- /dev/null +++ b/addon-sdk/source/lib/sdk/input/browser.js @@ -0,0 +1,73 @@ +/* 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 { windows, isBrowser, isInteractive, isDocumentLoaded, + getOuterId } = require("../window/utils"); +const { InputPort } = require("./system"); +const { lift, merges, foldp, keepIf, start, Input } = require("../event/utils"); +const { patch } = require("diffpatcher/index"); +const { Sequence, seq, filter, object, pairs } = require("../util/sequence"); + + +// Create lazy iterators from the regular arrays, although +// once https://github.com/mozilla/addon-sdk/pull/1314 lands +// `windows` will be transforme to lazy iterators. +// When iterated over belowe sequences items will represent +// state of windows at the time of iteration. +const opened = seq(function*() { + const items = windows("navigator:browser", {includePrivate: true}); + for (let item of items) { + yield [getOuterId(item), item]; + } +}); +const interactive = filter(([_, window]) => isInteractive(window), opened); +const loaded = filter(([_, window]) => isDocumentLoaded(window), opened); + +// Helper function that converts given argument to a delta. +const Update = window => window && object([getOuterId(window), window]); +const Delete = window => window && object([getOuterId(window), null]); + + +// Signal represents delta for last top level window close. +const LastClosed = lift(Delete, + keepIf(isBrowser, null, + new InputPort({topic: "domwindowclosed"}))); +exports.LastClosed = LastClosed; + +const windowFor = document => document && document.defaultView; + +// Signal represent delta for last top level window document becoming interactive. +const InteractiveDoc = new InputPort({topic: "chrome-document-interactive"}); +const InteractiveWin = lift(windowFor, InteractiveDoc); +const LastInteractive = lift(Update, keepIf(isBrowser, null, InteractiveWin)); +exports.LastInteractive = LastInteractive; + +// Signal represent delta for last top level window loaded. +const LoadedDoc = new InputPort({topic: "chrome-document-loaded"}); +const LoadedWin = lift(windowFor, LoadedDoc); +const LastLoaded = lift(Update, keepIf(isBrowser, null, LoadedWin)); +exports.LastLoaded = LastLoaded; + + +const initialize = input => { + if (!input.initialized) { + input.value = object(...input.value); + Input.start(input); + input.initialized = true; + } +}; + +// Signal represents set of top level interactive windows, updated any +// time new window becomes interactive or one get's closed. +const Interactive = foldp(patch, interactive, merges([LastInteractive, + LastClosed])); +Interactive[start] = initialize; +exports.Interactive = Interactive; + +// Signal represents set of top level loaded window, updated any time +// new window becomes interactive or one get's closed. +const Loaded = foldp(patch, loaded, merges([LastLoaded, LastClosed])); +Loaded[start] = initialize; +exports.Loaded = Loaded; diff --git a/addon-sdk/source/lib/sdk/input/customizable-ui.js b/addon-sdk/source/lib/sdk/input/customizable-ui.js new file mode 100644 index 000000000..a41d0971a --- /dev/null +++ b/addon-sdk/source/lib/sdk/input/customizable-ui.js @@ -0,0 +1,28 @@ +/* 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 } = require("chrome"); +const { CustomizableUI } = Cu.import('resource:///modules/CustomizableUI.jsm', {}); +const { receive } = require("../event/utils"); +const { InputPort } = require("./system"); +const { object} = require("../util/sequence"); +const { getOuterId } = require("../window/utils"); + +const Input = function() {}; +Input.prototype = Object.create(InputPort.prototype); + +Input.prototype.onCustomizeStart = function (window) { + receive(this, object([getOuterId(window), true])); +} + +Input.prototype.onCustomizeEnd = function (window) { + receive(this, object([getOuterId(window), null])); +} + +Input.prototype.addListener = input => CustomizableUI.addListener(input); + +Input.prototype.removeListener = input => CustomizableUI.removeListener(input); + +exports.CustomizationInput = Input; diff --git a/addon-sdk/source/lib/sdk/input/frame.js b/addon-sdk/source/lib/sdk/input/frame.js new file mode 100644 index 000000000..50efaa745 --- /dev/null +++ b/addon-sdk/source/lib/sdk/input/frame.js @@ -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"; + +const { Ci } = require("chrome"); +const { InputPort } = require("./system"); +const { getFrameElement, getOuterId, + getOwnerBrowserWindow } = require("../window/utils"); +const { isnt } = require("../lang/functional"); +const { foldp, lift, merges, keepIf } = require("../event/utils"); +const { object } = require("../util/sequence"); +const { compose } = require("../lang/functional"); +const { LastClosed } = require("./browser"); +const { patch } = require("diffpatcher/index"); + +const Document = Ci.nsIDOMDocument; + +const isntNull = isnt(null); + +const frameID = frame => frame.id; +const browserID = compose(getOuterId, getOwnerBrowserWindow); + +const isInnerFrame = frame => + frame && frame.hasAttribute("data-is-sdk-inner-frame"); + +// Utility function that given content window loaded in our frame views returns +// an actual frame. This basically takes care of fact that actual frame document +// is loaded in the nested iframe. If content window is not loaded in the nested +// frame of the frame view it returs null. +const getFrame = document => + document && document.defaultView && getFrameElement(document.defaultView); + +const FrameInput = function(options) { + const input = keepIf(isInnerFrame, null, + lift(getFrame, new InputPort(options))); + return lift(frame => { + if (!frame) return frame; + const [id, owner] = [frameID(frame), browserID(frame)]; + return object([id, {owners: object([owner, options.update])}]); + }, input); +}; + +const LastLoading = new FrameInput({topic: "document-element-inserted", + update: {readyState: "loading"}}); +exports.LastLoading = LastLoading; + +const LastInteractive = new FrameInput({topic: "content-document-interactive", + update: {readyState: "interactive"}}); +exports.LastInteractive = LastInteractive; + +const LastLoaded = new FrameInput({topic: "content-document-loaded", + update: {readyState: "complete"}}); +exports.LastLoaded = LastLoaded; + +const LastUnloaded = new FrameInput({topic: "content-page-hidden", + update: null}); +exports.LastUnloaded = LastUnloaded; + +// Represents state of SDK frames in form of data structure: +// {"frame#1": {"id": "frame#1", +// "inbox": {"data": "ping", +// "target": {"id": "frame#1", "owner": "outerWindowID#2"}, +// "source": {"id": "frame#1"}} +// "url": "resource://addon-1/data/index.html", +// "owners": {"outerWindowID#1": {"readyState": "loading"}, +// "outerWindowID#2": {"readyState": "complete"}} +// +// +// frame#2: {"id": "frame#2", +// "url": "resource://addon-1/data/main.html", +// "outbox": {"data": "pong", +// "source": {"id": "frame#2", "owner": "outerWindowID#1"} +// "target": {"id": "frame#2"}} +// "owners": {outerWindowID#1: {readyState: "interacitve"}}}} +const Frames = foldp(patch, {}, merges([ + LastLoading, + LastInteractive, + LastLoaded, + LastUnloaded, + new InputPort({ id: "frame-mailbox" }), + new InputPort({ id: "frame-change" }), + new InputPort({ id: "frame-changed" }) +])); +exports.Frames = Frames; diff --git a/addon-sdk/source/lib/sdk/input/system.js b/addon-sdk/source/lib/sdk/input/system.js new file mode 100644 index 000000000..66bc6daec --- /dev/null +++ b/addon-sdk/source/lib/sdk/input/system.js @@ -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"; + +const { Cc, Ci, Cr, Cu } = require("chrome"); +const { Input, start, stop, end, receive, outputs } = require("../event/utils"); +const { once, off } = require("../event/core"); +const { id: addonID } = require("../self"); + +const unloadMessage = require("@loader/unload"); +const observerService = Cc['@mozilla.org/observer-service;1']. + getService(Ci.nsIObserverService); +const { ShimWaiver } = Cu.import("resource://gre/modules/ShimWaiver.jsm"); +const addObserver = ShimWaiver.getProperty(observerService, "addObserver"); +const removeObserver = ShimWaiver.getProperty(observerService, "removeObserver"); + + +const addonUnloadTopic = "sdk:loader:destroy"; + +const isXrayWrapper = Cu.isXrayWrapper; +// In the past SDK used to double-wrap notifications dispatched, which +// made them awkward to use outside of SDK. At present they no longer +// do that, although we still supported for legacy reasons. +const isLegacyWrapper = x => + x && x.wrappedJSObject && + "observersModuleSubjectWrapper" in x.wrappedJSObject; + +const unwrapLegacy = x => x.wrappedJSObject.object; + +// `InputPort` provides a way to create a signal out of the observer +// notification subject's for the given `topic`. If `options.initial` +// is provided it is used as initial value otherwise `null` is used. +// Constructor can be given `options.id` that will be used to create +// a `topic` which is namespaced to an add-on (this avoids conflicts +// when multiple add-on are used, although in a future host probably +// should just be shared across add-ons). It is also possible to +// specify a specific `topic` via `options.topic` which is used as +// without namespacing. Created signal ends whenever add-on is +// unloaded. +const InputPort = function InputPort({id, topic, initial}) { + this.id = id || topic; + this.topic = topic || "sdk:" + addonID + ":" + id; + this.value = initial === void(0) ? null : initial; + this.observing = false; + this[outputs] = []; +}; + +// InputPort type implements `Input` signal interface. +InputPort.prototype = new Input(); +InputPort.prototype.constructor = InputPort; + +// When port is started (which is when it's subgraph get's +// first subscriber) actual observer is registered. +InputPort.start = input => { + input.addListener(input); + // Also register add-on unload observer to end this signal + // when that happens. + addObserver(input, addonUnloadTopic, false); +}; +InputPort.prototype[start] = InputPort.start; + +InputPort.addListener = input => addObserver(input, input.topic, false); +InputPort.prototype.addListener = InputPort.addListener; + +// When port is stopped (which is when it's subgraph has no +// no subcribers left) an actual observer unregistered. +// Note that port stopped once it ends as well (which is when +// add-on is unloaded). +InputPort.stop = input => { + input.removeListener(input); + removeObserver(input, addonUnloadTopic); +}; +InputPort.prototype[stop] = InputPort.stop; + +InputPort.removeListener = input => removeObserver(input, input.topic); +InputPort.prototype.removeListener = InputPort.removeListener; + +// `InputPort` also implements `nsIObserver` interface and +// `nsISupportsWeakReference` interfaces as it's going to be used as such. +InputPort.prototype.QueryInterface = function(iid) { + if (!iid.equals(Ci.nsIObserver) && !iid.equals(Ci.nsISupportsWeakReference)) + throw Cr.NS_ERROR_NO_INTERFACE; + + return this; +}; + +// `InputPort` instances implement `observe` method, which is invoked when +// observer notifications are dispatched. The `subject` of that notification +// are received on this signal. +InputPort.prototype.observe = function(subject, topic, data) { + // Unwrap message from the subject. SDK used to have it's own version of + // wrappedJSObjects which take precedence, if subject has `wrappedJSObject` + // and it's not an XrayWrapper use it as message. Otherwise use subject as + // is. + const message = subject === null ? null : + isLegacyWrapper(subject) ? unwrapLegacy(subject) : + isXrayWrapper(subject) ? subject : + subject.wrappedJSObject ? subject.wrappedJSObject : + subject; + + // If observer topic matches topic of the input port receive a message. + if (topic === this.topic) { + receive(this, message); + } + + // If observe topic is add-on unload topic we create an end message. + if (topic === addonUnloadTopic && message === unloadMessage) { + end(this); + } +}; + +exports.InputPort = InputPort; diff --git a/addon-sdk/source/lib/sdk/io/buffer.js b/addon-sdk/source/lib/sdk/io/buffer.js new file mode 100644 index 000000000..5ea169402 --- /dev/null +++ b/addon-sdk/source/lib/sdk/io/buffer.js @@ -0,0 +1,351 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental' +}; + +/* + * Encodings supported by TextEncoder/Decoder: + * utf-8, utf-16le, utf-16be + * http://encoding.spec.whatwg.org/#interface-textencoder + * + * Node however supports the following encodings: + * ascii, utf-8, utf-16le, usc2, base64, hex + */ + +const { Cu } = require('chrome'); +const { isNumber } = require('sdk/lang/type'); +const { TextEncoder, TextDecoder } = Cu.import('resource://gre/modules/commonjs/toolkit/loader.js', {}); + +exports.TextEncoder = TextEncoder; +exports.TextDecoder = TextDecoder; + +/** + * Use WeakMaps to work around Bug 929146, which prevents us from adding + * getters or values to typed arrays + * https://bugzilla.mozilla.org/show_bug.cgi?id=929146 + */ +const parents = new WeakMap(); +const views = new WeakMap(); + +function Buffer(subject, encoding /*, bufferLength */) { + + // Allow invocation without `new` constructor + if (!(this instanceof Buffer)) + return new Buffer(subject, encoding, arguments[2]); + + var type = typeof(subject); + + switch (type) { + case 'number': + // Create typed array of the given size if number. + try { + let buffer = new Uint8Array(subject > 0 ? Math.floor(subject) : 0); + return buffer; + } catch (e) { + if (/size and count too large/.test(e.message) || + /invalid arguments/.test(e.message)) + throw new RangeError('Could not instantiate buffer: size of buffer may be too large'); + else + throw new Error('Could not instantiate buffer'); + } + break; + case 'string': + // If string encode it and use buffer for the returned Uint8Array + // to create a local patched version that acts like node buffer. + encoding = encoding || 'utf8'; + return new Uint8Array(new TextEncoder(encoding).encode(subject).buffer); + case 'object': + // This form of the constructor uses the form of + // new Uint8Array(buffer, offset, length); + // So we can instantiate a typed array within the constructor + // to inherit the appropriate properties, where both the + // `subject` and newly instantiated buffer share the same underlying + // data structure. + if (arguments.length === 3) + return new Uint8Array(subject, encoding, arguments[2]); + // If array or alike just make a copy with a local patched prototype. + else + return new Uint8Array(subject); + default: + throw new TypeError('must start with number, buffer, array or string'); + } +} +exports.Buffer = Buffer; + +// Tests if `value` is a Buffer. +Buffer.isBuffer = value => value instanceof Buffer + +// Returns true if the encoding is a valid encoding argument & false otherwise +Buffer.isEncoding = function (encoding) { + if (!encoding) return false; + try { + new TextDecoder(encoding); + } catch(e) { + return false; + } + return true; +} + +// Gives the actual byte length of a string. encoding defaults to 'utf8'. +// This is not the same as String.prototype.length since that returns the +// number of characters in a string. +Buffer.byteLength = (value, encoding = 'utf8') => + new TextEncoder(encoding).encode(value).byteLength + +// Direct copy of the nodejs's buffer implementation: +// https://github.com/joyent/node/blob/b255f4c10a80343f9ce1cee56d0288361429e214/lib/buffer.js#L146-L177 +Buffer.concat = function(list, length) { + if (!Array.isArray(list)) + throw new TypeError('Usage: Buffer.concat(list[, length])'); + + if (typeof length === 'undefined') { + length = 0; + for (var i = 0; i < list.length; i++) + length += list[i].length; + } else { + length = ~~length; + } + + if (length < 0) + length = 0; + + if (list.length === 0) + return new Buffer(0); + else if (list.length === 1) + return list[0]; + + if (length < 0) + throw new RangeError('length is not a positive number'); + + var buffer = new Buffer(length); + var pos = 0; + for (var i = 0; i < list.length; i++) { + var buf = list[i]; + buf.copy(buffer, pos); + pos += buf.length; + } + + return buffer; +}; + +// Node buffer is very much like Uint8Array although it has bunch of methods +// that typically can be used in combination with `DataView` while preserving +// access by index. Since in SDK each module has it's own set of bult-ins it +// ok to patch ours to make it nodejs Buffer compatible. +const Uint8ArraySet = Uint8Array.prototype.set +Buffer.prototype = Uint8Array.prototype; +Object.defineProperties(Buffer.prototype, { + parent: { + get: function() { return parents.get(this, undefined); } + }, + view: { + get: function () { + let view = views.get(this, undefined); + if (view) return view; + view = new DataView(this.buffer); + views.set(this, view); + return view; + } + }, + toString: { + value: function(encoding, start, end) { + encoding = !!encoding ? (encoding + '').toLowerCase() : 'utf8'; + start = Math.max(0, ~~start); + end = Math.min(this.length, end === void(0) ? this.length : ~~end); + return new TextDecoder(encoding).decode(this.subarray(start, end)); + } + }, + toJSON: { + value: function() { + return { type: 'Buffer', data: Array.slice(this, 0) }; + } + }, + get: { + value: function(offset) { + return this[offset]; + } + }, + set: { + value: function(offset, value) { this[offset] = value; } + }, + copy: { + value: function(target, offset, start, end) { + let length = this.length; + let targetLength = target.length; + offset = isNumber(offset) ? offset : 0; + start = isNumber(start) ? start : 0; + + if (start < 0) + throw new RangeError('sourceStart is outside of valid range'); + if (end < 0) + throw new RangeError('sourceEnd is outside of valid range'); + + // If sourceStart > sourceEnd, or targetStart > targetLength, + // zero bytes copied + if (start > end || + offset > targetLength + ) + return 0; + + // If `end` is not defined, or if it is defined + // but would overflow `target`, redefine `end` + // so we can copy as much as we can + if (end - start > targetLength - offset || + end == null) { + let remainingTarget = targetLength - offset; + let remainingSource = length - start; + if (remainingSource <= remainingTarget) + end = length; + else + end = start + remainingTarget; + } + + Uint8ArraySet.call(target, this.subarray(start, end), offset); + return end - start; + } + }, + slice: { + value: function(start, end) { + let length = this.length; + start = ~~start; + end = end != null ? end : length; + + if (start < 0) { + start += length; + if (start < 0) start = 0; + } else if (start > length) + start = length; + + if (end < 0) { + end += length; + if (end < 0) end = 0; + } else if (end > length) + end = length; + + if (end < start) + end = start; + + // This instantiation uses the new Uint8Array(buffer, offset, length) version + // of construction to share the same underling data structure + let buffer = new Buffer(this.buffer, start, end - start); + + // If buffer has a value, assign its parent value to the + // buffer it shares its underlying structure with. If a slice of + // a slice, then use the root structure + if (buffer.length > 0) + parents.set(buffer, this.parent || this); + + return buffer; + } + }, + write: { + value: function(string, offset, length, encoding = 'utf8') { + // write(string, encoding); + if (typeof(offset) === 'string' && Number.isNaN(parseInt(offset))) { + [offset, length, encoding] = [0, null, offset]; + } + // write(string, offset, encoding); + else if (typeof(length) === 'string') + [length, encoding] = [null, length]; + + if (offset < 0 || offset > this.length) + throw new RangeError('offset is outside of valid range'); + + offset = ~~offset; + + // Clamp length if it would overflow buffer, or if its + // undefined + if (length == null || length + offset > this.length) + length = this.length - offset; + + let buffer = new TextEncoder(encoding).encode(string); + let result = Math.min(buffer.length, length); + if (buffer.length !== length) + buffer = buffer.subarray(0, length); + + Uint8ArraySet.call(this, buffer, offset); + return result; + } + }, + fill: { + value: function fill(value, start, end) { + let length = this.length; + value = value || 0; + start = start || 0; + end = end || length; + + if (typeof(value) === 'string') + value = value.charCodeAt(0); + if (typeof(value) !== 'number' || isNaN(value)) + throw TypeError('value is not a number'); + if (end < start) + throw new RangeError('end < start'); + + // Fill 0 bytes; we're done + if (end === start) + return 0; + if (length == 0) + return 0; + + if (start < 0 || start >= length) + throw RangeError('start out of bounds'); + + if (end < 0 || end > length) + throw RangeError('end out of bounds'); + + let index = start; + while (index < end) this[index++] = value; + } + } +}); + +// Define nodejs Buffer's getter and setter functions that just proxy +// to internal DataView's equivalent methods. + +// TODO do we need to check architecture to see if it's default big/little endian? +[['readUInt16LE', 'getUint16', true], + ['readUInt16BE', 'getUint16', false], + ['readInt16LE', 'getInt16', true], + ['readInt16BE', 'getInt16', false], + ['readUInt32LE', 'getUint32', true], + ['readUInt32BE', 'getUint32', false], + ['readInt32LE', 'getInt32', true], + ['readInt32BE', 'getInt32', false], + ['readFloatLE', 'getFloat32', true], + ['readFloatBE', 'getFloat32', false], + ['readDoubleLE', 'getFloat64', true], + ['readDoubleBE', 'getFloat64', false], + ['readUInt8', 'getUint8'], + ['readInt8', 'getInt8']].forEach(([alias, name, littleEndian]) => { + Object.defineProperty(Buffer.prototype, alias, { + value: function(offset) { + return this.view[name](offset, littleEndian); + } + }); +}); + +[['writeUInt16LE', 'setUint16', true], + ['writeUInt16BE', 'setUint16', false], + ['writeInt16LE', 'setInt16', true], + ['writeInt16BE', 'setInt16', false], + ['writeUInt32LE', 'setUint32', true], + ['writeUInt32BE', 'setUint32', false], + ['writeInt32LE', 'setInt32', true], + ['writeInt32BE', 'setInt32', false], + ['writeFloatLE', 'setFloat32', true], + ['writeFloatBE', 'setFloat32', false], + ['writeDoubleLE', 'setFloat64', true], + ['writeDoubleBE', 'setFloat64', false], + ['writeUInt8', 'setUint8'], + ['writeInt8', 'setInt8']].forEach(([alias, name, littleEndian]) => { + Object.defineProperty(Buffer.prototype, alias, { + value: function(value, offset) { + return this.view[name](offset, value, littleEndian); + } + }); +}); diff --git a/addon-sdk/source/lib/sdk/io/byte-streams.js b/addon-sdk/source/lib/sdk/io/byte-streams.js new file mode 100644 index 000000000..6afab4369 --- /dev/null +++ b/addon-sdk/source/lib/sdk/io/byte-streams.js @@ -0,0 +1,104 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +exports.ByteReader = ByteReader; +exports.ByteWriter = ByteWriter; + +const {Cc, Ci} = require("chrome"); + +// This just controls the maximum number of bytes we read in at one time. +const BUFFER_BYTE_LEN = 0x8000; + +function ByteReader(inputStream) { + const self = this; + + let stream = Cc["@mozilla.org/binaryinputstream;1"]. + createInstance(Ci.nsIBinaryInputStream); + stream.setInputStream(inputStream); + + let manager = new StreamManager(this, stream); + + this.read = function ByteReader_read(numBytes) { + manager.ensureOpened(); + if (typeof(numBytes) !== "number") + numBytes = Infinity; + + let data = ""; + let read = 0; + try { + while (true) { + let avail = stream.available(); + let toRead = Math.min(numBytes - read, avail, BUFFER_BYTE_LEN); + if (toRead <= 0) + break; + data += stream.readBytes(toRead); + read += toRead; + } + } + catch (err) { + throw new Error("Error reading from stream: " + err); + } + + return data; + }; +} + +function ByteWriter(outputStream) { + const self = this; + + let stream = Cc["@mozilla.org/binaryoutputstream;1"]. + createInstance(Ci.nsIBinaryOutputStream); + stream.setOutputStream(outputStream); + + let manager = new StreamManager(this, stream); + + this.write = function ByteWriter_write(str) { + manager.ensureOpened(); + try { + stream.writeBytes(str, str.length); + } + catch (err) { + throw new Error("Error writing to stream: " + err); + } + }; +} + + +// This manages the lifetime of stream, a ByteReader or ByteWriter. It defines +// closed and close() on stream and registers an unload listener that closes +// rawStream if it's still opened. It also provides ensureOpened(), which +// throws an exception if the stream is closed. +function StreamManager(stream, rawStream) { + const self = this; + this.rawStream = rawStream; + this.opened = true; + + stream.__defineGetter__("closed", function stream_closed() { + return !self.opened; + }); + + stream.close = function stream_close() { + self.ensureOpened(); + self.unload(); + }; + + require("../system/unload").ensure(this); +} + +StreamManager.prototype = { + ensureOpened: function StreamManager_ensureOpened() { + if (!this.opened) + throw new Error("The stream is closed and cannot be used."); + }, + unload: function StreamManager_unload() { + this.rawStream.close(); + this.opened = false; + } +}; diff --git a/addon-sdk/source/lib/sdk/io/file.js b/addon-sdk/source/lib/sdk/io/file.js new file mode 100644 index 000000000..47467df87 --- /dev/null +++ b/addon-sdk/source/lib/sdk/io/file.js @@ -0,0 +1,196 @@ +/* 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"; + +module.metadata = { + "stability": "deprecated" +}; + +const {Cc,Ci,Cr} = require("chrome"); +const byteStreams = require("./byte-streams"); +const textStreams = require("./text-streams"); + +// Flags passed when opening a file. See nsprpub/pr/include/prio.h. +const OPEN_FLAGS = { + RDONLY: parseInt("0x01"), + WRONLY: parseInt("0x02"), + CREATE_FILE: parseInt("0x08"), + APPEND: parseInt("0x10"), + TRUNCATE: parseInt("0x20"), + EXCL: parseInt("0x80") +}; + +var dirsvc = Cc["@mozilla.org/file/directory_service;1"] + .getService(Ci.nsIProperties); + +function MozFile(path) { + var file = Cc['@mozilla.org/file/local;1'] + .createInstance(Ci.nsILocalFile); + file.initWithPath(path); + return file; +} + +function ensureReadable(file) { + if (!file.isReadable()) + throw new Error("path is not readable: " + file.path); +} + +function ensureDir(file) { + ensureExists(file); + if (!file.isDirectory()) + throw new Error("path is not a directory: " + file.path); +} + +function ensureFile(file) { + ensureExists(file); + if (!file.isFile()) + throw new Error("path is not a file: " + file.path); +} + +function ensureExists(file) { + if (!file.exists()) + throw friendlyError(Cr.NS_ERROR_FILE_NOT_FOUND, file.path); +} + +function friendlyError(errOrResult, filename) { + var isResult = typeof(errOrResult) === "number"; + var result = isResult ? errOrResult : errOrResult.result; + switch (result) { + case Cr.NS_ERROR_FILE_NOT_FOUND: + return new Error("path does not exist: " + filename); + } + return isResult ? new Error("XPCOM error code: " + errOrResult) : errOrResult; +} + +exports.exists = function exists(filename) { + return MozFile(filename).exists(); +}; + +exports.isFile = function isFile(filename) { + return MozFile(filename).isFile(); +}; + +exports.read = function read(filename, mode) { + if (typeof(mode) !== "string") + mode = ""; + + // Ensure mode is read-only. + mode = /b/.test(mode) ? "b" : ""; + + var stream = exports.open(filename, mode); + try { + var str = stream.read(); + } + finally { + stream.close(); + } + + return str; +}; + +exports.join = function join(base) { + if (arguments.length < 2) + throw new Error("need at least 2 args"); + base = MozFile(base); + for (var i = 1; i < arguments.length; i++) + base.append(arguments[i]); + return base.path; +}; + +exports.dirname = function dirname(path) { + var parent = MozFile(path).parent; + return parent ? parent.path : ""; +}; + +exports.basename = function basename(path) { + var leafName = MozFile(path).leafName; + + // On Windows, leafName when the path is a volume letter and colon ("c:") is + // the path itself. But such a path has no basename, so we want the empty + // string. + return leafName == path ? "" : leafName; +}; + +exports.list = function list(path) { + var file = MozFile(path); + ensureDir(file); + ensureReadable(file); + + var entries = file.directoryEntries; + var entryNames = []; + while(entries.hasMoreElements()) { + var entry = entries.getNext(); + entry.QueryInterface(Ci.nsIFile); + entryNames.push(entry.leafName); + } + return entryNames; +}; + +exports.open = function open(filename, mode) { + var file = MozFile(filename); + if (typeof(mode) !== "string") + mode = ""; + + // File opened for write only. + if (/w/.test(mode)) { + if (file.exists()) + ensureFile(file); + var stream = Cc['@mozilla.org/network/file-output-stream;1']. + createInstance(Ci.nsIFileOutputStream); + var openFlags = OPEN_FLAGS.WRONLY | + OPEN_FLAGS.CREATE_FILE | + OPEN_FLAGS.TRUNCATE; + var permFlags = 0o644; // u+rw go+r + try { + stream.init(file, openFlags, permFlags, 0); + } + catch (err) { + throw friendlyError(err, filename); + } + return /b/.test(mode) ? + new byteStreams.ByteWriter(stream) : + new textStreams.TextWriter(stream); + } + + // File opened for read only, the default. + ensureFile(file); + stream = Cc['@mozilla.org/network/file-input-stream;1']. + createInstance(Ci.nsIFileInputStream); + try { + stream.init(file, OPEN_FLAGS.RDONLY, 0, 0); + } + catch (err) { + throw friendlyError(err, filename); + } + return /b/.test(mode) ? + new byteStreams.ByteReader(stream) : + new textStreams.TextReader(stream); +}; + +exports.remove = function remove(path) { + var file = MozFile(path); + ensureFile(file); + file.remove(false); +}; + +exports.mkpath = function mkpath(path) { + var file = MozFile(path); + if (!file.exists()) + file.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); // u+rwx go+rx + else if (!file.isDirectory()) + throw new Error("The path already exists and is not a directory: " + path); +}; + +exports.rmdir = function rmdir(path) { + var file = MozFile(path); + ensureDir(file); + try { + file.remove(false); + } + catch (err) { + // Bug 566950 explains why we're not catching a specific exception here. + throw new Error("The directory is not empty: " + path); + } +}; diff --git a/addon-sdk/source/lib/sdk/io/fs.js b/addon-sdk/source/lib/sdk/io/fs.js new file mode 100644 index 000000000..860a884a5 --- /dev/null +++ b/addon-sdk/source/lib/sdk/io/fs.js @@ -0,0 +1,984 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Cc, Ci, CC } = require("chrome"); + +const { setTimeout } = require("../timers"); +const { Stream, InputStream, OutputStream } = require("./stream"); +const { emit, on } = require("../event/core"); +const { Buffer } = require("./buffer"); +const { ns } = require("../core/namespace"); +const { Class } = require("../core/heritage"); + + +const nsILocalFile = CC("@mozilla.org/file/local;1", "nsILocalFile", + "initWithPath"); +const FileOutputStream = CC("@mozilla.org/network/file-output-stream;1", + "nsIFileOutputStream", "init"); +const FileInputStream = CC("@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", "init"); +const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", "setInputStream"); +const BinaryOutputStream = CC("@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", "setOutputStream"); +const StreamPump = CC("@mozilla.org/network/input-stream-pump;1", + "nsIInputStreamPump", "init"); + +const { createOutputTransport, createInputTransport } = + Cc["@mozilla.org/network/stream-transport-service;1"]. + getService(Ci.nsIStreamTransportService); + +const { OPEN_UNBUFFERED } = Ci.nsITransport; + + +const { REOPEN_ON_REWIND, DEFER_OPEN } = Ci.nsIFileInputStream; +const { DIRECTORY_TYPE, NORMAL_FILE_TYPE } = Ci.nsIFile; +const { NS_SEEK_SET, NS_SEEK_CUR, NS_SEEK_END } = Ci.nsISeekableStream; + +const FILE_PERMISSION = 0o666; +const PR_UINT32_MAX = 0xfffffff; +// Values taken from: +// http://mxr.mozilla.org/mozilla-central/source/nsprpub/pr/include/prio.h#615 +const PR_RDONLY = 0x01; +const PR_WRONLY = 0x02; +const PR_RDWR = 0x04; +const PR_CREATE_FILE = 0x08; +const PR_APPEND = 0x10; +const PR_TRUNCATE = 0x20; +const PR_SYNC = 0x40; +const PR_EXCL = 0x80; + +const FLAGS = { + "r": PR_RDONLY, + "r+": PR_RDWR, + "w": PR_CREATE_FILE | PR_TRUNCATE | PR_WRONLY, + "w+": PR_CREATE_FILE | PR_TRUNCATE | PR_RDWR, + "a": PR_APPEND | PR_CREATE_FILE | PR_WRONLY, + "a+": PR_APPEND | PR_CREATE_FILE | PR_RDWR +}; + +function accessor() { + let map = new WeakMap(); + return function(fd, value) { + if (value === null) map.delete(fd); + if (value !== undefined) map.set(fd, value); + return map.get(fd); + } +} + +var nsIFile = accessor(); +var nsIFileInputStream = accessor(); +var nsIFileOutputStream = accessor(); +var nsIBinaryInputStream = accessor(); +var nsIBinaryOutputStream = accessor(); + +// Just a contstant object used to signal that all of the file +// needs to be read. +const ALL = new String("Read all of the file"); + +function isWritable(mode) { + return !!(mode & PR_WRONLY || mode & PR_RDWR); +} +function isReadable(mode) { + return !!(mode & PR_RDONLY || mode & PR_RDWR); +} + +function isString(value) { + return typeof(value) === "string"; +} +function isFunction(value) { + return typeof(value) === "function"; +} + +function toArray(enumerator) { + let value = []; + while(enumerator.hasMoreElements()) + value.push(enumerator.getNext()) + return value +} + +function getFileName(file) { + return file.QueryInterface(Ci.nsIFile).leafName; +} + + +function remove(path, recursive) { + let fd = new nsILocalFile(path) + if (fd.exists()) { + fd.remove(recursive || false); + } + else { + throw FSError("remove", "ENOENT", 34, path); + } +} + +/** + * Utility function to convert either an octal number or string + * into an octal number + * 0777 => 0o777 + * "0644" => 0o644 + */ +function Mode(mode, fallback) { + return isString(mode) ? parseInt(mode, 8) : mode || fallback; +} +function Flags(flag) { + return !isString(flag) ? flag : + FLAGS[flag] || Error("Unknown file open flag: " + flag); +} + + +function FSError(op, code, errno, path, file, line) { + let error = Error(code + ", " + op + " " + path, file, line); + error.code = code; + error.path = path; + error.errno = errno; + return error; +} + +const ReadStream = Class({ + extends: InputStream, + initialize: function initialize(path, options) { + this.position = -1; + this.length = -1; + this.flags = "r"; + this.mode = FILE_PERMISSION; + this.bufferSize = 64 * 1024; + + options = options || {}; + + if ("flags" in options && options.flags) + this.flags = options.flags; + if ("bufferSize" in options && options.bufferSize) + this.bufferSize = options.bufferSize; + if ("length" in options && options.length) + this.length = options.length; + if ("position" in options && options.position !== undefined) + this.position = options.position; + + let { flags, mode, position, length } = this; + let fd = isString(path) ? openSync(path, flags, mode) : path; + this.fd = fd; + + let input = nsIFileInputStream(fd); + // Setting a stream position, unless it"s `-1` which means current position. + if (position >= 0) + input.QueryInterface(Ci.nsISeekableStream).seek(NS_SEEK_SET, position); + // We use `nsIStreamTransportService` service to transform blocking + // file input stream into a fully asynchronous stream that can be written + // without blocking the main thread. + let transport = createInputTransport(input, position, length, false); + // Open an input stream on a transport. We don"t pass flags to guarantee + // non-blocking stream semantics. Also we use defaults for segment size & + // count. + InputStream.prototype.initialize.call(this, { + asyncInputStream: transport.openInputStream(null, 0, 0) + }); + + // Close file descriptor on end and destroy the stream. + on(this, "end", _ => { + this.destroy(); + emit(this, "close"); + }); + + this.read(); + }, + destroy: function() { + closeSync(this.fd); + InputStream.prototype.destroy.call(this); + } +}); +exports.ReadStream = ReadStream; +exports.createReadStream = function createReadStream(path, options) { + return new ReadStream(path, options); +}; + +const WriteStream = Class({ + extends: OutputStream, + initialize: function initialize(path, options) { + this.drainable = true; + this.flags = "w"; + this.position = -1; + this.mode = FILE_PERMISSION; + + options = options || {}; + + if ("flags" in options && options.flags) + this.flags = options.flags; + if ("mode" in options && options.mode) + this.mode = options.mode; + if ("position" in options && options.position !== undefined) + this.position = options.position; + + let { position, flags, mode } = this; + // If pass was passed we create a file descriptor out of it. Otherwise + // we just use given file descriptor. + let fd = isString(path) ? openSync(path, flags, mode) : path; + this.fd = fd; + + let output = nsIFileOutputStream(fd); + // Setting a stream position, unless it"s `-1` which means current position. + if (position >= 0) + output.QueryInterface(Ci.nsISeekableStream).seek(NS_SEEK_SET, position); + // We use `nsIStreamTransportService` service to transform blocking + // file output stream into a fully asynchronous stream that can be written + // without blocking the main thread. + let transport = createOutputTransport(output, position, -1, false); + // Open an output stream on a transport. We don"t pass flags to guarantee + // non-blocking stream semantics. Also we use defaults for segment size & + // count. + OutputStream.prototype.initialize.call(this, { + asyncOutputStream: transport.openOutputStream(OPEN_UNBUFFERED, 0, 0), + output: output + }); + + // For write streams "finish" basically means close. + on(this, "finish", _ => { + this.destroy(); + emit(this, "close"); + }); + }, + destroy: function() { + OutputStream.prototype.destroy.call(this); + closeSync(this.fd); + } +}); +exports.WriteStream = WriteStream; +exports.createWriteStream = function createWriteStream(path, options) { + return new WriteStream(path, options); +}; + +const Stats = Class({ + initialize: function initialize(path) { + let file = new nsILocalFile(path); + if (!file.exists()) throw FSError("stat", "ENOENT", 34, path); + nsIFile(this, file); + }, + isDirectory: function() { + return nsIFile(this).isDirectory(); + }, + isFile: function() { + return nsIFile(this).isFile(); + }, + isSymbolicLink: function() { + return nsIFile(this).isSymlink(); + }, + get mode() { + return nsIFile(this).permissions; + }, + get size() { + return nsIFile(this).fileSize; + }, + get mtime() { + return nsIFile(this).lastModifiedTime; + }, + isBlockDevice: function() { + return nsIFile(this).isSpecial(); + }, + isCharacterDevice: function() { + return nsIFile(this).isSpecial(); + }, + isFIFO: function() { + return nsIFile(this).isSpecial(); + }, + isSocket: function() { + return nsIFile(this).isSpecial(); + }, + // non standard + get exists() { + return nsIFile(this).exists(); + }, + get hidden() { + return nsIFile(this).isHidden(); + }, + get writable() { + return nsIFile(this).isWritable(); + }, + get readable() { + return nsIFile(this).isReadable(); + } +}); +exports.Stats = Stats; + +const LStats = Class({ + extends: Stats, + get size() { + return this.isSymbolicLink() ? nsIFile(this).fileSizeOfLink : + nsIFile(this).fileSize; + }, + get mtime() { + return this.isSymbolicLink() ? nsIFile(this).lastModifiedTimeOfLink : + nsIFile(this).lastModifiedTime; + }, + // non standard + get permissions() { + return this.isSymbolicLink() ? nsIFile(this).permissionsOfLink : + nsIFile(this).permissions; + } +}); + +const FStat = Class({ + extends: Stats, + initialize: function initialize(fd) { + nsIFile(this, nsIFile(fd)); + } +}); + +function noop() {} +function Async(wrapped) { + return function (path, callback) { + let args = Array.slice(arguments); + callback = args.pop(); + // If node is not given a callback argument + // it just does not calls it. + if (typeof(callback) !== "function") { + args.push(callback); + callback = noop; + } + setTimeout(function() { + try { + var result = wrapped.apply(this, args); + if (result === undefined) callback(null); + else callback(null, result); + } catch (error) { + callback(error); + } + }, 0); + } +} + + +/** + * Synchronous rename(2) + */ +function renameSync(oldPath, newPath) { + let source = new nsILocalFile(oldPath); + let target = new nsILocalFile(newPath); + if (!source.exists()) throw FSError("rename", "ENOENT", 34, oldPath); + return source.moveTo(target.parent, target.leafName); +}; +exports.renameSync = renameSync; + +/** + * Asynchronous rename(2). No arguments other than a possible exception are + * given to the completion callback. + */ +var rename = Async(renameSync); +exports.rename = rename; + +/** + * Test whether or not the given path exists by checking with the file system. + */ +function existsSync(path) { + return new nsILocalFile(path).exists(); +} +exports.existsSync = existsSync; + +var exists = Async(existsSync); +exports.exists = exists; + +/** + * Synchronous ftruncate(2). + */ +function truncateSync(path, length) { + let fd = openSync(path, "w"); + ftruncateSync(fd, length); + closeSync(fd); +} +exports.truncateSync = truncateSync; + +/** + * Asynchronous ftruncate(2). No arguments other than a possible exception are + * given to the completion callback. + */ +function truncate(path, length, callback) { + open(path, "w", function(error, fd) { + if (error) return callback(error); + ftruncate(fd, length, function(error) { + if (error) { + closeSync(fd); + callback(error); + } + else { + close(fd, callback); + } + }); + }); +} +exports.truncate = truncate; + +function ftruncate(fd, length, callback) { + write(fd, new Buffer(length), 0, length, 0, function(error) { + callback(error); + }); +} +exports.ftruncate = ftruncate; + +function ftruncateSync(fd, length = 0) { + writeSync(fd, new Buffer(length), 0, length, 0); +} +exports.ftruncateSync = ftruncateSync; + +function chownSync(path, uid, gid) { + throw Error("Not implemented yet!!"); +} +exports.chownSync = chownSync; + +var chown = Async(chownSync); +exports.chown = chown; + +function lchownSync(path, uid, gid) { + throw Error("Not implemented yet!!"); +} +exports.lchownSync = chownSync; + +var lchown = Async(lchown); +exports.lchown = lchown; + +/** + * Synchronous chmod(2). + */ +function chmodSync (path, mode) { + let file; + try { + file = new nsILocalFile(path); + } catch(e) { + throw FSError("chmod", "ENOENT", 34, path); + } + + file.permissions = Mode(mode); +} +exports.chmodSync = chmodSync; +/** + * Asynchronous chmod(2). No arguments other than a possible exception are + * given to the completion callback. + */ +var chmod = Async(chmodSync); +exports.chmod = chmod; + +/** + * Synchronous chmod(2). + */ +function fchmodSync(fd, mode) { + throw Error("Not implemented yet!!"); +}; +exports.fchmodSync = fchmodSync; +/** + * Asynchronous chmod(2). No arguments other than a possible exception are + * given to the completion callback. + */ +var fchmod = Async(fchmodSync); +exports.fchmod = fchmod; + + +/** + * Synchronous stat(2). Returns an instance of `fs.Stats` + */ +function statSync(path) { + return new Stats(path); +}; +exports.statSync = statSync; + +/** + * Asynchronous stat(2). The callback gets two arguments (err, stats) where + * stats is a `fs.Stats` object. It looks like this: + */ +var stat = Async(statSync); +exports.stat = stat; + +/** + * Synchronous lstat(2). Returns an instance of `fs.Stats`. + */ +function lstatSync(path) { + return new LStats(path); +}; +exports.lstatSync = lstatSync; + +/** + * Asynchronous lstat(2). The callback gets two arguments (err, stats) where + * stats is a fs.Stats object. lstat() is identical to stat(), except that if + * path is a symbolic link, then the link itself is stat-ed, not the file that + * it refers to. + */ +var lstat = Async(lstatSync); +exports.lstat = lstat; + +/** + * Synchronous fstat(2). Returns an instance of `fs.Stats`. + */ +function fstatSync(fd) { + return new FStat(fd); +}; +exports.fstatSync = fstatSync; + +/** + * Asynchronous fstat(2). The callback gets two arguments (err, stats) where + * stats is a fs.Stats object. + */ +var fstat = Async(fstatSync); +exports.fstat = fstat; + +/** + * Synchronous link(2). + */ +function linkSync(source, target) { + throw Error("Not implemented yet!!"); +}; +exports.linkSync = linkSync; + +/** + * Asynchronous link(2). No arguments other than a possible exception are given + * to the completion callback. + */ +var link = Async(linkSync); +exports.link = link; + +/** + * Synchronous symlink(2). + */ +function symlinkSync(source, target) { + throw Error("Not implemented yet!!"); +}; +exports.symlinkSync = symlinkSync; + +/** + * Asynchronous symlink(2). No arguments other than a possible exception are + * given to the completion callback. + */ +var symlink = Async(symlinkSync); +exports.symlink = symlink; + +/** + * Synchronous readlink(2). Returns the resolved path. + */ +function readlinkSync(path) { + return new nsILocalFile(path).target; +}; +exports.readlinkSync = readlinkSync; + +/** + * Asynchronous readlink(2). The callback gets two arguments + * `(error, resolvedPath)`. + */ +var readlink = Async(readlinkSync); +exports.readlink = readlink; + +/** + * Synchronous realpath(2). Returns the resolved path. + */ +function realpathSync(path) { + return new nsILocalFile(path).path; +}; +exports.realpathSync = realpathSync; + +/** + * Asynchronous realpath(2). The callback gets two arguments + * `(err, resolvedPath)`. + */ +var realpath = Async(realpathSync); +exports.realpath = realpath; + +/** + * Synchronous unlink(2). + */ +var unlinkSync = remove; +exports.unlinkSync = unlinkSync; + +/** + * Asynchronous unlink(2). No arguments other than a possible exception are + * given to the completion callback. + */ +var unlink = Async(remove); +exports.unlink = unlink; + +/** + * Synchronous rmdir(2). + */ +var rmdirSync = remove; +exports.rmdirSync = rmdirSync; + +/** + * Asynchronous rmdir(2). No arguments other than a possible exception are + * given to the completion callback. + */ +var rmdir = Async(rmdirSync); +exports.rmdir = rmdir; + +/** + * Synchronous mkdir(2). + */ +function mkdirSync(path, mode) { + try { + return nsILocalFile(path).create(DIRECTORY_TYPE, Mode(mode)); + } catch (error) { + // Adjust exception thorw to match ones thrown by node. + if (error.name === "NS_ERROR_FILE_ALREADY_EXISTS") { + let { fileName, lineNumber } = error; + error = FSError("mkdir", "EEXIST", 47, path, fileName, lineNumber); + } + throw error; + } +}; +exports.mkdirSync = mkdirSync; + +/** + * Asynchronous mkdir(2). No arguments other than a possible exception are + * given to the completion callback. + */ +var mkdir = Async(mkdirSync); +exports.mkdir = mkdir; + +/** + * Synchronous readdir(3). Returns an array of filenames excluding `"."` and + * `".."`. + */ +function readdirSync(path) { + try { + return toArray(new nsILocalFile(path).directoryEntries).map(getFileName); + } + catch (error) { + // Adjust exception thorw to match ones thrown by node. + if (error.name === "NS_ERROR_FILE_TARGET_DOES_NOT_EXIST" || + error.name === "NS_ERROR_FILE_NOT_FOUND") + { + let { fileName, lineNumber } = error; + error = FSError("readdir", "ENOENT", 34, path, fileName, lineNumber); + } + throw error; + } +}; +exports.readdirSync = readdirSync; + +/** + * Asynchronous readdir(3). Reads the contents of a directory. The callback + * gets two arguments `(error, files)` where `files` is an array of the names + * of the files in the directory excluding `"."` and `".."`. + */ +var readdir = Async(readdirSync); +exports.readdir = readdir; + +/** + * Synchronous close(2). + */ + function closeSync(fd) { + let input = nsIFileInputStream(fd); + let output = nsIFileOutputStream(fd); + + // Closing input stream and removing reference. + if (input) input.close(); + // Closing output stream and removing reference. + if (output) output.close(); + + nsIFile(fd, null); + nsIFileInputStream(fd, null); + nsIFileOutputStream(fd, null); + nsIBinaryInputStream(fd, null); + nsIBinaryOutputStream(fd, null); +}; +exports.closeSync = closeSync; +/** + * Asynchronous close(2). No arguments other than a possible exception are + * given to the completion callback. + */ +var close = Async(closeSync); +exports.close = close; + +/** + * Synchronous open(2). + */ +function openSync(aPath, aFlag, aMode) { + let [ fd, flags, mode, file ] = + [ { path: aPath }, Flags(aFlag), Mode(aMode), nsILocalFile(aPath) ]; + + nsIFile(fd, file); + + // If trying to open file for just read that does not exists + // need to throw exception as node does. + if (!file.exists() && !isWritable(flags)) + throw FSError("open", "ENOENT", 34, aPath); + + // If we want to open file in read mode we initialize input stream. + if (isReadable(flags)) { + let input = FileInputStream(file, flags, mode, DEFER_OPEN); + nsIFileInputStream(fd, input); + } + + // If we want to open file in write mode we initialize output stream for it. + if (isWritable(flags)) { + let output = FileOutputStream(file, flags, mode, DEFER_OPEN); + nsIFileOutputStream(fd, output); + } + + return fd; +} +exports.openSync = openSync; +/** + * Asynchronous file open. See open(2). Flags can be + * `"r", "r+", "w", "w+", "a"`, or `"a+"`. mode defaults to `0666`. + * The callback gets two arguments `(error, fd). + */ +var open = Async(openSync); +exports.open = open; + +/** + * Synchronous version of buffer-based fs.write(). Returns the number of bytes + * written. + */ +function writeSync(fd, buffer, offset, length, position) { + if (length + offset > buffer.length) { + throw Error("Length is extends beyond buffer"); + } + else if (length + offset !== buffer.length) { + buffer = buffer.slice(offset, offset + length); + } + + let output = BinaryOutputStream(nsIFileOutputStream(fd)); + nsIBinaryOutputStream(fd, output); + // We write content as a byte array as this will avoid any transcoding + // if content was a buffer. + output.writeByteArray(buffer.valueOf(), buffer.length); + output.flush(); +}; +exports.writeSync = writeSync; + +/** + * Write buffer to the file specified by fd. + * + * `offset` and `length` determine the part of the buffer to be written. + * + * `position` refers to the offset from the beginning of the file where this + * data should be written. If `position` is `null`, the data will be written + * at the current position. See pwrite(2). + * + * The callback will be given three arguments `(error, written, buffer)` where + * written specifies how many bytes were written into buffer. + * + * Note that it is unsafe to use `fs.write` multiple times on the same file + * without waiting for the callback. + */ +function write(fd, buffer, offset, length, position, callback) { + if (!Buffer.isBuffer(buffer)) { + // (fd, data, position, encoding, callback) + let encoding = null; + [ position, encoding, callback ] = Array.slice(arguments, 1); + buffer = new Buffer(String(buffer), encoding); + offset = 0; + } else if (length + offset > buffer.length) { + throw Error("Length is extends beyond buffer"); + } else if (length + offset !== buffer.length) { + buffer = buffer.slice(offset, offset + length); + } + + let writeStream = new WriteStream(fd, { position: position, + length: length }); + writeStream.on("error", callback); + writeStream.write(buffer, function onEnd() { + writeStream.destroy(); + if (callback) + callback(null, buffer.length, buffer); + }); +}; +exports.write = write; + +/** + * Synchronous version of string-based fs.read. Returns the number of + * bytes read. + */ +function readSync(fd, buffer, offset, length, position) { + let input = nsIFileInputStream(fd); + // Setting a stream position, unless it"s `-1` which means current position. + if (position >= 0) + input.QueryInterface(Ci.nsISeekableStream).seek(NS_SEEK_SET, position); + // We use `nsIStreamTransportService` service to transform blocking + // file input stream into a fully asynchronous stream that can be written + // without blocking the main thread. + let binaryInputStream = BinaryInputStream(input); + let count = length === ALL ? binaryInputStream.available() : length; + if (offset === 0) binaryInputStream.readArrayBuffer(count, buffer.buffer); + else { + let chunk = new Buffer(count); + binaryInputStream.readArrayBuffer(count, chunk.buffer); + chunk.copy(buffer, offset); + } + + return buffer.slice(offset, offset + count); +}; +exports.readSync = readSync; + +/** + * Read data from the file specified by `fd`. + * + * `buffer` is the buffer that the data will be written to. + * `offset` is offset within the buffer where writing will start. + * + * `length` is an integer specifying the number of bytes to read. + * + * `position` is an integer specifying where to begin reading from in the file. + * If `position` is `null`, data will be read from the current file position. + * + * The callback is given the three arguments, `(error, bytesRead, buffer)`. + */ +function read(fd, buffer, offset, length, position, callback) { + let bytesRead = 0; + let readStream = new ReadStream(fd, { position: position, length: length }); + readStream.on("data", function onData(data) { + data.copy(buffer, offset + bytesRead); + bytesRead += data.length; + }); + readStream.on("end", function onEnd() { + callback(null, bytesRead, buffer); + readStream.destroy(); + }); +}; +exports.read = read; + +/** + * Asynchronously reads the entire contents of a file. + * The callback is passed two arguments `(error, data)`, where data is the + * contents of the file. + */ +function readFile(path, encoding, callback) { + if (isFunction(encoding)) { + callback = encoding + encoding = null + } + + let buffer = null; + try { + let readStream = new ReadStream(path); + readStream.on("data", function(data) { + if (!buffer) buffer = data; + else buffer = Buffer.concat([buffer, data], 2); + }); + readStream.on("error", function onError(error) { + callback(error); + }); + readStream.on("end", function onEnd() { + // Note: Need to destroy before invoking a callback + // so that file descriptor is released. + readStream.destroy(); + callback(null, buffer); + }); + } + catch (error) { + setTimeout(callback, 0, error); + } +}; +exports.readFile = readFile; + +/** + * Synchronous version of `fs.readFile`. Returns the contents of the path. + * If encoding is specified then this function returns a string. + * Otherwise it returns a buffer. + */ +function readFileSync(path, encoding) { + let fd = openSync(path, "r"); + let size = fstatSync(fd).size; + let buffer = new Buffer(size); + try { + readSync(fd, buffer, 0, ALL, 0); + } + finally { + closeSync(fd); + } + return buffer; +}; +exports.readFileSync = readFileSync; + +/** + * Asynchronously writes data to a file, replacing the file if it already + * exists. data can be a string or a buffer. + */ +function writeFile(path, content, encoding, callback) { + if (!isString(path)) + throw new TypeError('path must be a string'); + + try { + if (isFunction(encoding)) { + callback = encoding + encoding = null + } + if (isString(content)) + content = new Buffer(content, encoding); + + let writeStream = new WriteStream(path); + let error = null; + + writeStream.end(content, function() { + writeStream.destroy(); + callback(error); + }); + + writeStream.on("error", function onError(reason) { + error = reason; + writeStream.destroy(); + }); + } catch (error) { + callback(error); + } +}; +exports.writeFile = writeFile; + +/** + * The synchronous version of `fs.writeFile`. + */ +function writeFileSync(filename, data, encoding) { + // TODO: Implement this in bug 1148209 https://bugzilla.mozilla.org/show_bug.cgi?id=1148209 + throw Error("Not implemented"); +}; +exports.writeFileSync = writeFileSync; + + +function utimesSync(path, atime, mtime) { + throw Error("Not implemented"); +} +exports.utimesSync = utimesSync; + +var utimes = Async(utimesSync); +exports.utimes = utimes; + +function futimesSync(fd, atime, mtime, callback) { + throw Error("Not implemented"); +} +exports.futimesSync = futimesSync; + +var futimes = Async(futimesSync); +exports.futimes = futimes; + +function fsyncSync(fd, atime, mtime, callback) { + throw Error("Not implemented"); +} +exports.fsyncSync = fsyncSync; + +var fsync = Async(fsyncSync); +exports.fsync = fsync; + + +/** + * Watch for changes on filename. The callback listener will be called each + * time the file is accessed. + * + * The second argument is optional. The options if provided should be an object + * containing two members a boolean, persistent, and interval, a polling value + * in milliseconds. The default is { persistent: true, interval: 0 }. + */ +function watchFile(path, options, listener) { + throw Error("Not implemented"); +}; +exports.watchFile = watchFile; + + +function unwatchFile(path, listener) { + throw Error("Not implemented"); +} +exports.unwatchFile = unwatchFile; + +function watch(path, options, listener) { + throw Error("Not implemented"); +} +exports.watch = watch; diff --git a/addon-sdk/source/lib/sdk/io/stream.js b/addon-sdk/source/lib/sdk/io/stream.js new file mode 100644 index 000000000..0698b8e32 --- /dev/null +++ b/addon-sdk/source/lib/sdk/io/stream.js @@ -0,0 +1,440 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { CC, Cc, Ci, Cu, Cr, components } = require("chrome"); +const { EventTarget } = require("../event/target"); +const { emit } = require("../event/core"); +const { Buffer } = require("./buffer"); +const { Class } = require("../core/heritage"); +const { setTimeout } = require("../timers"); + + +const MultiplexInputStream = CC("@mozilla.org/io/multiplex-input-stream;1", + "nsIMultiplexInputStream"); +const AsyncStreamCopier = CC("@mozilla.org/network/async-stream-copier;1", + "nsIAsyncStreamCopier", "init"); +const StringInputStream = CC("@mozilla.org/io/string-input-stream;1", + "nsIStringInputStream"); +const ArrayBufferInputStream = CC("@mozilla.org/io/arraybuffer-input-stream;1", + "nsIArrayBufferInputStream"); + +const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", "setInputStream"); +const InputStreamPump = CC("@mozilla.org/network/input-stream-pump;1", + "nsIInputStreamPump", "init"); + +const threadManager = Cc["@mozilla.org/thread-manager;1"]. + getService(Ci.nsIThreadManager); + +const eventTarget = Cc["@mozilla.org/network/stream-transport-service;1"]. + getService(Ci.nsIEventTarget); + +var isFunction = value => typeof(value) === "function" + +function accessor() { + let map = new WeakMap(); + return function(target, value) { + if (value) + map.set(target, value); + return map.get(target); + } +} + +const Stream = Class({ + extends: EventTarget, + initialize: function() { + this.readable = false; + this.writable = false; + this.encoding = null; + }, + setEncoding: function setEncoding(encoding) { + this.encoding = String(encoding).toUpperCase(); + }, + pipe: function pipe(target, options) { + let source = this; + function onData(chunk) { + if (target.writable) { + if (false === target.write(chunk)) + source.pause(); + } + } + function onDrain() { + if (source.readable) + source.resume(); + } + function onEnd() { + target.end(); + } + function onPause() { + source.pause(); + } + function onResume() { + if (source.readable) + source.resume(); + } + + function cleanup() { + source.removeListener("data", onData); + target.removeListener("drain", onDrain); + source.removeListener("end", onEnd); + + target.removeListener("pause", onPause); + target.removeListener("resume", onResume); + + source.removeListener("end", cleanup); + source.removeListener("close", cleanup); + + target.removeListener("end", cleanup); + target.removeListener("close", cleanup); + } + + if (!options || options.end !== false) + target.on("end", onEnd); + + source.on("data", onData); + target.on("drain", onDrain); + target.on("resume", onResume); + target.on("pause", onPause); + + source.on("end", cleanup); + source.on("close", cleanup); + + target.on("end", cleanup); + target.on("close", cleanup); + + emit(target, "pipe", source); + }, + pause: function pause() { + emit(this, "pause"); + }, + resume: function resume() { + emit(this, "resume"); + }, + destroySoon: function destroySoon() { + this.destroy(); + } +}); +exports.Stream = Stream; + + +var nsIStreamListener = accessor(); +var nsIInputStreamPump = accessor(); +var nsIAsyncInputStream = accessor(); +var nsIBinaryInputStream = accessor(); + +const StreamListener = Class({ + initialize: function(stream) { + this.stream = stream; + }, + + // Next three methods are part of `nsIStreamListener` interface and are + // invoked by `nsIInputStreamPump.asyncRead`. + onDataAvailable: function(request, context, input, offset, count) { + let stream = this.stream; + let buffer = new ArrayBuffer(count); + nsIBinaryInputStream(stream).readArrayBuffer(count, buffer); + emit(stream, "data", new Buffer(buffer)); + }, + + // Next two methods implement `nsIRequestObserver` interface and are invoked + // by `nsIInputStreamPump.asyncRead`. + onStartRequest: function() {}, + // Called to signify the end of an asynchronous request. We only care to + // discover errors. + onStopRequest: function(request, context, status) { + let stream = this.stream; + stream.readable = false; + if (!components.isSuccessCode(status)) + emit(stream, "error", status); + else + emit(stream, "end"); + } +}); + + +const InputStream = Class({ + extends: Stream, + readable: false, + paused: false, + initialize: function initialize(options) { + let { asyncInputStream } = options; + + this.readable = true; + + let binaryInputStream = new BinaryInputStream(asyncInputStream); + let inputStreamPump = new InputStreamPump(asyncInputStream, + -1, -1, 0, 0, false); + let streamListener = new StreamListener(this); + + nsIAsyncInputStream(this, asyncInputStream); + nsIInputStreamPump(this, inputStreamPump); + nsIBinaryInputStream(this, binaryInputStream); + nsIStreamListener(this, streamListener); + + this.asyncInputStream = asyncInputStream; + this.inputStreamPump = inputStreamPump; + this.binaryInputStream = binaryInputStream; + }, + get status() { + return nsIInputStreamPump(this).status; + }, + read: function() { + nsIInputStreamPump(this).asyncRead(nsIStreamListener(this), null); + }, + pause: function pause() { + this.paused = true; + nsIInputStreamPump(this).suspend(); + emit(this, "paused"); + }, + resume: function resume() { + this.paused = false; + if (nsIInputStreamPump(this).isPending()) { + nsIInputStreamPump(this).resume(); + emit(this, "resume"); + } + }, + close: function close() { + this.readable = false; + nsIInputStreamPump(this).cancel(Cr.NS_OK); + nsIBinaryInputStream(this).close(); + nsIAsyncInputStream(this).close(); + }, + destroy: function destroy() { + this.close(); + + nsIInputStreamPump(this); + nsIAsyncInputStream(this); + nsIBinaryInputStream(this); + nsIStreamListener(this); + } +}); +exports.InputStream = InputStream; + + + +var nsIRequestObserver = accessor(); +var nsIAsyncOutputStream = accessor(); +var nsIAsyncStreamCopier = accessor(); +var nsIMultiplexInputStream = accessor(); + +const RequestObserver = Class({ + initialize: function(stream) { + this.stream = stream; + }, + // Method is part of `nsIRequestObserver` interface that is + // invoked by `nsIAsyncStreamCopier.asyncCopy`. + onStartRequest: function() {}, + // Method is part of `nsIRequestObserver` interface that is + // invoked by `nsIAsyncStreamCopier.asyncCopy`. + onStopRequest: function(request, context, status) { + let stream = this.stream; + stream.drained = true; + + // Remove copied chunk. + let multiplexInputStream = nsIMultiplexInputStream(stream); + multiplexInputStream.removeStream(0); + + // If there was an error report. + if (!components.isSuccessCode(status)) + emit(stream, "error", status); + + // If there more chunks in queue then flush them. + else if (multiplexInputStream.count) + stream.flush(); + + // If stream is still writable notify that queue has drained. + else if (stream.writable) + emit(stream, "drain"); + + // If stream is no longer writable close it. + else { + nsIAsyncStreamCopier(stream).cancel(Cr.NS_OK); + nsIMultiplexInputStream(stream).close(); + nsIAsyncOutputStream(stream).close(); + nsIAsyncOutputStream(stream).flush(); + } + } +}); + +const OutputStreamCallback = Class({ + initialize: function(stream) { + this.stream = stream; + }, + // Method is part of `nsIOutputStreamCallback` interface that + // is invoked by `nsIAsyncOutputStream.asyncWait`. It is registered + // with `WAIT_CLOSURE_ONLY` flag that overrides the default behavior, + // causing the `onOutputStreamReady` notification to be suppressed until + // the stream becomes closed. + onOutputStreamReady: function(nsIAsyncOutputStream) { + emit(this.stream, "finish"); + } +}); + +const OutputStream = Class({ + extends: Stream, + writable: false, + drained: true, + get bufferSize() { + let multiplexInputStream = nsIMultiplexInputStream(this); + return multiplexInputStream && multiplexInputStream.available(); + }, + initialize: function initialize(options) { + let { asyncOutputStream, output } = options; + this.writable = true; + + // Ensure that `nsIAsyncOutputStream` was provided. + asyncOutputStream.QueryInterface(Ci.nsIAsyncOutputStream); + + // Create a `nsIMultiplexInputStream` and `nsIAsyncStreamCopier`. Former + // is used to queue written data chunks that `asyncStreamCopier` will + // asynchronously drain into `asyncOutputStream`. + let multiplexInputStream = MultiplexInputStream(); + let asyncStreamCopier = AsyncStreamCopier(multiplexInputStream, + output || asyncOutputStream, + eventTarget, + // nsIMultiplexInputStream + // implemnts .readSegments() + true, + // nsIOutputStream may or + // may not implemnet + // .writeSegments(). + false, + // Use default buffer size. + null, + // Should not close an input. + false, + // Should not close an output. + false); + + // Create `requestObserver` implementing `nsIRequestObserver` interface + // in the constructor that's gonna be reused across several flushes. + let requestObserver = RequestObserver(this); + + + // Create observer that implements `nsIOutputStreamCallback` and register + // using `WAIT_CLOSURE_ONLY` flag. That way it will be notfied once + // `nsIAsyncOutputStream` is closed. + asyncOutputStream.asyncWait(OutputStreamCallback(this), + asyncOutputStream.WAIT_CLOSURE_ONLY, + 0, + threadManager.currentThread); + + nsIRequestObserver(this, requestObserver); + nsIAsyncOutputStream(this, asyncOutputStream); + nsIMultiplexInputStream(this, multiplexInputStream); + nsIAsyncStreamCopier(this, asyncStreamCopier); + + this.asyncOutputStream = asyncOutputStream; + this.multiplexInputStream = multiplexInputStream; + this.asyncStreamCopier = asyncStreamCopier; + }, + write: function write(content, encoding, callback) { + if (isFunction(encoding)) { + callback = encoding; + encoding = callback; + } + + // If stream is not writable we throw an error. + if (!this.writable) throw Error("stream is not writable"); + + let chunk = null; + + // If content is not a buffer then we create one out of it. + if (Buffer.isBuffer(content)) { + chunk = new ArrayBufferInputStream(); + chunk.setData(content.buffer, 0, content.length); + } + else { + chunk = new StringInputStream(); + chunk.setData(content, content.length); + } + + if (callback) + this.once("drain", callback); + + // Queue up chunk to be copied to output sync. + nsIMultiplexInputStream(this).appendStream(chunk); + this.flush(); + + return this.drained; + }, + flush: function() { + if (this.drained) { + this.drained = false; + nsIAsyncStreamCopier(this).asyncCopy(nsIRequestObserver(this), null); + } + }, + end: function end(content, encoding, callback) { + if (isFunction(content)) { + callback = content + content = callback + } + if (isFunction(encoding)) { + callback = encoding + encoding = callback + } + + // Setting a listener to "finish" event if passed. + if (isFunction(callback)) + this.once("finish", callback); + + + if (content) + this.write(content, encoding); + this.writable = false; + + // Close `asyncOutputStream` only if output has drained. If it's + // not drained than `asyncStreamCopier` is busy writing, so let + // it finish. Note that since `this.writable` is false copier will + // close `asyncOutputStream` once output drains. + if (this.drained) + nsIAsyncOutputStream(this).close(); + }, + destroy: function destroy() { + nsIAsyncOutputStream(this).close(); + nsIAsyncOutputStream(this); + nsIMultiplexInputStream(this); + nsIAsyncStreamCopier(this); + nsIRequestObserver(this); + } +}); +exports.OutputStream = OutputStream; + +const DuplexStream = Class({ + extends: Stream, + implements: [InputStream, OutputStream], + allowHalfOpen: true, + initialize: function initialize(options) { + options = options || {}; + let { readable, writable, allowHalfOpen } = options; + + InputStream.prototype.initialize.call(this, options); + OutputStream.prototype.initialize.call(this, options); + + if (readable === false) + this.readable = false; + + if (writable === false) + this.writable = false; + + if (allowHalfOpen === false) + this.allowHalfOpen = false; + + // If in a half open state and it's disabled enforce end. + this.once("end", () => { + if (!this.allowHalfOpen && (!this.readable || !this.writable)) + this.end(); + }); + }, + destroy: function destroy(error) { + InputStream.prototype.destroy.call(this); + OutputStream.prototype.destroy.call(this); + } +}); +exports.DuplexStream = DuplexStream; diff --git a/addon-sdk/source/lib/sdk/io/text-streams.js b/addon-sdk/source/lib/sdk/io/text-streams.js new file mode 100644 index 000000000..ed4ec4972 --- /dev/null +++ b/addon-sdk/source/lib/sdk/io/text-streams.js @@ -0,0 +1,235 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Cc, Ci, Cu, components } = require("chrome"); +const { ensure } = require("../system/unload"); +const { NetUtil } = Cu.import("resource://gre/modules/NetUtil.jsm", {}); + +// NetUtil.asyncCopy() uses this buffer length, and since we call it, for best +// performance we use it, too. +const BUFFER_BYTE_LEN = 0x8000; +const PR_UINT32_MAX = 0xffffffff; +const DEFAULT_CHARSET = "UTF-8"; + + +/** + * An input stream that reads text from a backing stream using a given text + * encoding. + * + * @param inputStream + * The stream is backed by this nsIInputStream. It must already be + * opened. + * @param charset + * Text in inputStream is expected to be in this character encoding. If + * not given, "UTF-8" is assumed. See nsICharsetConverterManager.idl for + * documentation on how to determine other valid values for this. + */ +function TextReader(inputStream, charset) { + charset = checkCharset(charset); + + let stream = Cc["@mozilla.org/intl/converter-input-stream;1"]. + createInstance(Ci.nsIConverterInputStream); + stream.init(inputStream, charset, BUFFER_BYTE_LEN, + Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER); + + let manager = new StreamManager(this, stream); + + /** + * Reads a string from the stream. If the stream is closed, an exception is + * thrown. + * + * @param numChars + * The number of characters to read. If not given, the remainder of + * the stream is read. + * @return The string read. If the stream is already at EOS, returns the + * empty string. + */ + this.read = function TextReader_read(numChars) { + manager.ensureOpened(); + + let readAll = false; + if (typeof(numChars) === "number") + numChars = Math.max(numChars, 0); + else + readAll = true; + + let str = ""; + let totalRead = 0; + let chunkRead = 1; + + // Read in numChars or until EOS, whichever comes first. Note that the + // units here are characters, not bytes. + while (true) { + let chunk = {}; + let toRead = readAll ? + PR_UINT32_MAX : + Math.min(numChars - totalRead, PR_UINT32_MAX); + if (toRead <= 0 || chunkRead <= 0) + break; + + // The converter stream reads in at most BUFFER_BYTE_LEN bytes in a call + // to readString, enough to fill its byte buffer. chunkRead will be the + // number of characters encoded by the bytes in that buffer. + chunkRead = stream.readString(toRead, chunk); + str += chunk.value; + totalRead += chunkRead; + } + + return str; + }; +} +exports.TextReader = TextReader; + +/** + * A buffered output stream that writes text to a backing stream using a given + * text encoding. + * + * @param outputStream + * The stream is backed by this nsIOutputStream. It must already be + * opened. + * @param charset + * Text will be written to outputStream using this character encoding. + * If not given, "UTF-8" is assumed. See nsICharsetConverterManager.idl + * for documentation on how to determine other valid values for this. + */ +function TextWriter(outputStream, charset) { + charset = checkCharset(charset); + + let stream = outputStream; + + // Buffer outputStream if it's not already. + let ioUtils = Cc["@mozilla.org/io-util;1"].getService(Ci.nsIIOUtil); + if (!ioUtils.outputStreamIsBuffered(outputStream)) { + stream = Cc["@mozilla.org/network/buffered-output-stream;1"]. + createInstance(Ci.nsIBufferedOutputStream); + stream.init(outputStream, BUFFER_BYTE_LEN); + } + + // I'd like to use nsIConverterOutputStream. But NetUtil.asyncCopy(), which + // we use below in writeAsync(), naturally expects its sink to be an instance + // of nsIOutputStream, which nsIConverterOutputStream's only implementation is + // not. So we use uconv and manually convert all strings before writing to + // outputStream. + let uconv = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. + createInstance(Ci.nsIScriptableUnicodeConverter); + uconv.charset = charset; + + let manager = new StreamManager(this, stream); + + /** + * Flushes the backing stream's buffer. + */ + this.flush = function TextWriter_flush() { + manager.ensureOpened(); + stream.flush(); + }; + + /** + * Writes a string to the stream. If the stream is closed, an exception is + * thrown. + * + * @param str + * The string to write. + */ + this.write = function TextWriter_write(str) { + manager.ensureOpened(); + let istream = uconv.convertToInputStream(str); + let len = istream.available(); + while (len > 0) { + stream.writeFrom(istream, len); + len = istream.available(); + } + istream.close(); + }; + + /** + * Writes a string on a background thread. After the write completes, the + * backing stream's buffer is flushed, and both the stream and the backing + * stream are closed, also on the background thread. If the stream is already + * closed, an exception is thrown immediately. + * + * @param str + * The string to write. + * @param callback + * An optional function. If given, it's called as callback(error) when + * the write completes. error is an Error object or undefined if there + * was no error. Inside callback, |this| is the stream object. + */ + this.writeAsync = function TextWriter_writeAsync(str, callback) { + manager.ensureOpened(); + let istream = uconv.convertToInputStream(str); + NetUtil.asyncCopy(istream, stream, (result) => { + let err = components.isSuccessCode(result) ? undefined : + new Error("An error occured while writing to the stream: " + result); + if (err) + console.error(err); + + // asyncCopy() closes its output (and input) stream. + manager.opened = false; + + if (typeof(callback) === "function") { + try { + callback.call(this, err); + } + catch (exc) { + console.exception(exc); + } + } + }); + }; +} +exports.TextWriter = TextWriter; + +// This manages the lifetime of stream, a TextReader or TextWriter. It defines +// closed and close() on stream and registers an unload listener that closes +// rawStream if it's still opened. It also provides ensureOpened(), which +// throws an exception if the stream is closed. +function StreamManager(stream, rawStream) { + this.rawStream = rawStream; + this.opened = true; + + /** + * True iff the stream is closed. + */ + stream.__defineGetter__("closed", () => !this.opened); + + /** + * Closes both the stream and its backing stream. If the stream is already + * closed, an exception is thrown. For TextWriters, this first flushes the + * backing stream's buffer. + */ + stream.close = () => { + this.ensureOpened(); + this.unload(); + }; + + ensure(this); +} + +StreamManager.prototype = { + ensureOpened: function StreamManager_ensureOpened() { + if (!this.opened) + throw new Error("The stream is closed and cannot be used."); + }, + unload: function StreamManager_unload() { + // TextWriter.writeAsync() causes rawStream to close and therefore sets + // opened to false, so check that we're still opened. + if (this.opened) { + // Calling close() on both an nsIUnicharInputStream and + // nsIBufferedOutputStream closes their backing streams. It also forces + // nsIOutputStreams to flush first. + this.rawStream.close(); + this.opened = false; + } + } +}; + +function checkCharset(charset) { + return typeof(charset) === "string" ? charset : DEFAULT_CHARSET; +} diff --git a/addon-sdk/source/lib/sdk/keyboard/hotkeys.js b/addon-sdk/source/lib/sdk/keyboard/hotkeys.js new file mode 100644 index 000000000..a179502b8 --- /dev/null +++ b/addon-sdk/source/lib/sdk/keyboard/hotkeys.js @@ -0,0 +1,110 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { observer: keyboardObserver } = require("./observer"); +const { getKeyForCode, normalize, isFunctionKey, + MODIFIERS } = require("./utils"); + +/** + * Register a global `hotkey` that executes `listener` when the key combination + * in `hotkey` is pressed. If more then one `listener` is registered on the same + * key combination only last one will be executed. + * + * @param {string} hotkey + * Key combination in the format of 'modifier key'. + * + * Examples: + * + * "accel s" + * "meta shift i" + * "control alt d" + * + * Modifier keynames: + * + * - **shift**: The Shift key. + * - **alt**: The Alt key. On the Macintosh, this is the Option key. On + * Macintosh this can only be used in conjunction with another modifier, + * since `Alt+Letter` combinations are reserved for entering special + * characters in text. + * - **meta**: The Meta key. On the Macintosh, this is the Command key. + * - **control**: The Control key. + * - **accel**: The key used for keyboard shortcuts on the user's platform, + * which is Control on Windows and Linux, and Command on Mac. Usually, this + * would be the value you would use. + * + * @param {function} listener + * Function to execute when the `hotkey` is executed. + */ +exports.register = function register(hotkey, listener) { + hotkey = normalize(hotkey); + hotkeys[hotkey] = listener; +}; + +/** + * Unregister a global `hotkey`. If passed `listener` is not the one registered + * for the given `hotkey`, the call to this function will be ignored. + * + * @param {string} hotkey + * Key combination in the format of 'modifier key'. + * @param {function} listener + * Function that will be invoked when the `hotkey` is pressed. + */ +exports.unregister = function unregister(hotkey, listener) { + hotkey = normalize(hotkey); + if (hotkeys[hotkey] === listener) + delete hotkeys[hotkey]; +}; + +/** + * Map of hotkeys and associated functions. + */ +const hotkeys = exports.hotkeys = {}; + +keyboardObserver.on("keydown", function onKeypress(event, window) { + let key, modifiers = []; + let isChar = "isChar" in event && event.isChar; + let which = "which" in event ? event.which : null; + let keyCode = "keyCode" in event ? event.keyCode : null; + + if ("shiftKey" in event && event.shiftKey) + modifiers.push("shift"); + if ("altKey" in event && event.altKey) + modifiers.push("alt"); + if ("ctrlKey" in event && event.ctrlKey) + modifiers.push("control"); + if ("metaKey" in event && event.metaKey) + modifiers.push("meta"); + + // If it's not a printable character then we fall back to a human readable + // equivalent of one of the following constants. + // http://dxr.mozilla.org/mozilla-central/source/dom/interfaces/events/nsIDOMKeyEvent.idl + key = getKeyForCode(keyCode); + + // If only non-function (f1 - f24) key or only modifiers are pressed we don't + // have a valid combination so we return immediately (Also, sometimes + // `keyCode` may be one for the modifier which means we do not have a + // modifier). + if (!key || (!isFunctionKey(key) && !modifiers.length) || key in MODIFIERS) + return; + + let combination = normalize({ key: key, modifiers: modifiers }); + let hotkey = hotkeys[combination]; + + if (hotkey) { + try { + hotkey(); + } catch (exception) { + console.exception(exception); + } finally { + // Work around bug 582052 by preventing the (nonexistent) default action. + event.preventDefault(); + } + } +}); diff --git a/addon-sdk/source/lib/sdk/keyboard/observer.js b/addon-sdk/source/lib/sdk/keyboard/observer.js new file mode 100644 index 000000000..b8e32b95c --- /dev/null +++ b/addon-sdk/source/lib/sdk/keyboard/observer.js @@ -0,0 +1,58 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Class } = require("../core/heritage"); +const { EventTarget } = require("../event/target"); +const { emit } = require("../event/core"); +const { DOMEventAssembler } = require("../deprecated/events/assembler"); +const { browserWindowIterator } = require('../deprecated/window-utils'); +const { isBrowser } = require('../window/utils'); +const { observer: windowObserver } = require("../windows/observer"); + +// Event emitter objects used to register listeners and emit events on them +// when they occur. +const Observer = Class({ + implements: [DOMEventAssembler, EventTarget], + initialize() { + // Adding each opened window to a list of observed windows. + windowObserver.on("open", window => { + if (isBrowser(window)) + this.observe(window); + }); + + // Removing each closed window form the list of observed windows. + windowObserver.on("close", window => { + if (isBrowser(window)) + this.ignore(window); + }); + + // Making observer aware of already opened windows. + for (let window of browserWindowIterator()) { + this.observe(window); + } + }, + /** + * Events that are supported and emitted by the module. + */ + supportedEventsTypes: [ "keydown", "keyup", "keypress" ], + /** + * Function handles all the supported events on all the windows that are + * observed. Method is used to proxy events to the listeners registered on + * this event emitter. + * @param {Event} event + * Keyboard event being emitted. + */ + handleEvent(event) { + emit(this, event.type, event, event.target.ownerDocument ? event.target.ownerDocument.defaultView + : undefined); + } +}); + +exports.observer = new Observer(); diff --git a/addon-sdk/source/lib/sdk/keyboard/utils.js b/addon-sdk/source/lib/sdk/keyboard/utils.js new file mode 100644 index 000000000..1b7df4ce3 --- /dev/null +++ b/addon-sdk/source/lib/sdk/keyboard/utils.js @@ -0,0 +1,189 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci } = require("chrome"); +const runtime = require("../system/runtime"); +const { isString } = require("../lang/type"); +const array = require("../util/array"); + + +const SWP = "{{SEPARATOR}}"; +const SEPARATOR = "-" +const INVALID_COMBINATION = "Hotkey key combination must contain one or more " + + "modifiers and only one key"; + +// Map of modifier key mappings. +const MODIFIERS = exports.MODIFIERS = { + 'accel': runtime.OS === "Darwin" ? 'meta' : 'control', + 'meta': 'meta', + 'control': 'control', + 'ctrl': 'control', + 'option': 'alt', + 'command': 'meta', + 'alt': 'alt', + 'shift': 'shift' +}; + +// Hash of key:code pairs for all the chars supported by `nsIDOMKeyEvent`. +// This is just a copy of the `nsIDOMKeyEvent` hash with normalized names. +// @See: http://dxr.mozilla.org/mozilla-central/source/dom/interfaces/events/nsIDOMKeyEvent.idl +const CODES = exports.CODES = new function Codes() { + let nsIDOMKeyEvent = Ci.nsIDOMKeyEvent; + // Names that will be substituted with a shorter analogs. + let aliases = { + 'subtract': '-', + 'add': '+', + 'equals': '=', + 'slash': '/', + 'backslash': '\\', + 'openbracket': '[', + 'closebracket': ']', + 'quote': '\'', + 'backquote': '`', + 'period': '.', + 'semicolon': ';', + 'comma': ',' + }; + + // Normalizing keys and copying values to `this` object. + Object.keys(nsIDOMKeyEvent).filter(function(key) { + // Filter out only key codes. + return key.indexOf('DOM_VK') === 0; + }).map(function(key) { + // Map to key:values + return [ key, nsIDOMKeyEvent[key] ]; + }).map(function([key, value]) { + return [ key.replace('DOM_VK_', '').replace('_', '').toLowerCase(), value ]; + }).forEach(function ([ key, value ]) { + this[aliases[key] || key] = value; + }, this); +}; + +// Inverted `CODES` hash of `code:key`. +const KEYS = exports.KEYS = new function Keys() { + Object.keys(CODES).forEach(function(key) { + this[CODES[key]] = key; + }, this) +} + +exports.getKeyForCode = function getKeyForCode(code) { + return (code in KEYS) && KEYS[code]; +}; +exports.getCodeForKey = function getCodeForKey(key) { + return (key in CODES) && CODES[key]; +}; + +/** + * Utility function that takes string or JSON that defines a `hotkey` and + * returns normalized string version of it. + * @param {JSON|String} hotkey + * @param {String} [separator=" "] + * Optional string that represents separator used to concatenate keys in the + * given `hotkey`. + * @returns {String} + * @examples + * + * require("keyboard/hotkeys").normalize("b Shift accel"); + * // 'control shift b' -> on windows & linux + * // 'meta shift b' -> on mac + * require("keyboard/hotkeys").normalize("alt-d-shift", "-"); + * // 'alt shift d' + */ +var normalize = exports.normalize = function normalize(hotkey, separator) { + if (!isString(hotkey)) + hotkey = toString(hotkey, separator); + return toString(toJSON(hotkey, separator), separator); +}; + +/* + * Utility function that splits a string of characters that defines a `hotkey` + * into modifier keys and the defining key. + * @param {String} hotkey + * @param {String} [separator=" "] + * Optional string that represents separator used to concatenate keys in the + * given `hotkey`. + * @returns {JSON} + * @examples + * + * require("keyboard/hotkeys").toJSON("accel shift b"); + * // { key: 'b', modifiers: [ 'control', 'shift' ] } -> on windows & linux + * // { key: 'b', modifiers: [ 'meta', 'shift' ] } -> on mac + * + * require("keyboard/hotkeys").normalize("alt-d-shift", "-"); + * // { key: 'd', modifiers: [ 'alt', 'shift' ] } + */ +var toJSON = exports.toJSON = function toJSON(hotkey, separator) { + separator = separator || SEPARATOR; + // Since default separator is `-`, combination may take form of `alt--`. To + // avoid misbehavior we replace `--` with `-{{SEPARATOR}}` where + // `{{SEPARATOR}}` can be swapped later. + hotkey = hotkey.toLowerCase().replace(separator + separator, separator + SWP); + + let value = {}; + let modifiers = []; + let keys = hotkey.split(separator); + keys.forEach(function(name) { + // If name is `SEPARATOR` than we swap it back. + if (name === SWP) + name = separator; + if (name in MODIFIERS) { + array.add(modifiers, MODIFIERS[name]); + } else { + if (!value.key) + value.key = name; + else + throw new TypeError(INVALID_COMBINATION); + } + }); + + if (!value.key) + throw new TypeError(INVALID_COMBINATION); + + value.modifiers = modifiers.sort(); + return value; +}; + +/** + * Utility function that takes object that defines a `hotkey` and returns + * string representation of it. + * + * _Please note that this function does not validates data neither it normalizes + * it, if you are unsure that data is well formed use `normalize` function + * instead. + * + * @param {JSON} hotkey + * @param {String} [separator=" "] + * Optional string that represents separator used to concatenate keys in the + * given `hotkey`. + * @returns {String} + * @examples + * + * require("keyboard/hotkeys").toString({ + * key: 'b', + * modifiers: [ 'control', 'shift' ] + * }, '+'); + * // 'control+shift+b + * + */ +var toString = exports.toString = function toString(hotkey, separator) { + let keys = hotkey.modifiers.slice(); + keys.push(hotkey.key); + return keys.join(separator || SEPARATOR); +}; + +/** + * Utility function takes `key` name and returns `true` if it's function key + * (F1, ..., F24) and `false` if it's not. + */ +var isFunctionKey = exports.isFunctionKey = function isFunctionKey(key) { + var $ + return key[0].toLowerCase() === 'f' && + ($ = parseInt(key.substr(1)), 0 < $ && $ < 25); +}; diff --git a/addon-sdk/source/lib/sdk/l10n.js b/addon-sdk/source/lib/sdk/l10n.js new file mode 100644 index 000000000..db5a9d7b6 --- /dev/null +++ b/addon-sdk/source/lib/sdk/l10n.js @@ -0,0 +1,91 @@ +/* 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"; + +module.metadata = { + "stability": "stable" +}; + +const json = require("./l10n/json/core"); +const { get: getKey } = require("./l10n/core"); +const properties = require("./l10n/properties/core"); +const { getRulesForLocale } = require("./l10n/plural-rules"); + +// Retrieve the plural mapping function +var pluralMappingFunction = getRulesForLocale(json.language()) || + getRulesForLocale("en"); + +exports.get = function get(k) { + // For now, we only accept a "string" as first argument + // TODO: handle plural forms in gettext pattern + if (typeof k !== "string") + throw new Error("First argument of localization method should be a string"); + let n = arguments[1]; + + // Get translation from big hashmap or default to hard coded string: + let localized = getKey(k, n) || k; + + // # Simplest usecase: + // // String hard coded in source code: + // _("Hello world") + // // Identifier of a key stored in properties file + // _("helloString") + if (arguments.length <= 1) + return localized; + + let args = Array.slice(arguments); + let placeholders = [null, ...args.slice(typeof(n) === "number" ? 2 : 1)]; + + if (typeof localized == "object" && "other" in localized) { + // # Plural form: + // // Strings hard coded in source code: + // _(["One download", "%d downloads"], 10); + // // Identifier of a key stored in properties file + // _("downloadNumber", 0); + let n = arguments[1]; + + // First handle simple universal forms that may not be mandatory + // for each language, (i.e. not different than 'other' form, + // but still usefull for better phrasing) + // For example 0 in english is the same form than 'other' + // but we accept 'zero' form if specified in localization file + if (n === 0 && "zero" in localized) + localized = localized["zero"]; + else if (n === 1 && "one" in localized) + localized = localized["one"]; + else if (n === 2 && "two" in localized) + localized = localized["two"]; + else { + let pluralForm = pluralMappingFunction(n); + if (pluralForm in localized) + localized = localized[pluralForm]; + else // Fallback in case of error: missing plural form + localized = localized["other"]; + } + + // Simulate a string with one placeholder: + args = [null, n]; + } + + // # String with placeholders: + // // Strings hard coded in source code: + // _("Hello %s", username) + // // Identifier of a key stored in properties file + // _("helloString", username) + // * We supports `%1s`, `%2s`, ... pattern in order to change arguments order + // in translation. + // * In case of plural form, we has `%d` instead of `%s`. + let offset = 1; + if (placeholders.length > 1) { + args = placeholders; + } + + localized = localized.replace(/%(\d*)[sd]/g, (v, n) => { + let rv = args[n != "" ? n : offset]; + offset++; + return rv; + }); + + return localized; +} diff --git a/addon-sdk/source/lib/sdk/l10n/core.js b/addon-sdk/source/lib/sdk/l10n/core.js new file mode 100644 index 000000000..2f8f84c04 --- /dev/null +++ b/addon-sdk/source/lib/sdk/l10n/core.js @@ -0,0 +1,9 @@ +/* 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 json = require("./json/core"); +const properties = require("./properties/core"); + +exports.get = json.usingJSON ? json.get : properties.get; diff --git a/addon-sdk/source/lib/sdk/l10n/html.js b/addon-sdk/source/lib/sdk/l10n/html.js new file mode 100644 index 000000000..fa2cf9cf0 --- /dev/null +++ b/addon-sdk/source/lib/sdk/l10n/html.js @@ -0,0 +1,32 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { processes, remoteRequire } = require("../remote/parent"); +remoteRequire("sdk/content/l10n-html"); + +var enabled = false; +function enable() { + if (!enabled) { + processes.port.emit("sdk/l10n/html/enable"); + enabled = true; + } +} +exports.enable = enable; + +function disable() { + if (enabled) { + processes.port.emit("sdk/l10n/html/disable"); + enabled = false; + } +} +exports.disable = disable; + +processes.forEvery(process => { + process.port.emit(enabled ? "sdk/l10n/html/enable" : "sdk/l10n/html/disable"); +}); diff --git a/addon-sdk/source/lib/sdk/l10n/json/core.js b/addon-sdk/source/lib/sdk/l10n/json/core.js new file mode 100644 index 000000000..af52f956f --- /dev/null +++ b/addon-sdk/source/lib/sdk/l10n/json/core.js @@ -0,0 +1,36 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +var usingJSON = false; +var hash = {}, bestMatchingLocale = null; +try { + let data = require("@l10n/data"); + hash = data.hash; + bestMatchingLocale = data.bestMatchingLocale; + usingJSON = true; +} +catch(e) {} + +exports.usingJSON = usingJSON; + +// Returns the translation for a given key, if available. +exports.get = function get(k) { + return k in hash ? hash[k] : null; +} + +// Returns the full length locale code: ja-JP-mac, en-US or fr +exports.locale = function locale() { + return bestMatchingLocale; +} + +// Returns the short locale code: ja, en, fr +exports.language = function language() { + return bestMatchingLocale ? bestMatchingLocale.split("-")[0].toLowerCase() + : "en"; +} diff --git a/addon-sdk/source/lib/sdk/l10n/loader.js b/addon-sdk/source/lib/sdk/l10n/loader.js new file mode 100644 index 000000000..60e219e44 --- /dev/null +++ b/addon-sdk/source/lib/sdk/l10n/loader.js @@ -0,0 +1,70 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci } = require("chrome"); +const { getPreferedLocales, findClosestLocale } = require("./locale"); +const { readURI } = require("../net/url"); +const { resolve } = require("../core/promise"); + +function parseJsonURI(uri) { + return readURI(uri). + then(JSON.parse). + then(null, function (error) { + throw Error("Failed to parse locale file:\n" + uri + "\n" + error); + }); +} + +// Returns the array stored in `locales.json` manifest that list available +// locales files +function getAvailableLocales(rootURI) { + let uri = rootURI + "locales.json"; + return parseJsonURI(uri).then(function (manifest) { + return "locales" in manifest && + Array.isArray(manifest.locales) ? + manifest.locales : []; + }); +} + +// Returns URI of the best locales file to use from the XPI +function getBestLocale(rootURI) { + // Read localization manifest file that contains list of available languages + return getAvailableLocales(rootURI).then(function (availableLocales) { + // Retrieve list of prefered locales to use + let preferedLocales = getPreferedLocales(); + + // Compute the most preferable locale to use by using these two lists + return findClosestLocale(availableLocales, preferedLocales); + }); +} + +/** + * Read localization files and returns a promise of data to put in `@l10n/data` + * pseudo module, in order to allow l10n/json/core to fetch it. + */ +exports.load = function load(rootURI) { + // First, search for a locale file: + return getBestLocale(rootURI).then(function (bestMatchingLocale) { + // It may be null if the addon doesn't have any locale file + if (!bestMatchingLocale) + return resolve(null); + + let localeURI = rootURI + "locale/" + bestMatchingLocale + ".json"; + + // Locale files only contains one big JSON object that is used as + // an hashtable of: "key to translate" => "translated key" + // TODO: We are likely to change this in order to be able to overload + // a specific key translation. For a specific package, module or line? + return parseJsonURI(localeURI).then(function (json) { + return { + hash: json, + bestMatchingLocale: bestMatchingLocale + }; + }); + }); +} diff --git a/addon-sdk/source/lib/sdk/l10n/locale.js b/addon-sdk/source/lib/sdk/l10n/locale.js new file mode 100644 index 000000000..950b33b20 --- /dev/null +++ b/addon-sdk/source/lib/sdk/l10n/locale.js @@ -0,0 +1,127 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const prefs = require("../preferences/service"); +const { Cu, Cc, Ci } = require("chrome"); +const { Services } = Cu.import("resource://gre/modules/Services.jsm"); + +/** + * Gets the currently selected locale for display. + * Gets all usable locale that we can use sorted by priority of relevance + * @return Array of locales, begins with highest priority + */ +const PREF_MATCH_OS_LOCALE = "intl.locale.matchOS"; +const PREF_SELECTED_LOCALE = "general.useragent.locale"; +const PREF_ACCEPT_LANGUAGES = "intl.accept_languages"; + +function getPreferedLocales(caseSensitve) { + let locales = []; + function addLocale(locale) { + locale = locale.trim(); + if (!caseSensitve) + locale = locale.toLowerCase(); + if (locales.indexOf(locale) === -1) + locales.push(locale); + } + + // Most important locale is OS one. But we use it, only if + // "intl.locale.matchOS" pref is set to `true`. + // Currently only used for multi-locales mobile builds. + // http://mxr.mozilla.org/mozilla-central/source/mobile/android/installer/Makefile.in#46 + if (prefs.get(PREF_MATCH_OS_LOCALE, false)) { + let localeService = Cc["@mozilla.org/intl/nslocaleservice;1"]. + getService(Ci.nsILocaleService); + let osLocale = localeService.getLocaleComponentForUserAgent(); + addLocale(osLocale); + } + + // In some cases, mainly on Fennec and on Linux version, + // `general.useragent.locale` is a special 'localized' value, like: + // "chrome://global/locale/intl.properties" + let browserUiLocale = prefs.getLocalized(PREF_SELECTED_LOCALE, "") || + prefs.get(PREF_SELECTED_LOCALE, ""); + if (browserUiLocale) + addLocale(browserUiLocale); + + // Third priority is the list of locales used for web content + let contentLocales = prefs.getLocalized(PREF_ACCEPT_LANGUAGES, "") || + prefs.get(PREF_ACCEPT_LANGUAGES, ""); + if (contentLocales) { + // This list is a string of locales seperated by commas. + // There is spaces after commas, so strip each item + for (let locale of contentLocales.split(",")) + addLocale(locale.replace(/(^\s+)|(\s+$)/g, "")); + } + + // Finally, we ensure that en-US is the final fallback if it wasn't added + addLocale("en-US"); + + return locales; +} +exports.getPreferedLocales = getPreferedLocales; + +/** + * Selects the closest matching locale from a list of locales. + * + * @param aLocales + * An array of available locales + * @param aMatchLocales + * An array of prefered locales, ordered by priority. Most wanted first. + * Locales have to be in lowercase. + * If null, uses getPreferedLocales() results + * @return the best match for the currently selected locale + * + * Stolen from http://dxr.mozilla.org/mozilla-central/source/toolkit/mozapps/extensions/internal/XPIProvider.jsm + */ +exports.findClosestLocale = function findClosestLocale(aLocales, aMatchLocales) { + aMatchLocales = aMatchLocales || getPreferedLocales(); + + // Holds the best matching localized resource + let bestmatch = null; + // The number of locale parts it matched with + let bestmatchcount = 0; + // The number of locale parts in the match + let bestpartcount = 0; + + for (let locale of aMatchLocales) { + let lparts = locale.split("-"); + for (let localized of aLocales) { + let found = localized.toLowerCase(); + // Exact match is returned immediately + if (locale == found) + return localized; + + let 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 + let maxmatchcount = Math.min(fparts.length, lparts.length); + let 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/addon-sdk/source/lib/sdk/l10n/plural-rules.js b/addon-sdk/source/lib/sdk/l10n/plural-rules.js new file mode 100644 index 000000000..a3ef48a5e --- /dev/null +++ b/addon-sdk/source/lib/sdk/l10n/plural-rules.js @@ -0,0 +1,407 @@ +/* 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 is automatically generated with /python-lib/plural-rules-generator.py +// Fetching data from: http://unicode.org/repos/cldr/trunk/common/supplemental/plurals.xml + +// Mapping of short locale name == to == > rule index in following list + +module.metadata = { + "stability": "unstable" +}; + +const LOCALES_TO_RULES = { + "af": 3, + "ak": 4, + "am": 4, + "ar": 1, + "asa": 3, + "az": 0, + "be": 11, + "bem": 3, + "bez": 3, + "bg": 3, + "bh": 4, + "bm": 0, + "bn": 3, + "bo": 0, + "br": 20, + "brx": 3, + "bs": 11, + "ca": 3, + "cgg": 3, + "chr": 3, + "cs": 12, + "cy": 17, + "da": 3, + "de": 3, + "dv": 3, + "dz": 0, + "ee": 3, + "el": 3, + "en": 3, + "eo": 3, + "es": 3, + "et": 3, + "eu": 3, + "fa": 0, + "ff": 5, + "fi": 3, + "fil": 4, + "fo": 3, + "fr": 5, + "fur": 3, + "fy": 3, + "ga": 8, + "gd": 24, + "gl": 3, + "gsw": 3, + "gu": 3, + "guw": 4, + "gv": 23, + "ha": 3, + "haw": 3, + "he": 2, + "hi": 4, + "hr": 11, + "hu": 0, + "id": 0, + "ig": 0, + "ii": 0, + "is": 3, + "it": 3, + "iu": 7, + "ja": 0, + "jmc": 3, + "jv": 0, + "ka": 0, + "kab": 5, + "kaj": 3, + "kcg": 3, + "kde": 0, + "kea": 0, + "kk": 3, + "kl": 3, + "km": 0, + "kn": 0, + "ko": 0, + "ksb": 3, + "ksh": 21, + "ku": 3, + "kw": 7, + "lag": 18, + "lb": 3, + "lg": 3, + "ln": 4, + "lo": 0, + "lt": 10, + "lv": 6, + "mas": 3, + "mg": 4, + "mk": 16, + "ml": 3, + "mn": 3, + "mo": 9, + "mr": 3, + "ms": 0, + "mt": 15, + "my": 0, + "nah": 3, + "naq": 7, + "nb": 3, + "nd": 3, + "ne": 3, + "nl": 3, + "nn": 3, + "no": 3, + "nr": 3, + "nso": 4, + "ny": 3, + "nyn": 3, + "om": 3, + "or": 3, + "pa": 3, + "pap": 3, + "pl": 13, + "ps": 3, + "pt": 3, + "rm": 3, + "ro": 9, + "rof": 3, + "ru": 11, + "rwk": 3, + "sah": 0, + "saq": 3, + "se": 7, + "seh": 3, + "ses": 0, + "sg": 0, + "sh": 11, + "shi": 19, + "sk": 12, + "sl": 14, + "sma": 7, + "smi": 7, + "smj": 7, + "smn": 7, + "sms": 7, + "sn": 3, + "so": 3, + "sq": 3, + "sr": 11, + "ss": 3, + "ssy": 3, + "st": 3, + "sv": 3, + "sw": 3, + "syr": 3, + "ta": 3, + "te": 3, + "teo": 3, + "th": 0, + "ti": 4, + "tig": 3, + "tk": 3, + "tl": 4, + "tn": 3, + "to": 0, + "tr": 0, + "ts": 3, + "tzm": 22, + "uk": 11, + "ur": 3, + "ve": 3, + "vi": 0, + "vun": 3, + "wa": 4, + "wae": 3, + "wo": 0, + "xh": 3, + "xog": 3, + "yo": 0, + "zh": 0, + "zu": 3 +}; + +// Utility functions for plural rules methods +function isIn(n, list) { + return list.indexOf(n) !== -1; +} +function isBetween(n, start, end) { + return start <= n && n <= end; +} + +// List of all plural rules methods, that maps an integer to the plural form name to use +const RULES = { + "0": function (n) { + + return "other" + }, + "1": function (n) { + if ((isBetween((n % 100), 3, 10))) + return "few"; + if (n == 0) + return "zero"; + if ((isBetween((n % 100), 11, 99))) + return "many"; + if (n == 2) + return "two"; + if (n == 1) + return "one"; + return "other" + }, + "2": function (n) { + if (n != 0 && (n % 10) == 0) + return "many"; + if (n == 2) + return "two"; + if (n == 1) + return "one"; + return "other" + }, + "3": function (n) { + if (n == 1) + return "one"; + return "other" + }, + "4": function (n) { + if ((isBetween(n, 0, 1))) + return "one"; + return "other" + }, + "5": function (n) { + if ((isBetween(n, 0, 2)) && n != 2) + return "one"; + return "other" + }, + "6": function (n) { + if (n == 0) + return "zero"; + if ((n % 10) == 1 && (n % 100) != 11) + return "one"; + return "other" + }, + "7": function (n) { + if (n == 2) + return "two"; + if (n == 1) + return "one"; + return "other" + }, + "8": function (n) { + if ((isBetween(n, 3, 6))) + return "few"; + if ((isBetween(n, 7, 10))) + return "many"; + if (n == 2) + return "two"; + if (n == 1) + return "one"; + return "other" + }, + "9": function (n) { + if (n == 0 || n != 1 && (isBetween((n % 100), 1, 19))) + return "few"; + if (n == 1) + return "one"; + return "other" + }, + "10": function (n) { + if ((isBetween((n % 10), 2, 9)) && !(isBetween((n % 100), 11, 19))) + return "few"; + if ((n % 10) == 1 && !(isBetween((n % 100), 11, 19))) + return "one"; + return "other" + }, + "11": function (n) { + if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14))) + return "few"; + if ((n % 10) == 0 || (isBetween((n % 10), 5, 9)) || (isBetween((n % 100), 11, 14))) + return "many"; + if ((n % 10) == 1 && (n % 100) != 11) + return "one"; + return "other" + }, + "12": function (n) { + if ((isBetween(n, 2, 4))) + return "few"; + if (n == 1) + return "one"; + return "other" + }, + "13": function (n) { + if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14))) + return "few"; + if (n != 1 && (isBetween((n % 10), 0, 1)) || (isBetween((n % 10), 5, 9)) || (isBetween((n % 100), 12, 14))) + return "many"; + if (n == 1) + return "one"; + return "other" + }, + "14": function (n) { + if ((isBetween((n % 100), 3, 4))) + return "few"; + if ((n % 100) == 2) + return "two"; + if ((n % 100) == 1) + return "one"; + return "other" + }, + "15": function (n) { + if (n == 0 || (isBetween((n % 100), 2, 10))) + return "few"; + if ((isBetween((n % 100), 11, 19))) + return "many"; + if (n == 1) + return "one"; + return "other" + }, + "16": function (n) { + if ((n % 10) == 1 && n != 11) + return "one"; + return "other" + }, + "17": function (n) { + if (n == 3) + return "few"; + if (n == 0) + return "zero"; + if (n == 6) + return "many"; + if (n == 2) + return "two"; + if (n == 1) + return "one"; + return "other" + }, + "18": function (n) { + if (n == 0) + return "zero"; + if ((isBetween(n, 0, 2)) && n != 0 && n != 2) + return "one"; + return "other" + }, + "19": function (n) { + if ((isBetween(n, 2, 10))) + return "few"; + if ((isBetween(n, 0, 1))) + return "one"; + return "other" + }, + "20": function (n) { + if ((isBetween((n % 10), 3, 4) || ((n % 10) == 9)) && !(isBetween((n % 100), 10, 19) || isBetween((n % 100), 70, 79) || isBetween((n % 100), 90, 99))) + return "few"; + if ((n % 1000000) == 0 && n != 0) + return "many"; + if ((n % 10) == 2 && !isIn((n % 100), [12, 72, 92])) + return "two"; + if ((n % 10) == 1 && !isIn((n % 100), [11, 71, 91])) + return "one"; + return "other" + }, + "21": function (n) { + if (n == 0) + return "zero"; + if (n == 1) + return "one"; + return "other" + }, + "22": function (n) { + if ((isBetween(n, 0, 1)) || (isBetween(n, 11, 99))) + return "one"; + return "other" + }, + "23": function (n) { + if ((isBetween((n % 10), 1, 2)) || (n % 20) == 0) + return "one"; + return "other" + }, + "24": function (n) { + if ((isBetween(n, 3, 10) || isBetween(n, 13, 19))) + return "few"; + if (isIn(n, [2, 12])) + return "two"; + if (isIn(n, [1, 11])) + return "one"; + return "other" + }, +}; + +/** + * Return a function that gives the plural form name for a given integer + * for the specified `locale` + * let fun = getRulesForLocale('en'); + * fun(1) -> 'one' + * fun(0) -> 'other' + * fun(1000) -> 'other' + */ +exports.getRulesForLocale = function getRulesForLocale(locale) { + let index = LOCALES_TO_RULES[locale]; + if (!(index in RULES)) { + console.warn('Plural form unknown for locale "' + locale + '"'); + return function () { return "other"; }; + } + return RULES[index]; +} + diff --git a/addon-sdk/source/lib/sdk/l10n/prefs.js b/addon-sdk/source/lib/sdk/l10n/prefs.js new file mode 100644 index 000000000..8ee26fc5b --- /dev/null +++ b/addon-sdk/source/lib/sdk/l10n/prefs.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"; + +const { on } = require("../system/events"); +const core = require("./core"); +const { id: jetpackId } = require('../self'); + +const OPTIONS_DISPLAYED = "addon-options-displayed"; + +function enable() { + on(OPTIONS_DISPLAYED, onOptionsDisplayed); +} +exports.enable = enable; + +function onOptionsDisplayed({ subject: document, data: addonId }) { + if (addonId !== jetpackId) + return; + localizeInlineOptions(document); +} + +function localizeInlineOptions(document) { + let query = 'setting[data-jetpack-id="' + jetpackId + '"][pref-name], ' + + 'button[data-jetpack-id="' + jetpackId + '"][pref-name]'; + let nodes = document.querySelectorAll(query); + for (let node of nodes) { + let name = node.getAttribute("pref-name"); + if (node.tagName == "setting") { + let desc = core.get(name + "_description"); + if (desc) + node.setAttribute("desc", desc); + let title = core.get(name + "_title"); + if (title) + node.setAttribute("title", title); + + for (let item of node.querySelectorAll("menuitem, radio")) { + let key = name + "_options." + item.getAttribute("label"); + let label = core.get(key); + if (label) + item.setAttribute("label", label); + } + } + else if (node.tagName == "button") { + let label = core.get(name + "_label"); + if (label) + node.setAttribute("label", label); + } + } +} +exports.localizeInlineOptions = localizeInlineOptions; diff --git a/addon-sdk/source/lib/sdk/l10n/properties/core.js b/addon-sdk/source/lib/sdk/l10n/properties/core.js new file mode 100644 index 000000000..7a9081d0b --- /dev/null +++ b/addon-sdk/source/lib/sdk/l10n/properties/core.js @@ -0,0 +1,87 @@ +/* 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 } = require("chrome"); +const { newURI } = require('../../url/utils') +const { getRulesForLocale } = require("../plural-rules"); +const { getPreferedLocales } = require('../locale'); +const { rootURI } = require("@loader/options"); +const { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); + +const baseURI = rootURI + "locale/"; +const preferedLocales = getPreferedLocales(true); + +// Make sure we don't get stale data after an update +// (See Bug 1300735 for rationale). +Services.strings.flushBundles(); + +function getLocaleURL(locale) { + // if the locale is a valid chrome URI, return it + try { + let uri = newURI(locale); + if (uri.scheme == 'chrome') + return uri.spec; + } + catch(_) {} + // otherwise try to construct the url + return baseURI + locale + ".properties"; +} + +function getKey(locale, key) { + let bundle = Services.strings.createBundle(getLocaleURL(locale)); + try { + return bundle.GetStringFromName(key) + ""; + } + catch (_) {} + return undefined; +} + +function get(key, n, locales) { + // try this locale + let locale = locales.shift(); + let localized; + + if (typeof n == 'number') { + if (n == 0) { + localized = getKey(locale, key + '[zero]'); + } + else if (n == 1) { + localized = getKey(locale, key + '[one]'); + } + else if (n == 2) { + localized = getKey(locale, key + '[two]'); + } + + if (!localized) { + // Retrieve the plural mapping function + let pluralForm = (getRulesForLocale(locale.split("-")[0].toLowerCase()) || + getRulesForLocale("en"))(n); + localized = getKey(locale, key + '[' + pluralForm + ']'); + } + + if (!localized) { + localized = getKey(locale, key + '[other]'); + } + } + + if (!localized) { + localized = getKey(locale, key); + } + + if (!localized) { + localized = getKey(locale, key + '[other]'); + } + + if (localized) { + return localized; + } + + // try next locale + if (locales.length) + return get(key, n, locales); + + return undefined; +} +exports.get = (k, n) => get(k, n, Array.slice(preferedLocales)); diff --git a/addon-sdk/source/lib/sdk/lang/functional.js b/addon-sdk/source/lib/sdk/lang/functional.js new file mode 100644 index 000000000..66e30edfa --- /dev/null +++ b/addon-sdk/source/lib/sdk/lang/functional.js @@ -0,0 +1,47 @@ +/* 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/. */ + +// Disclaimer: Some of the functions in this module implement APIs from +// Jeremy Ashkenas's http://underscorejs.org/ library and all credits for +// those goes to him. + +"use strict"; + +module.metadata = { + "stability": "unstable" +}; + +const { defer, remit, delay, debounce, + throttle } = require("./functional/concurrent"); +const { method, invoke, partial, curry, compose, wrap, identity, memoize, once, + cache, complement, constant, when, apply, flip, field, query, + isInstance, chainable, is, isnt } = require("./functional/core"); + +exports.defer = defer; +exports.remit = remit; +exports.delay = delay; +exports.debounce = debounce; +exports.throttle = throttle; + +exports.method = method; +exports.invoke = invoke; +exports.partial = partial; +exports.curry = curry; +exports.compose = compose; +exports.wrap = wrap; +exports.identity = identity; +exports.memoize = memoize; +exports.once = once; +exports.cache = cache; +exports.complement = complement; +exports.constant = constant; +exports.when = when; +exports.apply = apply; +exports.flip = flip; +exports.field = field; +exports.query = query; +exports.isInstance = isInstance; +exports.chainable = chainable; +exports.is = is; +exports.isnt = isnt; diff --git a/addon-sdk/source/lib/sdk/lang/functional/concurrent.js b/addon-sdk/source/lib/sdk/lang/functional/concurrent.js new file mode 100644 index 000000000..85e8cff46 --- /dev/null +++ b/addon-sdk/source/lib/sdk/lang/functional/concurrent.js @@ -0,0 +1,110 @@ +/* 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/. */ + +// Disclaimer: Some of the functions in this module implement APIs from +// Jeremy Ashkenas's http://underscorejs.org/ library and all credits for +// those goes to him. + +"use strict"; + +module.metadata = { + "stability": "unstable" +}; + +const { arity, name, derive, invoke } = require("./helpers"); +const { setTimeout, clearTimeout, setImmediate } = require("../../timers"); + +/** + * Takes a function and returns a wrapped one instead, calling which will call + * original function in the next turn of event loop. This is basically utility + * to do `setImmediate(function() { ... })`, with a difference that returned + * function is reused, instead of creating a new one each time. This also allows + * to use this functions as event listeners. + */ +const defer = f => derive(function(...args) { + setImmediate(invoke, f, args, this); +}, f); +exports.defer = defer; +// Exporting `remit` alias as `defer` may conflict with promises. +exports.remit = defer; + +/** + * Much like setTimeout, invokes function after wait milliseconds. If you pass + * the optional arguments, they will be forwarded on to the function when it is + * invoked. + */ +const delay = function delay(f, ms, ...args) { + setTimeout(() => f.apply(this, args), ms); +}; +exports.delay = delay; + +/** + * From underscore's `_.debounce` + * http://underscorejs.org + * (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + * Underscore may be freely distributed under the MIT license. + */ +const debounce = function debounce (fn, wait) { + let timeout, args, context, timestamp, result; + + let later = function () { + let last = Date.now() - timestamp; + if (last < wait) { + timeout = setTimeout(later, wait - last); + } else { + timeout = null; + result = fn.apply(context, args); + context = args = null; + } + }; + + return function (...aArgs) { + context = this; + args = aArgs; + timestamp = Date.now(); + if (!timeout) { + timeout = setTimeout(later, wait); + } + + return result; + }; +}; +exports.debounce = debounce; + +/** + * From underscore's `_.throttle` + * http://underscorejs.org + * (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + * Underscore may be freely distributed under the MIT license. + */ +const throttle = function throttle (func, wait, options) { + let context, args, result; + let timeout = null; + let previous = 0; + options || (options = {}); + let later = function() { + previous = options.leading === false ? 0 : Date.now(); + timeout = null; + result = func.apply(context, args); + context = args = null; + }; + return function() { + let now = Date.now(); + if (!previous && options.leading === false) previous = now; + let remaining = wait - (now - previous); + context = this; + args = arguments; + if (remaining <= 0) { + clearTimeout(timeout); + timeout = null; + previous = now; + result = func.apply(context, args); + context = args = null; + } else if (!timeout && options.trailing !== false) { + timeout = setTimeout(later, remaining); + } + return result; + }; +}; +exports.throttle = throttle; diff --git a/addon-sdk/source/lib/sdk/lang/functional/core.js b/addon-sdk/source/lib/sdk/lang/functional/core.js new file mode 100644 index 000000000..0d9143364 --- /dev/null +++ b/addon-sdk/source/lib/sdk/lang/functional/core.js @@ -0,0 +1,290 @@ +/* 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/. */ + +// Disclaimer: Some of the functions in this module implement APIs from +// Jeremy Ashkenas's http://underscorejs.org/ library and all credits for +// those goes to him. + +"use strict"; + +module.metadata = { + "stability": "unstable" +} +const { arity, name, derive, invoke } = require("./helpers"); + +/** + * Takes variadic numeber of functions and returns composed one. + * Returned function pushes `this` pseudo-variable to the head + * of the passed arguments and invokes all the functions from + * left to right passing same arguments to them. Composite function + * returns return value of the right most funciton. + */ +const method = (...lambdas) => { + return function method(...args) { + args.unshift(this); + return lambdas.reduce((_, lambda) => lambda.apply(this, args), + void(0)); + }; +}; +exports.method = method; + +/** + * Invokes `callee` by passing `params` as an arguments and `self` as `this` + * pseudo-variable. Returns value that is returned by a callee. + * @param {Function} callee + * Function to invoke. + * @param {Array} params + * Arguments to invoke function with. + * @param {Object} self + * Object to be passed as a `this` pseudo variable. + */ +exports.invoke = invoke; + +/** + * Takes a function and bind values to one or more arguments, returning a new + * function of smaller arity. + * + * @param {Function} fn + * The function to partial + * + * @returns The new function with binded values + */ +const partial = (f, ...curried) => { + if (typeof(f) !== "function") + throw new TypeError(String(f) + " is not a function"); + + let fn = derive(function(...args) { + return f.apply(this, curried.concat(args)); + }, f); + fn.arity = arity(f) - curried.length; + return fn; +}; +exports.partial = partial; + +/** + * Returns function with implicit currying, which will continue currying until + * expected number of argument is collected. Expected number of arguments is + * determined by `fn.length`. Using this with variadic functions is stupid, + * so don't do it. + * + * @examples + * + * var sum = curry(function(a, b) { + * return a + b + * }) + * console.log(sum(2, 2)) // 4 + * console.log(sum(2)(4)) // 6 + */ +const curry = new function() { + const currier = (fn, arity, params) => { + // Function either continues to curry arguments or executes function + // if desired arguments have being collected. + const curried = function(...input) { + // Prepend all curried arguments to the given arguments. + if (params) input.unshift.apply(input, params); + // If expected number of arguments has being collected invoke fn, + // othrewise return curried version Otherwise continue curried. + return (input.length >= arity) ? fn.apply(this, input) : + currier(fn, arity, input); + }; + curried.arity = arity - (params ? params.length : 0); + + return curried; + }; + + return fn => currier(fn, arity(fn)); +}; +exports.curry = curry; + +/** + * Returns the composition of a list of functions, where each function consumes + * the return value of the function that follows. In math terms, composing the + * functions `f()`, `g()`, and `h()` produces `f(g(h()))`. + * @example + * + * var greet = function(name) { return "hi: " + name; }; + * var exclaim = function(statement) { return statement + "!"; }; + * var welcome = compose(exclaim, greet); + * + * welcome('moe'); // => 'hi: moe!' + */ +function compose(...lambdas) { + return function composed(...args) { + let index = lambdas.length; + while (0 <= --index) + args = [lambdas[index].apply(this, args)]; + + return args[0]; + }; +} +exports.compose = compose; + +/* + * Returns the first function passed as an argument to the second, + * allowing you to adjust arguments, run code before and after, and + * conditionally execute the original function. + * @example + * + * var hello = function(name) { return "hello: " + name; }; + * hello = wrap(hello, function(f) { + * return "before, " + f("moe") + ", after"; + * }); + * + * hello(); // => 'before, hello: moe, after' + */ +const wrap = (f, wrapper) => derive(function wrapped(...args) { + return wrapper.apply(this, [f].concat(args)); +}, f); +exports.wrap = wrap; + +/** + * Returns the same value that is used as the argument. In math: f(x) = x + */ +const identity = value => value; +exports.identity = identity; + +/** + * Memoizes a given function by caching the computed result. Useful for + * speeding up slow-running computations. If passed an optional hashFunction, + * it will be used to compute the hash key for storing the result, based on + * the arguments to the original function. The default hashFunction just uses + * the first argument to the memoized function as the key. + */ +const memoize = (f, hasher) => { + let memo = Object.create(null); + let cache = new WeakMap(); + hasher = hasher || identity; + return derive(function memoizer(...args) { + const key = hasher.apply(this, args); + const type = typeof(key); + if (key && (type === "object" || type === "function")) { + if (!cache.has(key)) + cache.set(key, f.apply(this, args)); + return cache.get(key); + } + else { + if (!(key in memo)) + memo[key] = f.apply(this, args); + return memo[key]; + } + }, f); +}; +exports.memoize = memoize; + +/* + * Creates a version of the function that can only be called one time. Repeated + * calls to the modified function will have no effect, returning the value from + * the original call. Useful for initialization functions, instead of having to + * set a boolean flag and then check it later. + */ +const once = f => { + let ran = false, cache; + return derive(function(...args) { + return ran ? cache : (ran = true, cache = f.apply(this, args)); + }, f); +}; +exports.once = once; +// export cache as once will may be conflicting with event once a lot. +exports.cache = once; + +// Takes a `f` function and returns a function that takes the same +// arguments as `f`, has the same effects, if any, and returns the +// opposite truth value. +const complement = f => derive(function(...args) { + return args.length < arity(f) ? complement(partial(f, ...args)) : + !f.apply(this, args); +}, f); +exports.complement = complement; + +// Constructs function that returns `x` no matter what is it +// invoked with. +const constant = x => _ => x; +exports.constant = constant; + +// Takes `p` predicate, `consequent` function and an optional +// `alternate` function and composes function that returns +// application of arguments over `consequent` if application over +// `p` is `true` otherwise returns application over `alternate`. +// If `alternate` is not a function returns `undefined`. +const when = (p, consequent, alternate) => { + if (typeof(alternate) !== "function" && alternate !== void(0)) + throw TypeError("alternate must be a function"); + if (typeof(consequent) !== "function") + throw TypeError("consequent must be a function"); + + return function(...args) { + return p.apply(this, args) ? + consequent.apply(this, args) : + alternate && alternate.apply(this, args); + }; +}; +exports.when = when; + +// Apply function that behaves as `apply` does in lisp: +// apply(f, x, [y, z]) => f.apply(f, [x, y, z]) +// apply(f, x) => f.apply(f, [x]) +const apply = (f, ...rest) => f.apply(f, rest.concat(rest.pop())); +exports.apply = apply; + +// Returns function identical to given `f` but with flipped order +// of arguments. +const flip = f => derive(function(...args) { + return f.apply(this, args.reverse()); +}, f); +exports.flip = flip; + +// Takes field `name` and `target` and returns value of that field. +// If `target` is `null` or `undefined` it would be returned back +// instead of attempt to access it's field. Function is implicitly +// curried, this allows accessor function generation by calling it +// with only `name` argument. +const field = curry((name, target) => + // Note: Permisive `==` is intentional. + target == null ? target : target[name]); +exports.field = field; + +// Takes `.` delimited string representing `path` to a nested field +// and a `target` to get it from. For convinience function is +// implicitly curried, there for accessors can be created by invoking +// it with just a `path` argument. +const query = curry((path, target) => { + const names = path.split("."); + const count = names.length; + let index = 0; + let result = target; + // Note: Permisive `!=` is intentional. + while (result != null && index < count) { + result = result[names[index]]; + index = index + 1; + } + return result; +}); +exports.query = query; + +// Takes `Type` (constructor function) and a `value` and returns +// `true` if `value` is instance of the given `Type`. Function is +// implicitly curried this allows predicate generation by calling +// function with just first argument. +const isInstance = curry((Type, value) => value instanceof Type); +exports.isInstance = isInstance; + +/* + * Takes a funtion and returns a wrapped function that returns `this` + */ +const chainable = f => derive(function(...args) { + f.apply(this, args); + return this; +}, f); +exports.chainable = chainable; + +// Functions takes `expected` and `actual` values and returns `true` if +// `expected === actual`. Returns curried function if called with less then +// two arguments. +// +// [ 1, 0, 1, 0, 1 ].map(is(1)) // => [ true, false, true, false, true ] +const is = curry((expected, actual) => actual === expected); +exports.is = is; + +const isnt = complement(is); +exports.isnt = isnt; diff --git a/addon-sdk/source/lib/sdk/lang/functional/helpers.js b/addon-sdk/source/lib/sdk/lang/functional/helpers.js new file mode 100644 index 000000000..60f4e3300 --- /dev/null +++ b/addon-sdk/source/lib/sdk/lang/functional/helpers.js @@ -0,0 +1,29 @@ +/* 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/. */ + +// Disclaimer: Some of the functions in this module implement APIs from +// Jeremy Ashkenas's http://underscorejs.org/ library and all credits for +// those goes to him. + +"use strict"; + +module.metadata = { + "stability": "unstable" +} + +const arity = f => f.arity || f.length; +exports.arity = arity; + +const name = f => f.displayName || f.name; +exports.name = name; + +const derive = (f, source) => { + f.displayName = name(source); + f.arity = arity(source); + return f; +}; +exports.derive = derive; + +const invoke = (callee, params, self) => callee.apply(self, params); +exports.invoke = invoke; diff --git a/addon-sdk/source/lib/sdk/lang/type.js b/addon-sdk/source/lib/sdk/lang/type.js new file mode 100644 index 000000000..b50e6be4c --- /dev/null +++ b/addon-sdk/source/lib/sdk/lang/type.js @@ -0,0 +1,388 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +/** + * Returns `true` if `value` is `undefined`. + * @examples + * var foo; isUndefined(foo); // true + * isUndefined(0); // false + */ +function isUndefined(value) { + return value === undefined; +} +exports.isUndefined = isUndefined; + +/** + * Returns `true` if value is `null`. + * @examples + * isNull(null); // true + * isNull(undefined); // false + */ +function isNull(value) { + return value === null; +} +exports.isNull = isNull; + +/** + * Returns `true` if value is `null` or `undefined`. + * It's equivalent to `== null`, but resolve the ambiguity of the writer + * intention, makes clear that he's clearly checking both `null` and `undefined` + * values, and it's not a typo for `=== null`. + */ +function isNil(value) { + return value === null || value === undefined; +} +exports.isNil = isNil; + +function isBoolean(value) { + return typeof value === "boolean"; +} +exports.isBoolean = isBoolean; +/** + * Returns `true` if value is a string. + * @examples + * isString("moe"); // true + */ +function isString(value) { + return typeof value === "string"; +} +exports.isString = isString; + +/** + * Returns `true` if `value` is a number. + * @examples + * isNumber(8.4 * 5); // true + */ +function isNumber(value) { + return typeof value === "number"; +} +exports.isNumber = isNumber; + +/** + * Returns `true` if `value` is a `RegExp`. + * @examples + * isRegExp(/moe/); // true + */ +function isRegExp(value) { + return isObject(value) && instanceOf(value, RegExp); +} +exports.isRegExp = isRegExp; + +/** + * Returns true if `value` is a `Date`. + * @examples + * isDate(new Date()); // true + */ +function isDate(value) { + return isObject(value) && instanceOf(value, Date); +} +exports.isDate = isDate; + +/** + * Returns true if object is a Function. + * @examples + * isFunction(function foo(){}) // true + */ +function isFunction(value) { + return typeof value === "function"; +} +exports.isFunction = isFunction; + +/** + * Returns `true` if `value` is an object (please note that `null` is considered + * to be an atom and not an object). + * @examples + * isObject({}) // true + * isObject(null) // false + */ +function isObject(value) { + return typeof value === "object" && value !== null; +} +exports.isObject = isObject; + +/** + * 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 !!(aValue && aValue.isGenerator && aValue.isGenerator()); +} +exports.isGenerator = isGenerator; + +/** + * Returns true if `value` is an Array. + * @examples + * isArray([1, 2, 3]) // true + * isArray({ 0: 'foo', length: 1 }) // false + */ +var isArray = Array.isArray; +exports.isArray = isArray; + +/** + * Returns `true` if `value` is an Arguments object. + * @examples + * (function(){ return isArguments(arguments); })(1, 2, 3); // true + * isArguments([1,2,3]); // false + */ +function isArguments(value) { + return Object.prototype.toString.call(value) === "[object Arguments]"; +} +exports.isArguments = isArguments; + +var isMap = value => Object.prototype.toString.call(value) === "[object Map]" +exports.isMap = isMap; + +var isSet = value => Object.prototype.toString.call(value) === "[object Set]" +exports.isSet = isSet; + +/** + * Returns true if it is a primitive `value`. (null, undefined, number, + * boolean, string) + * @examples + * isPrimitive(3) // true + * isPrimitive('foo') // true + * isPrimitive({ bar: 3 }) // false + */ +function isPrimitive(value) { + return !isFunction(value) && !isObject(value); +} +exports.isPrimitive = isPrimitive; + +/** + * Returns `true` if given `object` is flat (it is direct decedent of + * `Object.prototype` or `null`). + * @examples + * isFlat({}) // true + * isFlat(new Type()) // false + */ +function isFlat(object) { + return isObject(object) && (isNull(Object.getPrototypeOf(object)) || + isNull(Object.getPrototypeOf( + Object.getPrototypeOf(object)))); +} +exports.isFlat = isFlat; + +/** + * Returns `true` if object contains no values. + */ +function isEmpty(object) { + if (isObject(object)) { + for (var key in object) + return false; + return true; + } + return false; +} +exports.isEmpty = isEmpty; + +/** + * Returns `true` if `value` is an array / flat object containing only atomic + * values and other flat objects. + */ +function isJSON(value, visited) { + // Adding value to array of visited values. + (visited || (visited = [])).push(value); + // If `value` is an atom return `true` cause it's valid JSON. + return isPrimitive(value) || + // If `value` is an array of JSON values that has not been visited + // yet. + (isArray(value) && value.every(function(element) { + return isJSON(element, visited); + })) || + // If `value` is a plain object containing properties with a JSON + // values it's a valid JSON. + (isFlat(value) && Object.keys(value).every(function(key) { + var $ = Object.getOwnPropertyDescriptor(value, key); + // Check every proprety of a plain object to verify that + // it's neither getter nor setter, but a JSON value, that + // has not been visited yet. + return ((!isObject($.value) || !~visited.indexOf($.value)) && + !('get' in $) && !('set' in $) && + isJSON($.value, visited)); + })); +} +exports.isJSON = function (value) { + return isJSON(value); +}; + +/** + * Returns `true` if `value` is JSONable + */ +const isJSONable = (value) => { + try { + JSON.parse(JSON.stringify(value)); + } + catch (e) { + return false; + } + return true; +}; +exports.isJSONable = isJSONable; + +/** + * Returns if `value` is an instance of a given `Type`. This is exactly same as + * `value instanceof Type` with a difference that `Type` can be from a scope + * that has a different top level object. (Like in case where `Type` is a + * function from different iframe / jetpack module / sandbox). + */ +function instanceOf(value, Type) { + var isConstructorNameSame; + var isConstructorSourceSame; + + // If `instanceof` returned `true` we know result right away. + var isInstanceOf = value instanceof Type; + + // If `instanceof` returned `false` we do ducktype check since `Type` may be + // from a different sandbox. If a constructor of the `value` or a constructor + // of the value's prototype has same name and source we assume that it's an + // instance of the Type. + if (!isInstanceOf && value) { + isConstructorNameSame = value.constructor.name === Type.name; + isConstructorSourceSame = String(value.constructor) == String(Type); + isInstanceOf = (isConstructorNameSame && isConstructorSourceSame) || + instanceOf(Object.getPrototypeOf(value), Type); + } + return isInstanceOf; +} +exports.instanceOf = instanceOf; + +/** + * Function returns textual representation of a value passed to it. Function + * takes additional `indent` argument that is used for indentation. Also + * optional `limit` argument may be passed to limit amount of detail returned. + * @param {Object} value + * @param {String} [indent=" "] + * @param {Number} [limit] + */ +function source(value, indent, limit, offset, visited) { + var result; + var names; + var nestingIndex; + var isCompact = !isUndefined(limit); + + indent = indent || " "; + offset = (offset || ""); + result = ""; + visited = visited || []; + + if (isUndefined(value)) { + result += "undefined"; + } + else if (isNull(value)) { + result += "null"; + } + else if (isString(value)) { + result += '"' + value + '"'; + } + else if (isFunction(value)) { + value = String(value).split("\n"); + if (isCompact && value.length > 2) { + value = value.splice(0, 2); + value.push("...}"); + } + result += value.join("\n" + offset); + } + else if (isArray(value)) { + if ((nestingIndex = (visited.indexOf(value) + 1))) { + result = "#" + nestingIndex + "#"; + } + else { + visited.push(value); + + if (isCompact) + value = value.slice(0, limit); + + result += "[\n"; + result += value.map(function(value) { + return offset + indent + source(value, indent, limit, offset + indent, + visited); + }).join(",\n"); + result += isCompact && value.length > limit ? + ",\n" + offset + "...]" : "\n" + offset + "]"; + } + } + else if (isObject(value)) { + if ((nestingIndex = (visited.indexOf(value) + 1))) { + result = "#" + nestingIndex + "#" + } + else { + visited.push(value) + + names = Object.keys(value); + + result += "{ // " + value + "\n"; + result += (isCompact ? names.slice(0, limit) : names).map(function(name) { + var _limit = isCompact ? limit - 1 : limit; + var descriptor = Object.getOwnPropertyDescriptor(value, name); + var result = offset + indent + "// "; + var accessor; + if (0 <= name.indexOf(" ")) + name = '"' + name + '"'; + + if (descriptor.writable) + result += "writable "; + if (descriptor.configurable) + result += "configurable "; + if (descriptor.enumerable) + result += "enumerable "; + + result += "\n"; + if ("value" in descriptor) { + result += offset + indent + name + ": "; + result += source(descriptor.value, indent, _limit, indent + offset, + visited); + } + else { + + if (descriptor.get) { + result += offset + indent + "get " + name + " "; + accessor = source(descriptor.get, indent, _limit, indent + offset, + visited); + result += accessor.substr(accessor.indexOf("{")); + } + + if (descriptor.set) { + result += offset + indent + "set " + name + " "; + accessor = source(descriptor.set, indent, _limit, indent + offset, + visited); + result += accessor.substr(accessor.indexOf("{")); + } + } + return result; + }).join(",\n"); + + if (isCompact) { + if (names.length > limit && limit > 0) { + result += ",\n" + offset + indent + "//..."; + } + } + else { + if (names.length) + result += ","; + + result += "\n" + offset + indent + '"__proto__": '; + result += source(Object.getPrototypeOf(value), indent, 0, + offset + indent); + } + + result += "\n" + offset + "}"; + } + } + else { + result += String(value); + } + return result; +} +exports.source = function (value, indentation, limit) { + return source(value, indentation, limit); +}; diff --git a/addon-sdk/source/lib/sdk/lang/weak-set.js b/addon-sdk/source/lib/sdk/lang/weak-set.js new file mode 100644 index 000000000..8972602a5 --- /dev/null +++ b/addon-sdk/source/lib/sdk/lang/weak-set.js @@ -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/. */ + +module.metadata = { + "stability": "experimental" +}; + +"use strict"; + +const { Cu } = require("chrome"); + +function makeGetterFor(Type) { + let cache = new WeakMap(); + + return { + getFor(target) { + if (!cache.has(target)) + cache.set(target, new Type()); + + return cache.get(target); + }, + clearFor(target) { + return cache.delete(target) + } + } +} + +var {getFor: getLookupFor, clearFor: clearLookupFor} = makeGetterFor(WeakMap); +var {getFor: getRefsFor, clearFor: clearRefsFor} = makeGetterFor(Set); + +function add(target, value) { + if (has(target, value)) + return; + + getLookupFor(target).set(value, true); + getRefsFor(target).add(Cu.getWeakReference(value)); +} +exports.add = add; + +function remove(target, value) { + getLookupFor(target).delete(value); +} +exports.remove = remove; + +function has(target, value) { + return getLookupFor(target).has(value); +} +exports.has = has; + +function clear(target) { + clearLookupFor(target); + clearRefsFor(target); +} +exports.clear = clear; + +function iterator(target) { + let refs = getRefsFor(target); + + for (let ref of refs) { + let value = ref.get(); + + // If `value` is already gc'ed, it would be `null`. + // The `has` function is using a WeakMap as lookup table, so passing `null` + // would raise an exception because WeakMap accepts as value only non-null + // object. + // Plus, if `value` is already gc'ed, we do not have to take it in account + // during the iteration, and remove it from the references. + if (value !== null && has(target, value)) + yield value; + else + refs.delete(ref); + } +} +exports.iterator = iterator; diff --git a/addon-sdk/source/lib/sdk/loader/cuddlefish.js b/addon-sdk/source/lib/sdk/loader/cuddlefish.js new file mode 100644 index 000000000..6ba19157b --- /dev/null +++ b/addon-sdk/source/lib/sdk/loader/cuddlefish.js @@ -0,0 +1,102 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +// This module is manually loaded by bootstrap.js in a sandbox and immediatly +// put in module cache so that it is never loaded in any other way. + +/* Workarounds to include dependencies in the manifest +require('chrome') // Otherwise CFX will complain about Components +require('toolkit/loader') // Otherwise CFX will stip out loader.js +require('sdk/addon/runner') // Otherwise CFX will stip out addon/runner.js +*/ + +const { classes: Cc, Constructor: CC, interfaces: Ci, utils: Cu } = Components; + +// `loadSandbox` is exposed by bootstrap.js +const loaderURI = module.uri.replace("sdk/loader/cuddlefish.js", + "toolkit/loader.js"); +const xulappURI = module.uri.replace("loader/cuddlefish.js", + "system/xul-app.jsm"); +// We need to keep a reference to the sandbox in order to unload it in +// bootstrap.js + +var loaderSandbox = loadSandbox(loaderURI); +const loaderModule = loaderSandbox.exports; + +const { incompatibility } = Cu.import(xulappURI, {}).XulApp; + +const { override, load } = loaderModule; + +function CuddlefishLoader(options) { + let { manifest } = options; + + options = override(options, { + // Put `api-utils/loader` and `api-utils/cuddlefish` loaded as JSM to module + // cache to avoid subsequent loads via `require`. + modules: override({ + 'toolkit/loader': loaderModule, + 'sdk/loader/cuddlefish': exports + }, options.modules), + resolve: function resolve(id, requirer) { + let entry = requirer && requirer in manifest && manifest[requirer]; + let uri = null; + + // If manifest entry for this requirement is present we follow manifest. + // Note: Standard library modules like 'panel' will be present in + // manifest unless they were moved to platform. + if (entry) { + let requirement = entry.requirements[id]; + // If requirer entry is in manifest and it's requirement is not, than + // it has no authority to load since linker was not able to find it. + if (!requirement) + throw Error('Module: ' + requirer + ' has no authority to load: ' + + id, requirer); + + uri = requirement; + } else { + // If requirer is off manifest than it's a system module and we allow it + // to go off manifest by resolving a relative path. + uri = loaderModule.resolve(id, requirer); + } + return uri; + }, + load: function(loader, module) { + let result; + let error; + + // In order to get the module's metadata, we need to load the module. + // if an exception is raised here, it could be that is due to application + // incompatibility. Therefore the exception is stored, and thrown again + // only if the module seems be compatible with the application currently + // running. Otherwise the incompatibility message takes the precedence. + try { + result = load(loader, module); + } + catch (e) { + error = e; + } + + error = incompatibility(module) || error; + + if (error) + throw error; + + return result; + } + }); + + let loader = loaderModule.Loader(options); + // Hack to allow loading from `toolkit/loader`. + loader.modules[loaderURI] = loaderSandbox; + return loader; +} + +exports = override(loaderModule, { + Loader: CuddlefishLoader +}); diff --git a/addon-sdk/source/lib/sdk/loader/sandbox.js b/addon-sdk/source/lib/sdk/loader/sandbox.js new file mode 100644 index 000000000..791dbc086 --- /dev/null +++ b/addon-sdk/source/lib/sdk/loader/sandbox.js @@ -0,0 +1,74 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Cc, Ci, CC, Cu } = require('chrome'); +const systemPrincipal = CC('@mozilla.org/systemprincipal;1', 'nsIPrincipal')(); +const scriptLoader = Cc['@mozilla.org/moz/jssubscript-loader;1']. + getService(Ci.mozIJSSubScriptLoader); +const self = require('sdk/self'); +const { getTabId } = require('../tabs/utils'); +const { getInnerId } = require('../window/utils'); + +const { devtools } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const { require: devtoolsRequire } = devtools; +const { addContentGlobal, removeContentGlobal } = devtoolsRequire("devtools/server/content-globals"); + +/** + * Make a new sandbox that inherits given `source`'s principals. Source can be + * URI string, DOMWindow or `null` for system principals. + */ +function sandbox(target, options) { + options = options || {}; + options.metadata = options.metadata ? options.metadata : {}; + options.metadata.addonID = options.metadata.addonID ? + options.metadata.addonID : self.id; + + let sandbox = Cu.Sandbox(target || systemPrincipal, options); + Cu.setSandboxMetadata(sandbox, options.metadata); + let innerWindowID = options.metadata['inner-window-id'] + if (innerWindowID) { + addContentGlobal({ + global: sandbox, + 'inner-window-id': innerWindowID + }); + } + return sandbox; +} +exports.sandbox = sandbox; + +/** + * Evaluates given `source` in a given `sandbox` and returns result. + */ +function evaluate(sandbox, code, uri, line, version) { + return Cu.evalInSandbox(code, sandbox, version || '1.8', uri || '', line || 1); +} +exports.evaluate = evaluate; + +/** + * Evaluates code under the given `uri` in the given `sandbox`. + * + * @param {String} uri + * The URL pointing to the script to load. + * It must be a local chrome:, resource:, file: or data: URL. + */ +function load(sandbox, uri) { + if (uri.indexOf('data:') === 0) { + let source = uri.substr(uri.indexOf(',') + 1); + + return evaluate(sandbox, decodeURIComponent(source), '1.8', uri, 0); + } else { + return scriptLoader.loadSubScript(uri, sandbox, 'UTF-8'); + } +} +exports.load = load; + +/** + * Forces the given `sandbox` to be freed immediately. + */ +exports.nuke = Cu.nukeSandbox diff --git a/addon-sdk/source/lib/sdk/messaging.js b/addon-sdk/source/lib/sdk/messaging.js new file mode 100644 index 000000000..07580eb33 --- /dev/null +++ b/addon-sdk/source/lib/sdk/messaging.js @@ -0,0 +1,12 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { window } = require("sdk/addon/window"); +exports.MessageChannel = window.MessageChannel; +exports.MessagePort = window.MessagePort; diff --git a/addon-sdk/source/lib/sdk/model/core.js b/addon-sdk/source/lib/sdk/model/core.js new file mode 100644 index 000000000..315f8b1cd --- /dev/null +++ b/addon-sdk/source/lib/sdk/model/core.js @@ -0,0 +1,23 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { dispatcher } = require("../util/dispatcher"); + + +// Define `modelFor` accessor function that can be implemented +// for different types of views. Since view's we'll be dealing +// with types that don't really play well with `instanceof` +// operator we're gonig to use `dispatcher` that is slight +// extension over polymorphic dispatch provided by method. +// This allows models to extend implementations of this by +// providing predicates: +// +// modelFor.when($ => $ && $.nodeName === "tab", findTabById($.id)) +const modelFor = dispatcher("modelFor"); +exports.modelFor = modelFor; diff --git a/addon-sdk/source/lib/sdk/net/url.js b/addon-sdk/source/lib/sdk/net/url.js new file mode 100644 index 000000000..5502171ee --- /dev/null +++ b/addon-sdk/source/lib/sdk/net/url.js @@ -0,0 +1,94 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Ci, Cu, components } = require("chrome"); + +const { defer } = require("../core/promise"); +const { merge } = require("../util/object"); + +const { NetUtil } = Cu.import("resource://gre/modules/NetUtil.jsm", {}); +const { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); + +/** + * Reads a URI and returns a promise. + * + * @param uri {string} The URI to read + * @param [options] {object} This parameter can have any or all of the following + * fields: `charset`. By default the `charset` is set to 'UTF-8'. + * + * @returns {promise} The promise that will be resolved with the content of the + * URL given. + * + * @example + * let promise = readURI('resource://gre/modules/NetUtil.jsm', { + * charset: 'US-ASCII' + * }); + */ +function readURI(uri, options) { + options = options || {}; + let charset = options.charset || 'UTF-8'; + + let channel = NetUtil.newChannel({ + uri: NetUtil.newURI(uri, charset), + loadUsingSystemPrincipal: true}); + + let { promise, resolve, reject } = defer(); + + try { + NetUtil.asyncFetch(channel, function (stream, result) { + if (components.isSuccessCode(result)) { + let count = stream.available(); + let data = NetUtil.readInputStreamToString(stream, count, { charset : charset }); + + resolve(data); + } else { + reject("Failed to read: '" + uri + "' (Error Code: " + result + ")"); + } + }); + } + catch (e) { + reject("Failed to read: '" + uri + "' (Error: " + e.message + ")"); + } + + return promise; +} + +exports.readURI = readURI; + +/** + * Reads a URI synchronously. + * This function is intentionally undocumented to favorites the `readURI` usage. + * + * @param uri {string} The URI to read + * @param [charset] {string} The character set to use when read the content of + * the `uri` given. By default is set to 'UTF-8'. + * + * @returns {string} The content of the URI given. + * + * @example + * let data = readURISync('resource://gre/modules/NetUtil.jsm'); + */ +function readURISync(uri, charset) { + charset = typeof charset === "string" ? charset : "UTF-8"; + + let channel = NetUtil.newChannel({ + uri: NetUtil.newURI(uri, charset), + loadUsingSystemPrincipal: true}); + let stream = channel.open2(); + + let count = stream.available(); + let data = NetUtil.readInputStreamToString(stream, count, { charset : charset }); + + stream.close(); + + return data; +} + +exports.readURISync = readURISync; diff --git a/addon-sdk/source/lib/sdk/net/xhr.js b/addon-sdk/source/lib/sdk/net/xhr.js new file mode 100644 index 000000000..415b9cbf4 --- /dev/null +++ b/addon-sdk/source/lib/sdk/net/xhr.js @@ -0,0 +1,36 @@ +/* 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"; + +module.metadata = { + "stability": "stable" +}; + +const { deprecateFunction } = require("../util/deprecate"); +const { Cc, Ci } = require("chrome"); +const XMLHttpRequest = require("../addon/window").window.XMLHttpRequest; + +Object.defineProperties(XMLHttpRequest.prototype, { + mozBackgroundRequest: { + value: true, + }, + forceAllowThirdPartyCookie: { + configurable: true, + value: deprecateFunction(function() { + forceAllowThirdPartyCookie(this); + + }, "`xhr.forceAllowThirdPartyCookie()` is deprecated, please use" + + "`require('sdk/net/xhr').forceAllowThirdPartyCookie(request)` instead") + } +}); +exports.XMLHttpRequest = XMLHttpRequest; + +function forceAllowThirdPartyCookie(xhr) { + if (xhr.channel instanceof Ci.nsIHttpChannelInternal) + xhr.channel.forceAllowThirdPartyCookie = true; +} +exports.forceAllowThirdPartyCookie = forceAllowThirdPartyCookie; + +// No need to handle add-on unloads as addon/window is closed at unload +// and it will take down all the associated requests. diff --git a/addon-sdk/source/lib/sdk/notifications.js b/addon-sdk/source/lib/sdk/notifications.js new file mode 100644 index 000000000..752e08fb1 --- /dev/null +++ b/addon-sdk/source/lib/sdk/notifications.js @@ -0,0 +1,112 @@ +/* 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"; + +module.metadata = { + "stability": "stable" +}; + +const { Cc, Ci, Cr } = require("chrome"); +const apiUtils = require("./deprecated/api-utils"); +const { isString, isUndefined, instanceOf } = require('./lang/type'); +const { URL, isLocalURL } = require('./url'); +const { data } = require('./self'); + +const NOTIFICATION_DIRECTIONS = ["auto", "ltr", "rtl"]; + +try { + let alertServ = Cc["@mozilla.org/alerts-service;1"]. + getService(Ci.nsIAlertsService); + + // The unit test sets this to a mock notification function. + var notify = alertServ.showAlertNotification.bind(alertServ); +} +catch (err) { + // An exception will be thrown if the platform doesn't provide an alert + // service, e.g., if Growl is not installed on OS X. In that case, use a + // mock notification function that just logs to the console. + notify = notifyUsingConsole; +} + +exports.notify = function notifications_notify(options) { + let valOpts = validateOptions(options); + let clickObserver = !valOpts.onClick ? null : { + observe: (subject, topic, data) => { + if (topic === "alertclickcallback") { + try { + valOpts.onClick.call(exports, valOpts.data); + } + catch(e) { + console.exception(e); + } + } + } + }; + function notifyWithOpts(notifyFn) { + let { iconURL } = valOpts; + iconURL = iconURL && isLocalURL(iconURL) ? data.url(iconURL) : iconURL; + + notifyFn(iconURL, valOpts.title, valOpts.text, !!clickObserver, + valOpts.data, clickObserver, valOpts.tag, valOpts.dir, valOpts.lang); + } + try { + notifyWithOpts(notify); + } + catch (err) { + if (err instanceof Ci.nsIException && err.result == Cr.NS_ERROR_FILE_NOT_FOUND) { + console.warn("The notification icon named by " + iconURL + + " does not exist. A default icon will be used instead."); + delete valOpts.iconURL; + notifyWithOpts(notify); + } + else { + notifyWithOpts(notifyUsingConsole); + } + } +}; + +function notifyUsingConsole(iconURL, title, text) { + title = title ? "[" + title + "]" : ""; + text = text || ""; + let str = [title, text].filter(s => s).join(" "); + console.log(str); +} + +function validateOptions(options) { + return apiUtils.validateOptions(options, { + data: { + is: ["string", "undefined"] + }, + iconURL: { + is: ["string", "undefined", "object"], + ok: function(value) { + return isUndefined(value) || isString(value) || (value instanceof URL); + }, + msg: "`iconURL` must be a string or an URL instance." + }, + onClick: { + is: ["function", "undefined"] + }, + text: { + is: ["string", "undefined", "number"] + }, + title: { + is: ["string", "undefined", "number"] + }, + tag: { + is: ["string", "undefined", "number"] + }, + dir: { + is: ["string", "undefined"], + ok: function(value) { + return isUndefined(value) || ~NOTIFICATION_DIRECTIONS.indexOf(value); + }, + msg: '`dir` option must be one of: "auto", "ltr" or "rtl".' + }, + lang: { + is: ["string", "undefined"] + } + }); +} diff --git a/addon-sdk/source/lib/sdk/output/system.js b/addon-sdk/source/lib/sdk/output/system.js new file mode 100644 index 000000000..4fb16dcd5 --- /dev/null +++ b/addon-sdk/source/lib/sdk/output/system.js @@ -0,0 +1,71 @@ +/* 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, Ci, Cr } = require("chrome"); +const { Input, start, stop, receive, outputs } = require("../event/utils"); +const { id: addonID } = require("../self"); +const { setImmediate } = require("../timers"); +const { notifyObservers } = Cc['@mozilla.org/observer-service;1']. + getService(Ci.nsIObserverService); + +const NOT_AN_INPUT = "OutputPort can be used only for sending messages"; + +// `OutputPort` creates a port to which messages can be send. Those +// messages are actually disptached as `subject`'s of the observer +// notifications. This is handy for communicating between different +// components of the SDK. By default messages are dispatched +// asynchronously, although `options.sync` can be used to make them +// synchronous. If `options.id` is given `topic` for observer +// notifications is generated by namespacing it, to avoid spamming +// other SDK add-ons. It's also possible to provide `options.topic` +// to use excat `topic` without namespacing it. +// +// Note: Symmetric `new InputPort({ id: "x" })` instances can be used to +// receive messages send to the instances of `new OutputPort({ id: "x" })`. +const OutputPort = function({id, topic, sync}) { + this.id = id || topic; + this.sync = !!sync; + this.topic = topic || "sdk:" + addonID + ":" + id; +}; +// OutputPort extends base signal type to implement same message +// receiving interface. +OutputPort.prototype = new Input(); +OutputPort.constructor = OutputPort; + +// OutputPort can not be consumed there for starting or stopping it +// is not supported. +OutputPort.prototype[start] = _ => { throw TypeError(NOT_AN_INPUT); }; +OutputPort.prototype[stop] = _ => { throw TypeError(NOT_AN_INPUT); }; + +// Port reecives message send to it, which will be dispatched via +// observer notification service. +OutputPort.receive = ({topic, sync}, message) => { + const type = typeof(message); + const supported = message === null || + type === "object" || + type === "function"; + + // There is no sensible way to wrap JS primitives that would make sense + // for general observer notification users. It's also probably not very + // useful to dispatch JS primitives as subject of observer service, there + // for we do not support those use cases. + if (!supported) + throw new TypeError("Unsupproted message type: `" + type + "`"); + + // Normalize `message` to create a valid observer notification `subject`. + // If `message` is `null`, implements `nsISupports` interface or already + // represents wrapped JS object use it as is. Otherwise create a wrapped + // object so that observers could receive it. + const subject = message === null ? null : + message instanceof Ci.nsISupports ? message : + message.wrappedJSObject ? message : + {wrappedJSObject: message}; + if (sync) + notifyObservers(subject, topic, null); + else + setImmediate(notifyObservers, subject, topic, null); +}; +OutputPort.prototype[receive] = OutputPort.receive; +exports.OutputPort = OutputPort; diff --git a/addon-sdk/source/lib/sdk/page-mod.js b/addon-sdk/source/lib/sdk/page-mod.js new file mode 100644 index 000000000..538be2732 --- /dev/null +++ b/addon-sdk/source/lib/sdk/page-mod.js @@ -0,0 +1,190 @@ +/* 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"; + +module.metadata = { + "stability": "stable" +}; + +const { contract: loaderContract } = require('./content/loader'); +const { contract } = require('./util/contract'); +const { WorkerHost, connect } = require('./content/utils'); +const { Class } = require('./core/heritage'); +const { Disposable } = require('./core/disposable'); +const { Worker } = require('./content/worker'); +const { EventTarget } = require('./event/target'); +const { on, emit, once, setListeners } = require('./event/core'); +const { isRegExp, isUndefined } = require('./lang/type'); +const { merge, omit } = require('./util/object'); +const { remove, has, hasAny } = require("./util/array"); +const { Rules } = require("./util/rules"); +const { processes, frames, remoteRequire } = require('./remote/parent'); +remoteRequire('sdk/content/page-mod'); + +const pagemods = new Map(); +const workers = new Map(); +const models = new WeakMap(); +var modelFor = (mod) => models.get(mod); +var workerFor = (mod) => workers.get(mod)[0]; + +// Helper functions +var isRegExpOrString = (v) => isRegExp(v) || typeof v === 'string'; + +var PAGEMOD_ID = 0; + +// Validation Contracts +const modOptions = { + // contentStyle* / contentScript* are sharing the same validation constraints, + // so they can be mostly reused, except for the messages. + contentStyle: merge(Object.create(loaderContract.rules.contentScript), { + msg: 'The `contentStyle` option must be a string or an array of strings.' + }), + contentStyleFile: merge(Object.create(loaderContract.rules.contentScriptFile), { + msg: 'The `contentStyleFile` option must be a local URL or an array of URLs' + }), + include: { + is: ['string', 'array', 'regexp'], + ok: (rule) => { + if (isRegExpOrString(rule)) + return true; + if (Array.isArray(rule) && rule.length > 0) + return rule.every(isRegExpOrString); + return false; + }, + msg: 'The `include` option must always contain atleast one rule as a string, regular expression, or an array of strings and regular expressions.' + }, + exclude: { + is: ['string', 'array', 'regexp', 'undefined'], + ok: (rule) => { + if (isRegExpOrString(rule) || isUndefined(rule)) + return true; + if (Array.isArray(rule) && rule.length > 0) + return rule.every(isRegExpOrString); + return false; + }, + msg: 'If set, the `exclude` option must always contain at least one ' + + 'rule as a string, regular expression, or an array of strings and ' + + 'regular expressions.' + }, + attachTo: { + is: ['string', 'array', 'undefined'], + map: function (attachTo) { + if (!attachTo) return ['top', 'frame']; + if (typeof attachTo === 'string') return [attachTo]; + return attachTo; + }, + ok: function (attachTo) { + return hasAny(attachTo, ['top', 'frame']) && + attachTo.every(has.bind(null, ['top', 'frame', 'existing'])); + }, + msg: 'The `attachTo` option must be a string or an array of strings. ' + + 'The only valid options are "existing", "top" and "frame", and must ' + + 'contain at least "top" or "frame" values.' + }, +}; + +const modContract = contract(merge({}, loaderContract.rules, modOptions)); + +/** + * PageMod constructor (exported below). + * @constructor + */ +const PageMod = Class({ + implements: [ + modContract.properties(modelFor), + EventTarget, + Disposable, + ], + extends: WorkerHost(workerFor), + setup: function PageMod(options) { + let mod = this; + let model = modContract(options); + models.set(this, model); + model.id = PAGEMOD_ID++; + + let include = model.include; + model.include = Rules(); + model.include.add.apply(model.include, [].concat(include)); + + let exclude = isUndefined(model.exclude) ? [] : model.exclude; + model.exclude = Rules(); + model.exclude.add.apply(model.exclude, [].concat(exclude)); + + // Set listeners on {PageMod} itself, not the underlying worker, + // like `onMessage`, as it'll get piped. + setListeners(this, options); + + pagemods.set(model.id, this); + workers.set(this, []); + + function serializeRules(rules) { + for (let rule of rules) { + yield isRegExp(rule) ? { type: "regexp", pattern: rule.source, flags: rule.flags } + : { type: "string", value: rule }; + } + } + + model.childOptions = omit(model, ["include", "exclude", "contentScriptOptions"]); + model.childOptions.include = [...serializeRules(model.include)]; + model.childOptions.exclude = [...serializeRules(model.exclude)]; + model.childOptions.contentScriptOptions = model.contentScriptOptions ? + JSON.stringify(model.contentScriptOptions) : + null; + + processes.port.emit('sdk/page-mod/create', model.childOptions); + }, + + dispose: function(reason) { + processes.port.emit('sdk/page-mod/destroy', modelFor(this).id); + pagemods.delete(modelFor(this).id); + workers.delete(this); + }, + + destroy: function(reason) { + // Explicit destroy call, i.e. not via unload so destroy the workers + let list = workers.get(this); + if (!list) + return; + + // Triggers dispose which will cause the child page-mod to be destroyed + Disposable.prototype.destroy.call(this, reason); + + // Destroy any active workers + for (let worker of list) + worker.destroy(reason); + } +}); +exports.PageMod = PageMod; + +// Whenever a new process starts send over the list of page-mods +processes.forEvery(process => { + for (let mod of pagemods.values()) + process.port.emit('sdk/page-mod/create', modelFor(mod).childOptions); +}); + +frames.port.on('sdk/page-mod/worker-create', (frame, modId, workerOptions) => { + let mod = pagemods.get(modId); + if (!mod) + return; + + // Attach the parent side of the worker to the child + let worker = Worker(); + + workers.get(mod).unshift(worker); + worker.on('*', (event, ...args) => { + // page-mod's "attach" event needs to be passed a worker + if (event === 'attach') + emit(mod, event, worker) + else + emit(mod, event, ...args); + }); + + worker.on('detach', () => { + let array = workers.get(mod); + if (array) + remove(array, worker); + }); + + connect(worker, frame, workerOptions); +}); diff --git a/addon-sdk/source/lib/sdk/page-mod/match-pattern.js b/addon-sdk/source/lib/sdk/page-mod/match-pattern.js new file mode 100644 index 000000000..afbbd401e --- /dev/null +++ b/addon-sdk/source/lib/sdk/page-mod/match-pattern.js @@ -0,0 +1,10 @@ +/* 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 { deprecateUsage } = require("../util/deprecate"); + +deprecateUsage("Module 'sdk/page-mod/match-pattern' is deprecated use 'sdk/util/match-pattern' instead"); + +module.exports = require("../util/match-pattern"); diff --git a/addon-sdk/source/lib/sdk/page-worker.js b/addon-sdk/source/lib/sdk/page-worker.js new file mode 100644 index 000000000..837cf774b --- /dev/null +++ b/addon-sdk/source/lib/sdk/page-worker.js @@ -0,0 +1,194 @@ +/* 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"; + +module.metadata = { + "stability": "stable" +}; + +const { Class } = require('./core/heritage'); +const { ns } = require('./core/namespace'); +const { pipe, stripListeners } = require('./event/utils'); +const { connect, destroy, WorkerHost } = require('./content/utils'); +const { Worker } = require('./content/worker'); +const { Disposable } = require('./core/disposable'); +const { EventTarget } = require('./event/target'); +const { setListeners } = require('./event/core'); +const { window } = require('./addon/window'); +const { create: makeFrame, getDocShell } = require('./frame/utils'); +const { contract } = require('./util/contract'); +const { contract: loaderContract } = require('./content/loader'); +const { Rules } = require('./util/rules'); +const { merge } = require('./util/object'); +const { uuid } = require('./util/uuid'); +const { useRemoteProcesses, remoteRequire, frames } = require("./remote/parent"); +remoteRequire("sdk/content/page-worker"); + +const workers = new WeakMap(); +const pages = new Map(); + +const internal = ns(); + +let workerFor = (page) => workers.get(page); +let isDisposed = (page) => !pages.has(internal(page).id); + +// The frame is used to ensure we have a remote process to load workers in +let remoteFrame = null; +let framePromise = null; +function getFrame() { + if (framePromise) + return framePromise; + + framePromise = new Promise(resolve => { + let view = makeFrame(window.document, { + namespaceURI: "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", + nodeName: "iframe", + type: "content", + remote: useRemoteProcesses, + uri: "about:blank" + }); + + // Wait for the remote side to connect + let listener = (frame) => { + if (frame.frameElement != view) + return; + frames.off("attach", listener); + remoteFrame = frame; + resolve(frame); + } + frames.on("attach", listener); + }); + return framePromise; +} + +var pageContract = contract(merge({ + allow: { + is: ['object', 'undefined', 'null'], + map: function (allow) { return { script: !allow || allow.script !== false }} + }, + onMessage: { + is: ['function', 'undefined'] + }, + include: { + is: ['string', 'array', 'regexp', 'undefined'] + }, + contentScriptWhen: { + is: ['string', 'undefined'], + map: (when) => when || "end" + } +}, loaderContract.rules)); + +function enableScript (page) { + getDocShell(viewFor(page)).allowJavascript = true; +} + +function disableScript (page) { + getDocShell(viewFor(page)).allowJavascript = false; +} + +function Allow (page) { + return { + get script() { + return internal(page).options.allow.script; + }, + set script(value) { + internal(page).options.allow.script = value; + + if (isDisposed(page)) + return; + + remoteFrame.port.emit("sdk/frame/set", internal(page).id, { allowScript: value }); + } + }; +} + +function isValidURL(page, url) { + return !page.rules || page.rules.matchesAny(url); +} + +const Page = Class({ + implements: [ + EventTarget, + Disposable + ], + extends: WorkerHost(workerFor), + setup: function Page(options) { + options = pageContract(options); + // Sanitize the options + if ("contentScriptOptions" in options) + options.contentScriptOptions = JSON.stringify(options.contentScriptOptions); + + internal(this).id = uuid().toString(); + internal(this).options = options; + + for (let prop of ['contentScriptFile', 'contentScript', 'contentScriptWhen']) { + this[prop] = options[prop]; + } + + pages.set(internal(this).id, this); + + // Set listeners on the {Page} object itself, not the underlying worker, + // like `onMessage`, as it gets piped + setListeners(this, options); + let worker = new Worker(stripListeners(options)); + workers.set(this, worker); + pipe(worker, this); + + if (options.include) { + this.rules = Rules(); + this.rules.add.apply(this.rules, [].concat(options.include)); + } + + getFrame().then(frame => { + if (isDisposed(this)) + return; + + frame.port.emit("sdk/frame/create", internal(this).id, stripListeners(options)); + }); + }, + get allow() { return Allow(this); }, + set allow(value) { + if (isDisposed(this)) + return; + this.allow.script = pageContract({ allow: value }).allow.script; + }, + get contentURL() { + return internal(this).options.contentURL; + }, + set contentURL(value) { + if (!isValidURL(this, value)) + return; + internal(this).options.contentURL = value; + if (isDisposed(this)) + return; + + remoteFrame.port.emit("sdk/frame/set", internal(this).id, { contentURL: value }); + }, + dispose: function () { + if (isDisposed(this)) + return; + pages.delete(internal(this).id); + let worker = workerFor(this); + if (worker) + destroy(worker); + remoteFrame.port.emit("sdk/frame/destroy", internal(this).id); + + // Destroy the remote frame if all the pages have been destroyed + if (pages.size == 0) { + framePromise = null; + remoteFrame.frameElement.remove(); + remoteFrame = null; + } + }, + toString: function () { return '[object Page]' } +}); + +exports.Page = Page; + +frames.port.on("sdk/frame/connect", (frame, id, params) => { + let page = pages.get(id); + if (!page) + return; + connect(workerFor(page), frame, params); +}); diff --git a/addon-sdk/source/lib/sdk/panel.js b/addon-sdk/source/lib/sdk/panel.js new file mode 100644 index 000000000..4b625799d --- /dev/null +++ b/addon-sdk/source/lib/sdk/panel.js @@ -0,0 +1,427 @@ +/* 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"; + +// The panel module currently supports only Firefox and SeaMonkey. +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=jetpack-panel-apps +module.metadata = { + "stability": "stable", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cu, Ci } = require("chrome"); +const { setTimeout } = require('./timers'); +const { Class } = require("./core/heritage"); +const { merge } = require("./util/object"); +const { WorkerHost } = require("./content/utils"); +const { Worker } = require("./deprecated/sync-worker"); +const { Disposable } = require("./core/disposable"); +const { WeakReference } = require('./core/reference'); +const { contract: loaderContract } = require("./content/loader"); +const { contract } = require("./util/contract"); +const { on, off, emit, setListeners } = require("./event/core"); +const { EventTarget } = require("./event/target"); +const domPanel = require("./panel/utils"); +const { getDocShell } = require('./frame/utils'); +const { events } = require("./panel/events"); +const systemEvents = require("./system/events"); +const { filter, pipe, stripListeners } = require("./event/utils"); +const { getNodeView, getActiveView } = require("./view/core"); +const { isNil, isObject, isNumber } = require("./lang/type"); +const { getAttachEventType } = require("./content/utils"); +const { number, boolean, object } = require('./deprecated/api-utils'); +const { Style } = require("./stylesheet/style"); +const { attach, detach } = require("./content/mod"); + +var isRect = ({top, right, bottom, left}) => [top, right, bottom, left]. + some(value => isNumber(value) && !isNaN(value)); + +var isSDKObj = obj => obj instanceof Class; + +var rectContract = contract({ + top: number, + right: number, + bottom: number, + left: number +}); + +var position = { + is: object, + map: v => (isNil(v) || isSDKObj(v) || !isObject(v)) ? v : rectContract(v), + ok: v => isNil(v) || isSDKObj(v) || (isObject(v) && isRect(v)), + msg: 'The option "position" must be a SDK object registered as anchor; ' + + 'or an object with one or more of the following keys set to numeric ' + + 'values: top, right, bottom, left.' +} + +var displayContract = contract({ + width: number, + height: number, + focus: boolean, + position: position +}); + +var panelContract = contract(merge({ + // contentStyle* / contentScript* are sharing the same validation constraints, + // so they can be mostly reused, except for the messages. + contentStyle: merge(Object.create(loaderContract.rules.contentScript), { + msg: 'The `contentStyle` option must be a string or an array of strings.' + }), + contentStyleFile: merge(Object.create(loaderContract.rules.contentScriptFile), { + msg: 'The `contentStyleFile` option must be a local URL or an array of URLs' + }), + contextMenu: boolean, + allow: { + is: ['object', 'undefined', 'null'], + map: function (allow) { return { script: !allow || allow.script !== false }} + }, +}, displayContract.rules, loaderContract.rules)); + +function Allow(panel) { + return { + get script() { return getDocShell(viewFor(panel).backgroundFrame).allowJavascript; }, + set script(value) { return setScriptState(panel, value); }, + }; +} + +function setScriptState(panel, value) { + let view = viewFor(panel); + getDocShell(view.backgroundFrame).allowJavascript = value; + getDocShell(view.viewFrame).allowJavascript = value; + view.setAttribute("sdkscriptenabled", "" + value); +} + +function isDisposed(panel) { + return !views.has(panel); +} + +var panels = new WeakMap(); +var models = new WeakMap(); +var views = new WeakMap(); +var workers = new WeakMap(); +var styles = new WeakMap(); + +const viewFor = (panel) => views.get(panel); +const modelFor = (panel) => models.get(panel); +const panelFor = (view) => panels.get(view); +const workerFor = (panel) => workers.get(panel); +const styleFor = (panel) => styles.get(panel); + +function getPanelFromWeakRef(weakRef) { + if (!weakRef) { + return null; + } + let panel = weakRef.get(); + if (!panel) { + return null; + } + if (isDisposed(panel)) { + return null; + } + return panel; +} + +var SinglePanelManager = { + visiblePanel: null, + enqueuedPanel: null, + enqueuedPanelCallback: null, + // Calls |callback| with no arguments when the panel may be shown. + requestOpen: function(panelToOpen, callback) { + let currentPanel = getPanelFromWeakRef(SinglePanelManager.visiblePanel); + if (currentPanel || SinglePanelManager.enqueuedPanel) { + SinglePanelManager.enqueuedPanel = Cu.getWeakReference(panelToOpen); + SinglePanelManager.enqueuedPanelCallback = callback; + if (currentPanel && currentPanel.isShowing) { + currentPanel.hide(); + } + } else { + SinglePanelManager.notifyPanelCanOpen(panelToOpen, callback); + } + }, + notifyPanelCanOpen: function(panel, callback) { + let view = viewFor(panel); + // Can't pass an arrow function as the event handler because we need to be + // able to call |removeEventListener| later. + view.addEventListener("popuphidden", SinglePanelManager.onVisiblePanelHidden, true); + view.addEventListener("popupshown", SinglePanelManager.onVisiblePanelShown, false); + SinglePanelManager.enqueuedPanel = null; + SinglePanelManager.enqueuedPanelCallback = null; + SinglePanelManager.visiblePanel = Cu.getWeakReference(panel); + callback(); + }, + onVisiblePanelShown: function(event) { + let panel = panelFor(event.target); + if (SinglePanelManager.enqueuedPanel) { + // Another panel started waiting for |panel| to close before |panel| was + // even done opening. + panel.hide(); + } + }, + onVisiblePanelHidden: function(event) { + let view = event.target; + let panel = panelFor(view); + let currentPanel = getPanelFromWeakRef(SinglePanelManager.visiblePanel); + if (currentPanel && currentPanel != panel) { + return; + } + SinglePanelManager.visiblePanel = null; + view.removeEventListener("popuphidden", SinglePanelManager.onVisiblePanelHidden, true); + view.removeEventListener("popupshown", SinglePanelManager.onVisiblePanelShown, false); + let nextPanel = getPanelFromWeakRef(SinglePanelManager.enqueuedPanel); + let nextPanelCallback = SinglePanelManager.enqueuedPanelCallback; + if (nextPanel) { + SinglePanelManager.notifyPanelCanOpen(nextPanel, nextPanelCallback); + } + } +}; + +const Panel = Class({ + implements: [ + // Generate accessors for the validated properties that update model on + // set and return values from model on get. + panelContract.properties(modelFor), + EventTarget, + Disposable, + WeakReference + ], + extends: WorkerHost(workerFor), + setup: function setup(options) { + let model = merge({ + defaultWidth: 320, + defaultHeight: 240, + focus: true, + position: Object.freeze({}), + contextMenu: false + }, panelContract(options)); + model.ready = false; + models.set(this, model); + + if (model.contentStyle || model.contentStyleFile) { + styles.set(this, Style({ + uri: model.contentStyleFile, + source: model.contentStyle + })); + } + + // Setup view + let viewOptions = {allowJavascript: !model.allow || (model.allow.script !== false)}; + let view = domPanel.make(null, viewOptions); + panels.set(view, this); + views.set(this, view); + + // Load panel content. + domPanel.setURL(view, model.contentURL); + + // Allow context menu + domPanel.allowContextMenu(view, model.contextMenu); + + // Setup listeners. + setListeners(this, options); + let worker = new Worker(stripListeners(options)); + workers.set(this, worker); + + // pipe events from worker to a panel. + pipe(worker, this); + }, + dispose: function dispose() { + this.hide(); + off(this); + + workerFor(this).destroy(); + detach(styleFor(this)); + + domPanel.dispose(viewFor(this)); + + // Release circular reference between view and panel instance. This + // way view will be GC-ed. And panel as well once all the other refs + // will be removed from it. + views.delete(this); + }, + /* Public API: Panel.width */ + get width() { + return modelFor(this).width; + }, + set width(value) { + this.resize(value, this.height); + }, + /* Public API: Panel.height */ + get height() { + return modelFor(this).height; + }, + set height(value) { + this.resize(this.width, value); + }, + + /* Public API: Panel.focus */ + get focus() { + return modelFor(this).focus; + }, + + /* Public API: Panel.position */ + get position() { + return modelFor(this).position; + }, + + /* Public API: Panel.contextMenu */ + get contextMenu() { + return modelFor(this).contextMenu; + }, + set contextMenu(allow) { + let model = modelFor(this); + model.contextMenu = panelContract({ contextMenu: allow }).contextMenu; + domPanel.allowContextMenu(viewFor(this), model.contextMenu); + }, + + get contentURL() { + return modelFor(this).contentURL; + }, + set contentURL(value) { + let model = modelFor(this); + model.contentURL = panelContract({ contentURL: value }).contentURL; + domPanel.setURL(viewFor(this), model.contentURL); + // Detach worker so that messages send will be queued until it's + // reatached once panel content is ready. + workerFor(this).detach(); + }, + + get allow() { return Allow(this); }, + set allow(value) { + let allowJavascript = panelContract({ allow: value }).allow.script; + return setScriptState(this, value); + }, + + /* Public API: Panel.isShowing */ + get isShowing() { + return !isDisposed(this) && domPanel.isOpen(viewFor(this)); + }, + + /* Public API: Panel.show */ + show: function show(options={}, anchor) { + SinglePanelManager.requestOpen(this, () => { + if (options instanceof Ci.nsIDOMElement) { + [anchor, options] = [options, null]; + } + + if (anchor instanceof Ci.nsIDOMElement) { + console.warn( + "Passing a DOM node to Panel.show() method is an unsupported " + + "feature that will be soon replaced. " + + "See: https://bugzilla.mozilla.org/show_bug.cgi?id=878877" + ); + } + + let model = modelFor(this); + let view = viewFor(this); + let anchorView = getNodeView(anchor || options.position || model.position); + + options = merge({ + position: model.position, + width: model.width, + height: model.height, + defaultWidth: model.defaultWidth, + defaultHeight: model.defaultHeight, + focus: model.focus, + contextMenu: model.contextMenu + }, displayContract(options)); + + if (!isDisposed(this)) { + domPanel.show(view, options, anchorView); + } + }); + return this; + }, + + /* Public API: Panel.hide */ + hide: function hide() { + // Quit immediately if panel is disposed or there is no state change. + domPanel.close(viewFor(this)); + + return this; + }, + + /* Public API: Panel.resize */ + resize: function resize(width, height) { + let model = modelFor(this); + let view = viewFor(this); + let change = panelContract({ + width: width || model.width || model.defaultWidth, + height: height || model.height || model.defaultHeight + }); + + model.width = change.width + model.height = change.height + + domPanel.resize(view, model.width, model.height); + + return this; + } +}); +exports.Panel = Panel; + +// Note must be defined only after value to `Panel` is assigned. +getActiveView.define(Panel, viewFor); + +// Filter panel events to only panels that are create by this module. +var panelEvents = filter(events, ({target}) => panelFor(target)); + +// Panel events emitted after panel has being shown. +var shows = filter(panelEvents, ({type}) => type === "popupshown"); + +// Panel events emitted after panel became hidden. +var hides = filter(panelEvents, ({type}) => type === "popuphidden"); + +// Panel events emitted after content inside panel is ready. For different +// panels ready may mean different state based on `contentScriptWhen` attribute. +// Weather given event represents readyness is detected by `getAttachEventType` +// helper function. +var ready = filter(panelEvents, ({type, target}) => + getAttachEventType(modelFor(panelFor(target))) === type); + +// Panel event emitted when the contents of the panel has been loaded. +var readyToShow = filter(panelEvents, ({type}) => type === "DOMContentLoaded"); + +// Styles should be always added as soon as possible, and doesn't makes them +// depends on `contentScriptWhen` +var start = filter(panelEvents, ({type}) => type === "document-element-inserted"); + +// Forward panel show / hide events to panel's own event listeners. +on(shows, "data", ({target}) => { + let panel = panelFor(target); + if (modelFor(panel).ready) + emit(panel, "show"); +}); + +on(hides, "data", ({target}) => { + let panel = panelFor(target); + if (modelFor(panel).ready) + emit(panel, "hide"); +}); + +on(ready, "data", ({target}) => { + let panel = panelFor(target); + let window = domPanel.getContentDocument(target).defaultView; + + workerFor(panel).attach(window); +}); + +on(readyToShow, "data", ({target}) => { + let panel = panelFor(target); + + if (!modelFor(panel).ready) { + modelFor(panel).ready = true; + + if (viewFor(panel).state == "open") + emit(panel, "show"); + } +}); + +on(start, "data", ({target}) => { + let panel = panelFor(target); + let window = domPanel.getContentDocument(target).defaultView; + + attach(styleFor(panel), window); +}); diff --git a/addon-sdk/source/lib/sdk/panel/events.js b/addon-sdk/source/lib/sdk/panel/events.js new file mode 100644 index 000000000..f3040a11d --- /dev/null +++ b/addon-sdk/source/lib/sdk/panel/events.js @@ -0,0 +1,27 @@ +/* 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 basically translates system/events to a SDK standard events +// so that `map`, `filter` and other utilities could be used with them. + +module.metadata = { + "stability": "experimental" +}; + +const events = require("../system/events"); +const { emit } = require("../event/core"); + +var channel = {}; + +function forward({ subject, type, data }) { + return emit(channel, "data", { target: subject, type: type, data: data }); +} + +["popupshowing", "popuphiding", "popupshown", "popuphidden", +"document-element-inserted", "DOMContentLoaded", "load" +].forEach(type => events.on(type, forward)); + +exports.events = channel; diff --git a/addon-sdk/source/lib/sdk/panel/utils.js b/addon-sdk/source/lib/sdk/panel/utils.js new file mode 100644 index 000000000..c85b274bc --- /dev/null +++ b/addon-sdk/source/lib/sdk/panel/utils.js @@ -0,0 +1,451 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci } = require("chrome"); +const { Services } = require("resource://gre/modules/Services.jsm"); +const { setTimeout } = require("../timers"); +const { platform } = require("../system"); +const { getMostRecentBrowserWindow, getOwnerBrowserWindow, + getHiddenWindow, getScreenPixelsPerCSSPixel } = require("../window/utils"); + +const { create: createFrame, swapFrameLoaders, getDocShell } = require("../frame/utils"); +const { window: addonWindow } = require("../addon/window"); +const { isNil } = require("../lang/type"); +const { data } = require('../self'); + +const events = require("../system/events"); + + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +function calculateRegion({ position, width, height, defaultWidth, defaultHeight }, rect) { + position = position || {}; + + let x, y; + + let hasTop = !isNil(position.top); + let hasRight = !isNil(position.right); + let hasBottom = !isNil(position.bottom); + let hasLeft = !isNil(position.left); + let hasWidth = !isNil(width); + let hasHeight = !isNil(height); + + // if width is not specified by constructor or show's options, then get + // the default width + if (!hasWidth) + width = defaultWidth; + + // if height is not specified by constructor or show's options, then get + // the default height + if (!hasHeight) + height = defaultHeight; + + // default position is centered + x = (rect.right - width) / 2; + y = (rect.top + rect.bottom - height) / 2; + + if (hasTop) { + y = rect.top + position.top; + + if (hasBottom && !hasHeight) + height = rect.bottom - position.bottom - y; + } + else if (hasBottom) { + y = rect.bottom - position.bottom - height; + } + + if (hasLeft) { + x = position.left; + + if (hasRight && !hasWidth) + width = rect.right - position.right - x; + } + else if (hasRight) { + x = rect.right - width - position.right; + } + + return {x: x, y: y, width: width, height: height}; +} + +function open(panel, options, anchor) { + // Wait for the XBL binding to be constructed + if (!panel.openPopup) setTimeout(open, 50, panel, options, anchor); + else display(panel, options, anchor); +} +exports.open = open; + +function isOpen(panel) { + return panel.state === "open" +} +exports.isOpen = isOpen; + +function isOpening(panel) { + return panel.state === "showing" +} +exports.isOpening = isOpening + +function close(panel) { + // Sometimes "TypeError: panel.hidePopup is not a function" is thrown + // when quitting the host application while a panel is visible. To suppress + // these errors, check for "hidePopup" in panel before calling it. + // It's not clear if there's an issue or it's expected behavior. + // See Bug 1151796. + + return panel.hidePopup && panel.hidePopup(); +} +exports.close = close + + +function resize(panel, width, height) { + // Resize the iframe instead of using panel.sizeTo + // because sizeTo doesn't work with arrow panels + if (panel.firstChild) { + panel.firstChild.style.width = width + "px"; + panel.firstChild.style.height = height + "px"; + } +} +exports.resize = resize + +function display(panel, options, anchor) { + let document = panel.ownerDocument; + + let x, y; + let { width, height, defaultWidth, defaultHeight } = options; + + let popupPosition = null; + + // Panel XBL has some SDK incompatible styling decisions. We shim panel + // instances until proper fix for Bug 859504 is shipped. + shimDefaultStyle(panel); + + if (!anchor) { + // The XUL Panel doesn't have an arrow, so the margin needs to be reset + // in order to, be positioned properly + panel.style.margin = "0"; + + let viewportRect = document.defaultView.gBrowser.getBoundingClientRect(); + + ({x, y, width, height} = calculateRegion(options, viewportRect)); + } + else { + // The XUL Panel has an arrow, so the margin needs to be reset + // to the default value. + panel.style.margin = ""; + let { CustomizableUI, window } = anchor.ownerDocument.defaultView; + + // In Australis, widgets may be positioned in an overflow panel or the + // menu panel. + // In such cases clicking this widget will hide the overflow/menu panel, + // and the widget's panel will show instead. + // If `CustomizableUI` is not available, it means the anchor is not in a + // chrome browser window, and therefore there is no need for this check. + if (CustomizableUI) { + let node = anchor; + ({anchor} = CustomizableUI.getWidget(anchor.id).forWindow(window)); + + // if `node` is not the `anchor` itself, it means the widget is + // positioned in a panel, therefore we have to hide it before show + // the widget's panel in the same anchor + if (node !== anchor) + CustomizableUI.hidePanelForNode(anchor); + } + + width = width || defaultWidth; + height = height || defaultHeight; + + // Open the popup by the anchor. + let rect = anchor.getBoundingClientRect(); + + let zoom = getScreenPixelsPerCSSPixel(window); + let screenX = rect.left + window.mozInnerScreenX * zoom; + let screenY = rect.top + window.mozInnerScreenY * zoom; + + // Set up the vertical position of the popup relative to the anchor + // (always display the arrow on anchor center) + let horizontal, vertical; + if (screenY > window.screen.availHeight / 2 + height) + vertical = "top"; + else + vertical = "bottom"; + + if (screenY > window.screen.availWidth / 2 + width) + horizontal = "left"; + else + horizontal = "right"; + + let verticalInverse = vertical == "top" ? "bottom" : "top"; + popupPosition = vertical + "center " + verticalInverse + horizontal; + + // Allow panel to flip itself if the panel can't be displayed at the + // specified position (useful if we compute a bad position or if the + // user moves the window and panel remains visible) + panel.setAttribute("flip", "both"); + } + + if (!panel.viewFrame) { + panel.viewFrame = document.importNode(panel.backgroundFrame, false); + panel.appendChild(panel.viewFrame); + + let {privateBrowsingId} = getDocShell(panel.viewFrame).getOriginAttributes(); + let principal = Services.scriptSecurityManager.createNullPrincipal({privateBrowsingId}); + getDocShell(panel.viewFrame).createAboutBlankContentViewer(principal); + } + + // Resize the iframe instead of using panel.sizeTo + // because sizeTo doesn't work with arrow panels + panel.firstChild.style.width = width + "px"; + panel.firstChild.style.height = height + "px"; + + panel.openPopup(anchor, popupPosition, x, y); +} +exports.display = display; + +// This utility function is just a workaround until Bug 859504 has shipped. +function shimDefaultStyle(panel) { + let document = panel.ownerDocument; + // Please note that `panel` needs to be part of document in order to reach + // it's anonymous nodes. One of the anonymous node has a big padding which + // doesn't work well since panel frame needs to fill all of the panel. + // XBL binding is a not the best option as it's applied asynchronously, and + // makes injected frames behave in strange way. Also this feels a lot + // cheaper to do. + ["panel-inner-arrowcontent", "panel-arrowcontent"].forEach(function(value) { + let node = document.getAnonymousElementByAttribute(panel, "class", value); + if (node) node.style.padding = 0; + }); +} + +function show(panel, options, anchor) { + // Prevent the panel from getting focus when showing up + // if focus is set to false + panel.setAttribute("noautofocus", !options.focus); + + let window = anchor && getOwnerBrowserWindow(anchor); + let { document } = window ? window : getMostRecentBrowserWindow(); + attach(panel, document); + + open(panel, options, anchor); +} +exports.show = show + +function onPanelClick(event) { + let { target, metaKey, ctrlKey, shiftKey, button } = event; + let accel = platform === "darwin" ? metaKey : ctrlKey; + let isLeftClick = button === 0; + let isMiddleClick = button === 1; + + if ((isLeftClick && (accel || shiftKey)) || isMiddleClick) { + let link = target.closest('a'); + + if (link && link.href) + getMostRecentBrowserWindow().openUILink(link.href, event) + } +} + +function setupPanelFrame(frame) { + frame.setAttribute("flex", 1); + frame.setAttribute("transparent", "transparent"); + frame.setAttribute("autocompleteenabled", true); + frame.setAttribute("tooltip", "aHTMLTooltip"); + if (platform === "darwin") { + frame.style.borderRadius = "var(--arrowpanel-border-radius, 3.5px)"; + frame.style.padding = "1px"; + } +} + +function make(document, options) { + document = document || getMostRecentBrowserWindow().document; + let panel = document.createElementNS(XUL_NS, "panel"); + panel.setAttribute("type", "arrow"); + panel.setAttribute("sdkscriptenabled", options.allowJavascript); + + // The panel needs to be attached to a browser window in order for us + // to copy browser styles to the content document when it loads. + attach(panel, document); + + let frameOptions = { + allowJavascript: options.allowJavascript, + allowPlugins: true, + allowAuth: true, + allowWindowControl: false, + // Need to override `nodeName` to use `iframe` as `browsers` save session + // history and in consequence do not dispatch "inner-window-destroyed" + // notifications. + browser: false, + }; + + let backgroundFrame = createFrame(addonWindow, frameOptions); + setupPanelFrame(backgroundFrame); + + getDocShell(backgroundFrame).inheritPrivateBrowsingId = false; + + function onPopupShowing({type, target}) { + if (target === this) { + let attrs = getDocShell(backgroundFrame).getOriginAttributes(); + getDocShell(panel.viewFrame).setOriginAttributes(attrs); + + swapFrameLoaders(backgroundFrame, panel.viewFrame); + } + } + + function onPopupHiding({type, target}) { + if (target === this) { + swapFrameLoaders(backgroundFrame, panel.viewFrame); + + panel.viewFrame.remove(); + panel.viewFrame = null; + } + } + + function onContentReady({target, type}) { + if (target === getContentDocument(panel)) { + style(panel); + events.emit(type, { subject: panel }); + } + } + + function onContentLoad({target, type}) { + if (target === getContentDocument(panel)) + events.emit(type, { subject: panel }); + } + + function onContentChange({subject: document, type}) { + if (document === getContentDocument(panel) && document.defaultView) + events.emit(type, { subject: panel }); + } + + function onPanelStateChange({target, type}) { + if (target === this) + events.emit(type, { subject: panel }) + } + + panel.addEventListener("popupshowing", onPopupShowing); + panel.addEventListener("popuphiding", onPopupHiding); + for (let event of ["popupshowing", "popuphiding", "popupshown", "popuphidden"]) + panel.addEventListener(event, onPanelStateChange); + + panel.addEventListener("click", onPanelClick, false); + + // Panel content document can be either in panel `viewFrame` or in + // a `backgroundFrame` depending on panel state. Listeners are set + // on both to avoid setting and removing listeners on panel state changes. + + panel.addEventListener("DOMContentLoaded", onContentReady, true); + backgroundFrame.addEventListener("DOMContentLoaded", onContentReady, true); + + panel.addEventListener("load", onContentLoad, true); + backgroundFrame.addEventListener("load", onContentLoad, true); + + events.on("document-element-inserted", onContentChange); + + panel.backgroundFrame = backgroundFrame; + panel.viewFrame = null; + + // Store event listener on the panel instance so that it won't be GC-ed + // while panel is alive. + panel.onContentChange = onContentChange; + + return panel; +} +exports.make = make; + +function attach(panel, document) { + document = document || getMostRecentBrowserWindow().document; + let container = document.getElementById("mainPopupSet"); + if (container !== panel.parentNode) { + detach(panel); + document.getElementById("mainPopupSet").appendChild(panel); + } +} +exports.attach = attach; + +function detach(panel) { + if (panel.parentNode) panel.parentNode.removeChild(panel); +} +exports.detach = detach; + +function dispose(panel) { + panel.backgroundFrame.remove(); + panel.backgroundFrame = null; + events.off("document-element-inserted", panel.onContentChange); + panel.onContentChange = null; + detach(panel); +} +exports.dispose = dispose; + +function style(panel) { + /** + Injects default OS specific panel styles into content document that is loaded + into given panel. Optionally `document` of the browser window can be + given to inherit styles from it, by default it will use either panel owner + document or an active browser's document. It should not matter though unless + Firefox decides to style windows differently base on profile or mode like + chrome for example. + **/ + + try { + let document = panel.ownerDocument; + let contentDocument = getContentDocument(panel); + let window = document.defaultView; + let node = document.getAnonymousElementByAttribute(panel, "class", + "panel-arrowcontent"); + + let { color, fontFamily, fontSize, fontWeight } = window.getComputedStyle(node); + + let style = contentDocument.createElement("style"); + style.id = "sdk-panel-style"; + style.textContent = "body { " + + "color: " + color + ";" + + "font-family: " + fontFamily + ";" + + "font-weight: " + fontWeight + ";" + + "font-size: " + fontSize + ";" + + "}"; + + let container = contentDocument.head ? contentDocument.head : + contentDocument.documentElement; + + if (container.firstChild) + container.insertBefore(style, container.firstChild); + else + container.appendChild(style); + } + catch (error) { + console.error("Unable to apply panel style"); + console.exception(error); + } +} +exports.style = style; + +var getContentFrame = panel => panel.viewFrame || panel.backgroundFrame; +exports.getContentFrame = getContentFrame; + +function getContentDocument(panel) { + return getContentFrame(panel).contentDocument; +} +exports.getContentDocument = getContentDocument; + +function setURL(panel, url) { + let frame = getContentFrame(panel); + let webNav = getDocShell(frame).QueryInterface(Ci.nsIWebNavigation); + + webNav.loadURI(url ? data.url(url) : "about:blank", 0, null, null, null); +} + +exports.setURL = setURL; + +function allowContextMenu(panel, allow) { + if (allow) { + panel.setAttribute("context", "contentAreaContextMenu"); + } + else { + panel.removeAttribute("context"); + } +} +exports.allowContextMenu = allowContextMenu; diff --git a/addon-sdk/source/lib/sdk/passwords.js b/addon-sdk/source/lib/sdk/passwords.js new file mode 100644 index 000000000..70f0aa4da --- /dev/null +++ b/addon-sdk/source/lib/sdk/passwords.js @@ -0,0 +1,61 @@ +/* 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'; + +module.metadata = { + "stability": "stable" +}; + +const { search, remove, store } = require("./passwords/utils"); +const { defer, delay } = require("./lang/functional"); + +/** + * Utility function that returns `onComplete` and `onError` callbacks form the + * given `options` objects. Also properties are removed from the passed + * `options` objects. + * @param {Object} options + * Object that is passed to the exported functions of this module. + * @returns {Function[]} + * Array with two elements `onComplete` and `onError` functions. + */ +function getCallbacks(options) { + let value = [ + 'onComplete' in options ? options.onComplete : null, + 'onError' in options ? defer(options.onError) : console.exception + ]; + + delete options.onComplete; + delete options.onError; + + return value; +}; + +/** + * Creates a wrapper function that tries to call `onComplete` with a return + * value of the wrapped function or falls back to `onError` if wrapped function + * throws an exception. + */ +function createWrapperMethod(wrapped) { + return function (options) { + let [ onComplete, onError ] = getCallbacks(options); + try { + let value = wrapped(options); + if (onComplete) { + delay(function() { + try { + onComplete(value); + } catch (exception) { + onError(exception); + } + }); + } + } catch (exception) { + onError(exception); + } + }; +} + +exports.search = createWrapperMethod(search); +exports.store = createWrapperMethod(store); +exports.remove = createWrapperMethod(remove); diff --git a/addon-sdk/source/lib/sdk/passwords/utils.js b/addon-sdk/source/lib/sdk/passwords/utils.js new file mode 100644 index 000000000..334efa490 --- /dev/null +++ b/addon-sdk/source/lib/sdk/passwords/utils.js @@ -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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci, CC } = require("chrome"); +const { uri: ADDON_URI } = require("../self"); +const loginManager = Cc["@mozilla.org/login-manager;1"]. + getService(Ci.nsILoginManager); +const { URL: parseURL } = require("../url"); +const LoginInfo = CC("@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", "init"); + +function filterMatchingLogins(loginInfo) { + return Object.keys(this).every(key => loginInfo[key] === this[key], this); +} + +/** + * Removes `user`, `password` and `path` fields from the given `url` if it's + * 'http', 'https' or 'ftp'. All other URLs are returned unchanged. + * @example + * http://user:pass@www.site.com/foo/?bar=baz#bang -> http://www.site.com + */ +function normalizeURL(url) { + let { scheme, host, port } = parseURL(url); + // We normalize URL only if it's `http`, `https` or `ftp`. All other types of + // URLs (`resource`, `chrome`, etc..) should not be normalized as they are + // used with add-on associated credentials path. + return scheme === "http" || scheme === "https" || scheme === "ftp" ? + scheme + "://" + (host || "") + (port ? ":" + port : "") : + url +} + +function Login(options) { + let login = Object.create(Login.prototype); + Object.keys(options || {}).forEach(function(key) { + if (key === 'url') + login.hostname = normalizeURL(options.url); + else if (key === 'formSubmitURL') + login.formSubmitURL = options.formSubmitURL ? + normalizeURL(options.formSubmitURL) : null; + else if (key === 'realm') + login.httpRealm = options.realm; + else + login[key] = options[key]; + }); + + return login; +} +Login.prototype.toJSON = function toJSON() { + return { + url: this.hostname || ADDON_URI, + realm: this.httpRealm || null, + formSubmitURL: this.formSubmitURL || null, + username: this.username || null, + password: this.password || null, + usernameField: this.usernameField || '', + passwordField: this.passwordField || '', + } +}; +Login.prototype.toLoginInfo = function toLoginInfo() { + let { url, realm, formSubmitURL, username, password, usernameField, + passwordField } = this.toJSON(); + + return new LoginInfo(url, formSubmitURL, realm, username, password, + usernameField, passwordField); +}; + +function loginToJSON(value) { + return Login(value).toJSON(); +} + +/** + * Returns array of `nsILoginInfo` objects that are stored in the login manager + * and have all the properties with matching values as a given `options` object. + * @param {Object} options + * @returns {nsILoginInfo[]} + */ +exports.search = function search(options) { + return loginManager.getAllLogins() + .filter(filterMatchingLogins, Login(options)) + .map(loginToJSON); +}; + +/** + * Stores login info created from the given `options` to the applications + * built-in login management system. + * @param {Object} options. + */ +exports.store = function store(options) { + loginManager.addLogin(Login(options).toLoginInfo()); +}; + +/** + * Removes login info from the applications built-in login management system. + * _Please note: When removing a login info the specified properties must + * exactly match to the one that is already stored or exception will be thrown._ + * @param {Object} options. + */ +exports.remove = function remove(options) { + loginManager.removeLogin(Login(options).toLoginInfo()); +}; diff --git a/addon-sdk/source/lib/sdk/places/bookmarks.js b/addon-sdk/source/lib/sdk/places/bookmarks.js new file mode 100644 index 000000000..c4f9528f1 --- /dev/null +++ b/addon-sdk/source/lib/sdk/places/bookmarks.js @@ -0,0 +1,395 @@ +/* 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"; + +module.metadata = { + "stability": "unstable", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +/* + * Requiring hosts so they can subscribe to client messages + */ +require('./host/host-bookmarks'); +require('./host/host-tags'); +require('./host/host-query'); + +const { Cc, Ci } = require('chrome'); +const { Class } = require('../core/heritage'); +const { send } = require('../addon/events'); +const { defer, reject, all, resolve, promised } = require('../core/promise'); +const { EventTarget } = require('../event/target'); +const { emit } = require('../event/core'); +const { identity, defer:async } = require('../lang/functional'); +const { extend, merge } = require('../util/object'); +const { fromIterator } = require('../util/array'); +const { + constructTree, fetchItem, createQuery, + isRootGroup, createQueryOptions +} = require('./utils'); +const { + bookmarkContract, groupContract, separatorContract +} = require('./contract'); +const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); + +/* + * Mapping of uncreated bookmarks with their created + * counterparts + */ +const itemMap = new WeakMap(); + +/* + * Constant used by nsIHistoryQuery; 1 is a bookmark query + * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions + */ +const BOOKMARK_QUERY = 1; + +/* + * Bookmark Item classes + */ + +const Bookmark = Class({ + extends: [ + bookmarkContract.properties(identity) + ], + initialize: function initialize (options) { + merge(this, bookmarkContract(extend(defaults, options))); + }, + type: 'bookmark', + toString: () => '[object Bookmark]' +}); +exports.Bookmark = Bookmark; + +const Group = Class({ + extends: [ + groupContract.properties(identity) + ], + initialize: function initialize (options) { + // Don't validate if root group + if (isRootGroup(options)) + merge(this, options); + else + merge(this, groupContract(extend(defaults, options))); + }, + type: 'group', + toString: () => '[object Group]' +}); +exports.Group = Group; + +const Separator = Class({ + extends: [ + separatorContract.properties(identity) + ], + initialize: function initialize (options) { + merge(this, separatorContract(extend(defaults, options))); + }, + type: 'separator', + toString: () => '[object Separator]' +}); +exports.Separator = Separator; + +/* + * Functions + */ + +function save (items, options) { + items = [].concat(items); + options = options || {}; + let emitter = EventTarget(); + let results = []; + let errors = []; + let root = constructTree(items); + let cache = new Map(); + + let isExplicitSave = item => !!~items.indexOf(item); + // `walk` returns an aggregate promise indicating the completion + // of the `commitItem` on each node, not whether or not that + // commit was successful + + // Force this to be async, as if a ducktype fails validation, + // the promise implementation will fire an error event, which will + // not trigger the handler as it's not yet bound + // + // Can remove after `Promise.jsm` is implemented in Bug 881047, + // which will guarantee next tick execution + async(() => root.walk(preCommitItem).then(commitComplete))(); + + function preCommitItem ({value:item}) { + // Do nothing if tree root, default group (unsavable), + // or if it's a dependency and not explicitly saved (in the list + // of items to be saved), and not needed to be saved + if (item === null || // node is the tree root + isRootGroup(item) || + (getId(item) && !isExplicitSave(item))) + return; + + return promised(validate)(item) + .then(() => commitItem(item, options)) + .then(data => construct(data, cache)) + .then(savedItem => { + // If item was just created, make a map between + // the creation object and created object, + // so we can reference the item that doesn't have an id + if (!getId(item)) + saveId(item, savedItem.id); + + // Emit both the processed item, and original item + // so a mapping can be understood in handler + emit(emitter, 'data', savedItem, item); + + // Push to results iff item was explicitly saved + if (isExplicitSave(item)) + results[items.indexOf(item)] = savedItem; + }, reason => { + // Force reason to be a string for consistency + reason = reason + ''; + // Emit both the reason, and original item + // so a mapping can be understood in handler + emit(emitter, 'error', reason + '', item); + // Store unsaved item in results list + results[items.indexOf(item)] = item; + errors.push(reason); + }); + } + + // Called when traversal of the node tree is completed and all + // items have been committed + function commitComplete () { + emit(emitter, 'end', results); + } + + return emitter; +} +exports.save = save; + +function search (queries, options) { + queries = [].concat(queries); + let emitter = EventTarget(); + let cache = new Map(); + let queryObjs = queries.map(createQuery.bind(null, BOOKMARK_QUERY)); + let optionsObj = createQueryOptions(BOOKMARK_QUERY, options); + + // Can remove after `Promise.jsm` is implemented in Bug 881047, + // which will guarantee next tick execution + async(() => { + send('sdk-places-query', { queries: queryObjs, options: optionsObj }) + .then(handleQueryResponse); + })(); + + function handleQueryResponse (data) { + let deferreds = data.map(item => { + return construct(item, cache).then(bookmark => { + emit(emitter, 'data', bookmark); + return bookmark; + }, reason => { + emit(emitter, 'error', reason); + errors.push(reason); + }); + }); + + all(deferreds).then(data => { + emit(emitter, 'end', data); + }, () => emit(emitter, 'end', [])); + } + + return emitter; +} +exports.search = search; + +function remove (items) { + return [].concat(items).map(item => { + item.remove = true; + return item; + }); +} + +exports.remove = remove; + +/* + * Internal Utilities + */ + +function commitItem (item, options) { + // Get the item's ID, or getId it's saved version if it exists + let id = getId(item); + let data = normalize(item); + let promise; + + data.id = id; + + if (!id) { + promise = send('sdk-places-bookmarks-create', data); + } else if (item.remove) { + promise = send('sdk-places-bookmarks-remove', { id: id }); + } else { + promise = send('sdk-places-bookmarks-last-updated', { + id: id + }).then(function (updated) { + // If attempting to save an item that is not the + // latest snapshot of a bookmark item, execute + // the resolution function + if (updated !== item.updated && options.resolve) + return fetchItem(id) + .then(options.resolve.bind(null, data)); + else + return data; + }).then(send.bind(null, 'sdk-places-bookmarks-save')); + } + + return promise; +} + +/* + * Turns a bookmark item into a plain object, + * converts `tags` from Set to Array, group instance to an id + */ +function normalize (item) { + let data = merge({}, item); + // Circumvent prototype property of `type` + delete data.type; + data.type = item.type; + data.tags = []; + if (item.tags) { + data.tags = fromIterator(item.tags); + } + data.group = getId(data.group) || exports.UNSORTED.id; + + return data; +} + +/* + * Takes a data object and constructs a BookmarkItem instance + * of it, recursively generating parent instances as well. + * + * Pass in a `cache` Map to reuse instances of + * bookmark items to reduce overhead; + * The cache object is a map of id to a deferred with a + * promise that resolves to the bookmark item. + */ +function construct (object, cache, forced) { + let item = instantiate(object); + let deferred = defer(); + + // Item could not be instantiated + if (!item) + return resolve(null); + + // Return promise for item if found in the cache, + // and not `forced`. `forced` indicates that this is the construct + // call that should not read from cache, but should actually perform + // the construction, as it was set before several async calls + if (cache.has(item.id) && !forced) + return cache.get(item.id).promise; + else if (cache.has(item.id)) + deferred = cache.get(item.id); + else + cache.set(item.id, deferred); + + // When parent group is found in cache, use + // the same deferred value + if (item.group && cache.has(item.group)) { + cache.get(item.group).promise.then(group => { + item.group = group; + deferred.resolve(item); + }); + + // If not in the cache, and a root group, return + // the premade instance + } else if (rootGroups.get(item.group)) { + item.group = rootGroups.get(item.group); + deferred.resolve(item); + + // If not in the cache or a root group, fetch the parent + } else { + cache.set(item.group, defer()); + fetchItem(item.group).then(group => { + return construct(group, cache, true); + }).then(group => { + item.group = group; + deferred.resolve(item); + }, deferred.reject); + } + + return deferred.promise; +} + +function instantiate (object) { + if (object.type === 'bookmark') + return Bookmark(object); + if (object.type === 'group') + return Group(object); + if (object.type === 'separator') + return Separator(object); + return null; +} + +/** + * Validates a bookmark item; will throw an error if ininvalid, + * to be used with `promised`. As bookmark items check on their class, + * this only checks ducktypes + */ +function validate (object) { + if (!isDuckType(object)) return true; + let contract = object.type === 'bookmark' ? bookmarkContract : + object.type === 'group' ? groupContract : + object.type === 'separator' ? separatorContract : + null; + if (!contract) { + throw Error('No type specified'); + } + + // If object has a property set, and undefined, + // manually override with default as it'll fail otherwise + let withDefaults = Object.keys(defaults).reduce((obj, prop) => { + if (obj[prop] == null) obj[prop] = defaults[prop]; + return obj; + }, extend(object)); + + contract(withDefaults); +} + +function isDuckType (item) { + return !(item instanceof Bookmark) && + !(item instanceof Group) && + !(item instanceof Separator); +} + +function saveId (unsaved, id) { + itemMap.set(unsaved, id); +} + +// Fetches an item's ID from itself, or from the mapped items +function getId (item) { + return typeof item === 'number' ? item : + item ? item.id || itemMap.get(item) : + null; +} + +/* + * Set up the default, root groups + */ + +var defaultGroupMap = { + MENU: bmsrv.bookmarksMenuFolder, + TOOLBAR: bmsrv.toolbarFolder, + UNSORTED: bmsrv.unfiledBookmarksFolder +}; + +var rootGroups = new Map(); + +for (let i in defaultGroupMap) { + let group = Object.freeze(Group({ title: i, id: defaultGroupMap[i] })); + rootGroups.set(defaultGroupMap[i], group); + exports[i] = group; +} + +var defaults = { + group: exports.UNSORTED, + index: -1 +}; diff --git a/addon-sdk/source/lib/sdk/places/contract.js b/addon-sdk/source/lib/sdk/places/contract.js new file mode 100644 index 000000000..a3541c34d --- /dev/null +++ b/addon-sdk/source/lib/sdk/places/contract.js @@ -0,0 +1,73 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci } = require('chrome'); +const { isValidURI, URL } = require('../url'); +const { contract } = require('../util/contract'); +const { extend } = require('../util/object'); + +// map of property validations +const validItem = { + id: { + is: ['number', 'undefined', 'null'], + }, + group: { + is: ['object', 'number', 'undefined', 'null'], + ok: function (value) { + return value && + (value.toString && value.toString() === '[object Group]') || + typeof value === 'number' || + value.type === 'group'; + }, + msg: 'The `group` property must be a valid Group object' + }, + index: { + is: ['undefined', 'null', 'number'], + map: value => value == null ? -1 : value, + msg: 'The `index` property must be a number.' + }, + updated: { + is: ['number', 'undefined'] + } +}; + +const validTitle = { + title: { + is: ['string'], + msg: 'The `title` property must be defined.' + } +}; + +const validURL = { + url: { + is: ['string'], + ok: isValidURI, + msg: 'The `url` property must be a valid URL.' + } +}; + +const validTags = { + tags: { + is: ['object'], + ok: tags => tags instanceof Set, + map: function (tags) { + if (Array.isArray(tags)) + return new Set(tags); + if (tags == null) + return new Set(); + return tags; + }, + msg: 'The `tags` property must be a Set, or an array' + } +}; + +exports.bookmarkContract = contract( + extend(validItem, validTitle, validURL, validTags)); +exports.separatorContract = contract(validItem); +exports.groupContract = contract(extend(validItem, validTitle)); diff --git a/addon-sdk/source/lib/sdk/places/events.js b/addon-sdk/source/lib/sdk/places/events.js new file mode 100644 index 000000000..a3f95ee03 --- /dev/null +++ b/addon-sdk/source/lib/sdk/places/events.js @@ -0,0 +1,128 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '*', + "SeaMonkey": '*' + } +}; + +const { Cc, Ci } = require('chrome'); +const { Unknown } = require('../platform/xpcom'); +const { Class } = require('../core/heritage'); +const { merge } = require('../util/object'); +const bookmarkService = Cc['@mozilla.org/browser/nav-bookmarks-service;1'] + .getService(Ci.nsINavBookmarksService); +const historyService = Cc['@mozilla.org/browser/nav-history-service;1'] + .getService(Ci.nsINavHistoryService); +const { mapBookmarkItemType } = require('./utils'); +const { EventTarget } = require('../event/target'); +const { emit } = require('../event/core'); +const { when } = require('../system/unload'); + +const emitter = EventTarget(); + +var HISTORY_ARGS = { + onBeginUpdateBatch: [], + onEndUpdateBatch: [], + onClearHistory: [], + onDeleteURI: ['url'], + onDeleteVisits: ['url', 'visitTime'], + onPageChanged: ['url', 'property', 'value'], + onTitleChanged: ['url', 'title'], + onVisit: [ + 'url', 'visitId', 'time', 'sessionId', 'referringId', 'transitionType' + ] +}; + +var HISTORY_EVENTS = { + onBeginUpdateBatch: 'history-start-batch', + onEndUpdateBatch: 'history-end-batch', + onClearHistory: 'history-start-clear', + onDeleteURI: 'history-delete-url', + onDeleteVisits: 'history-delete-visits', + onPageChanged: 'history-page-changed', + onTitleChanged: 'history-title-changed', + onVisit: 'history-visit' +}; + +var BOOKMARK_ARGS = { + onItemAdded: [ + 'id', 'parentId', 'index', 'type', 'url', 'title', 'dateAdded' + ], + onItemChanged: [ + 'id', 'property', null, 'value', 'lastModified', 'type', 'parentId' + ], + onItemMoved: [ + 'id', 'previousParentId', 'previousIndex', 'currentParentId', + 'currentIndex', 'type' + ], + onItemRemoved: ['id', 'parentId', 'index', 'type', 'url'], + onItemVisited: ['id', 'visitId', 'time', 'transitionType', 'url', 'parentId'] +}; + +var BOOKMARK_EVENTS = { + onItemAdded: 'bookmark-item-added', + onItemChanged: 'bookmark-item-changed', + onItemMoved: 'bookmark-item-moved', + onItemRemoved: 'bookmark-item-removed', + onItemVisited: 'bookmark-item-visited', +}; + +function createHandler (type, propNames) { + propNames = propNames || []; + return function (...args) { + let data = propNames.reduce((acc, prop, i) => { + if (prop) + acc[prop] = formatValue(prop, args[i]); + return acc; + }, {}); + + emit(emitter, 'data', { + type: type, + data: data + }); + }; +} + +/* + * Creates an observer, creating handlers based off of + * the `events` names, and ordering arguments from `propNames` hash + */ +function createObserverInstance (events, propNames) { + let definition = Object.keys(events).reduce((prototype, eventName) => { + prototype[eventName] = createHandler(events[eventName], propNames[eventName]); + return prototype; + }, {}); + + return Class(merge(definition, { extends: Unknown }))(); +} + +/* + * Formats `data` based off of the value of `type` + */ +function formatValue (type, data) { + if (type === 'type') + return mapBookmarkItemType(data); + if (type === 'url' && data) + return data.spec; + return data; +} + +var historyObserver = createObserverInstance(HISTORY_EVENTS, HISTORY_ARGS); +historyService.addObserver(historyObserver, false); + +var bookmarkObserver = createObserverInstance(BOOKMARK_EVENTS, BOOKMARK_ARGS); +bookmarkService.addObserver(bookmarkObserver, false); + +when(() => { + historyService.removeObserver(historyObserver); + bookmarkService.removeObserver(bookmarkObserver); +}); + +exports.events = emitter; diff --git a/addon-sdk/source/lib/sdk/places/favicon.js b/addon-sdk/source/lib/sdk/places/favicon.js new file mode 100644 index 000000000..05b057db1 --- /dev/null +++ b/addon-sdk/source/lib/sdk/places/favicon.js @@ -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"; + +module.metadata = { + "stability": "unstable", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cc, Ci, Cu } = require("chrome"); +const { defer, reject } = require("../core/promise"); +const FaviconService = Cc["@mozilla.org/browser/favicon-service;1"]. + getService(Ci.nsIFaviconService); +const AsyncFavicons = FaviconService.QueryInterface(Ci.mozIAsyncFavicons); +const { isValidURI } = require("../url"); +const { newURI, getURL } = require("../url/utils"); + +/** + * Takes an object of several possible types and + * returns a promise that resolves to the page's favicon URI. + * @param {String|Tab} object + * @param {Function} (callback) + * @returns {Promise} + */ + +function getFavicon (object, callback) { + let url = getURL(object); + let deferred = defer(); + + if (url && isValidURI(url)) { + AsyncFavicons.getFaviconURLForPage(newURI(url), function (aURI) { + if (aURI && aURI.spec) + deferred.resolve(aURI.spec.toString()); + else + deferred.reject(null); + }); + } else { + deferred.reject(null); + } + + if (callback) deferred.promise.then(callback, callback); + return deferred.promise; +} +exports.getFavicon = getFavicon; diff --git a/addon-sdk/source/lib/sdk/places/history.js b/addon-sdk/source/lib/sdk/places/history.js new file mode 100644 index 000000000..b243b024c --- /dev/null +++ b/addon-sdk/source/lib/sdk/places/history.js @@ -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"; + +module.metadata = { + "stability": "unstable", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +/* + * Requiring hosts so they can subscribe to client messages + */ +require('./host/host-bookmarks'); +require('./host/host-tags'); +require('./host/host-query'); + +const { Cc, Ci } = require('chrome'); +const { Class } = require('../core/heritage'); +const { events, send } = require('../addon/events'); +const { defer, reject, all } = require('../core/promise'); +const { uuid } = require('../util/uuid'); +const { flatten } = require('../util/array'); +const { has, extend, merge, pick } = require('../util/object'); +const { emit } = require('../event/core'); +const { defer: async } = require('../lang/functional'); +const { EventTarget } = require('../event/target'); +const { + urlQueryParser, createQuery, createQueryOptions +} = require('./utils'); + +/* + * Constant used by nsIHistoryQuery; 0 is a history query + * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions + */ +const HISTORY_QUERY = 0; + +var search = function query (queries, options) { + queries = [].concat(queries); + let emitter = EventTarget(); + let queryObjs = queries.map(createQuery.bind(null, HISTORY_QUERY)); + let optionsObj = createQueryOptions(HISTORY_QUERY, options); + + // Can remove after `Promise.jsm` is implemented in Bug 881047, + // which will guarantee next tick execution + async(() => { + send('sdk-places-query', { + query: queryObjs, + options: optionsObj + }).then(results => { + results.map(item => emit(emitter, 'data', item)); + emit(emitter, 'end', results); + }, reason => { + emit(emitter, 'error', reason); + emit(emitter, 'end', []); + }); + })(); + + return emitter; +}; +exports.search = search; diff --git a/addon-sdk/source/lib/sdk/places/host/host-bookmarks.js b/addon-sdk/source/lib/sdk/places/host/host-bookmarks.js new file mode 100644 index 000000000..3245c4070 --- /dev/null +++ b/addon-sdk/source/lib/sdk/places/host/host-bookmarks.js @@ -0,0 +1,238 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cc, Ci } = require('chrome'); +const browserHistory = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsIBrowserHistory); +const asyncHistory = Cc["@mozilla.org/browser/history;1"]. + getService(Ci.mozIAsyncHistory); +const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); +const taggingService = Cc["@mozilla.org/browser/tagging-service;1"]. + getService(Ci.nsITaggingService); +const ios = Cc['@mozilla.org/network/io-service;1']. + getService(Ci.nsIIOService); +const { query } = require('./host-query'); +const { + defer, all, resolve, promised, reject +} = require('../../core/promise'); +const { request, response } = require('../../addon/host'); +const { send } = require('../../addon/events'); +const { on, emit } = require('../../event/core'); +const { filter } = require('../../event/utils'); +const { URL, isValidURI } = require('../../url'); +const { newURI } = require('../../url/utils'); + +const DEFAULT_INDEX = bmsrv.DEFAULT_INDEX; +const UNSORTED_ID = bmsrv.unfiledBookmarksFolder; +const ROOT_FOLDERS = [ + bmsrv.unfiledBookmarksFolder, bmsrv.toolbarFolder, + bmsrv.tagsFolder, bmsrv.bookmarksMenuFolder +]; + +const EVENT_MAP = { + 'sdk-places-bookmarks-create': createBookmarkItem, + 'sdk-places-bookmarks-save': saveBookmarkItem, + 'sdk-places-bookmarks-last-updated': getBookmarkLastUpdated, + 'sdk-places-bookmarks-get': getBookmarkItem, + 'sdk-places-bookmarks-remove': removeBookmarkItem, + 'sdk-places-bookmarks-get-all': getAllBookmarks, + 'sdk-places-bookmarks-get-children': getChildren +}; + +function typeMap (type) { + if (typeof type === 'number') { + if (bmsrv.TYPE_BOOKMARK === type) return 'bookmark'; + if (bmsrv.TYPE_FOLDER === type) return 'group'; + if (bmsrv.TYPE_SEPARATOR === type) return 'separator'; + } else { + if ('bookmark' === type) return bmsrv.TYPE_BOOKMARK; + if ('group' === type) return bmsrv.TYPE_FOLDER; + if ('separator' === type) return bmsrv.TYPE_SEPARATOR; + } +} + +function getBookmarkLastUpdated ({id}) { + return resolve(bmsrv.getItemLastModified(id)); +} +exports.getBookmarkLastUpdated; + +function createBookmarkItem (data) { + let error; + + if (data.group == null) data.group = UNSORTED_ID; + if (data.index == null) data.index = DEFAULT_INDEX; + + if (data.type === 'group') + data.id = bmsrv.createFolder( + data.group, data.title, data.index + ); + else if (data.type === 'separator') + data.id = bmsrv.insertSeparator( + data.group, data.index + ); + else + data.id = bmsrv.insertBookmark( + data.group, newURI(data.url), data.index, data.title + ); + + // In the event where default or no index is provided (-1), + // query the actual index for the response + if (data.index === -1) + data.index = bmsrv.getItemIndex(data.id); + + try { + data.updated = bmsrv.getItemLastModified(data.id); + } + catch (e) { + console.exception(e); + } + + return tag(data, true).then(() => data); +} +exports.createBookmarkItem = createBookmarkItem; + +function saveBookmarkItem (data) { + let id = data.id; + if (!id) + reject('Item is missing id'); + + let group = bmsrv.getFolderIdForItem(id); + let index = bmsrv.getItemIndex(id); + let type = bmsrv.getItemType(id); + let title = typeMap(type) !== 'separator' ? + bmsrv.getItemTitle(id) : + undefined; + let url = typeMap(type) === 'bookmark' ? + bmsrv.getBookmarkURI(id).spec : + undefined; + + if (url != data.url) + bmsrv.changeBookmarkURI(id, newURI(data.url)); + else if (typeMap(type) === 'bookmark') + data.url = url; + + if (title != data.title) + bmsrv.setItemTitle(id, data.title); + else if (typeMap(type) !== 'separator') + data.title = title; + + if (data.group && data.group !== group) + bmsrv.moveItem(id, data.group, data.index || -1); + else if (data.index != null && data.index !== index) { + // We use moveItem here instead of setItemIndex + // so we don't have to manage the indicies of the siblings + bmsrv.moveItem(id, group, data.index); + } else if (data.index == null) + data.index = index; + + data.updated = bmsrv.getItemLastModified(data.id); + + return tag(data).then(() => data); +} +exports.saveBookmarkItem = saveBookmarkItem; + +function removeBookmarkItem (data) { + let id = data.id; + + if (!id) + reject('Item is missing id'); + + bmsrv.removeItem(id); + return resolve(null); +} +exports.removeBookmarkItem = removeBookmarkItem; + +function getBookmarkItem (data) { + let id = data.id; + + if (!id) + reject('Item is missing id'); + + let type = bmsrv.getItemType(id); + + data.type = typeMap(type); + + if (type === bmsrv.TYPE_BOOKMARK || type === bmsrv.TYPE_FOLDER) + data.title = bmsrv.getItemTitle(id); + + if (type === bmsrv.TYPE_BOOKMARK) { + data.url = bmsrv.getBookmarkURI(id).spec; + // Should be moved into host-tags as a method + data.tags = taggingService.getTagsForURI(newURI(data.url), {}); + } + + data.group = bmsrv.getFolderIdForItem(id); + data.index = bmsrv.getItemIndex(id); + data.updated = bmsrv.getItemLastModified(data.id); + + return resolve(data); +} +exports.getBookmarkItem = getBookmarkItem; + +function getAllBookmarks () { + return query({}, { queryType: 1 }).then(bookmarks => + all(bookmarks.map(getBookmarkItem))); +} +exports.getAllBookmarks = getAllBookmarks; + +function getChildren ({ id }) { + if (typeMap(bmsrv.getItemType(id)) !== 'group') return []; + let ids = []; + for (let i = 0; ids[ids.length - 1] !== -1; i++) + ids.push(bmsrv.getIdForItemAt(id, i)); + ids.pop(); + return all(ids.map(id => getBookmarkItem({ id: id }))); +} +exports.getChildren = getChildren; + +/* + * Hook into host + */ + +var reqStream = filter(request, (data) => /sdk-places-bookmarks/.test(data.event)); +on(reqStream, 'data', ({ event, id, data }) => { + if (!EVENT_MAP[event]) return; + + let resData = { id: id, event: event }; + + promised(EVENT_MAP[event])(data). + then(res => resData.data = res, e => resData.error = e). + then(() => emit(response, 'data', resData)); +}); + +function tag (data, isNew) { + // If a new item, we can skip checking what other tags + // are on the item + if (data.type !== 'bookmark') { + return resolve(); + } + else if (!isNew) { + return send('sdk-places-tags-get-tags-by-url', { url: data.url }) + .then(tags => { + return send('sdk-places-tags-untag', { + tags: tags.filter(tag => !~data.tags.indexOf(tag)), + url: data.url + }); + }).then(() => send('sdk-places-tags-tag', { + url: data.url, tags: data.tags + })); + } + else if (data.tags && data.tags.length) { + return send('sdk-places-tags-tag', { url: data.url, tags: data.tags }); + } + else + return resolve(); +} + diff --git a/addon-sdk/source/lib/sdk/places/host/host-query.js b/addon-sdk/source/lib/sdk/places/host/host-query.js new file mode 100644 index 000000000..f2dbd6550 --- /dev/null +++ b/addon-sdk/source/lib/sdk/places/host/host-query.js @@ -0,0 +1,179 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cc, Ci } = require('chrome'); +const { all } = require('../../core/promise'); +const { safeMerge, omit } = require('../../util/object'); +const historyService = Cc['@mozilla.org/browser/nav-history-service;1'] + .getService(Ci.nsINavHistoryService); +const bookmarksService = Cc['@mozilla.org/browser/nav-bookmarks-service;1'] + .getService(Ci.nsINavBookmarksService); +const { request, response } = require('../../addon/host'); +const { newURI } = require('../../url/utils'); +const { send } = require('../../addon/events'); +const { on, emit } = require('../../event/core'); +const { filter } = require('../../event/utils'); + +const ROOT_FOLDERS = [ + bookmarksService.unfiledBookmarksFolder, bookmarksService.toolbarFolder, + bookmarksService.bookmarksMenuFolder +]; + +const EVENT_MAP = { + 'sdk-places-query': queryReceiver +}; + +// Properties that need to be manually +// copied into a nsINavHistoryQuery object +const MANUAL_QUERY_PROPERTIES = [ + 'uri', 'folder', 'tags', 'url', 'folder' +]; + +const PLACES_PROPERTIES = [ + 'uri', 'title', 'accessCount', 'time' +]; + +function execute (queries, options) { + return new Promise(resolve => { + let root = historyService + .executeQueries(queries, queries.length, options).root; + // Let's extract an eventual uri wildcard, if both domain and uri are set. + // See utils.js::urlQueryParser() for more details. + // In case of multiple queries, we only retain the first found wildcard. + let uriWildcard = queries.reduce((prev, query) => { + if (query.uri && query.domain) { + if (!prev) + prev = query.uri.spec; + query.uri = null; + } + return prev; + }, ""); + resolve(collect([], root, uriWildcard)); + }); +} + +function collect (acc, node, uriWildcard) { + node.containerOpen = true; + for (let i = 0; i < node.childCount; i++) { + let child = node.getChild(i); + + if (!uriWildcard || child.uri.startsWith(uriWildcard)) { + acc.push(child); + } + if (child.type === child.RESULT_TYPE_FOLDER) { + let container = child.QueryInterface(Ci.nsINavHistoryContainerResultNode); + collect(acc, container, uriWildcard); + } + } + node.containerOpen = false; + return acc; +} + +function query (queries, options) { + return new Promise((resolve, reject) => { + queries = queries || []; + options = options || {}; + let optionsObj, queryObjs; + + optionsObj = historyService.getNewQueryOptions(); + queryObjs = [].concat(queries).map(createQuery); + if (!queryObjs.length) { + queryObjs = [historyService.getNewQuery()]; + } + safeMerge(optionsObj, options); + + /* + * Currently `places:` queries are not supported + */ + optionsObj.excludeQueries = true; + + execute(queryObjs, optionsObj).then((results) => { + if (optionsObj.queryType === 0) { + return results.map(normalize); + } + else if (optionsObj.queryType === 1) { + // Formats query results into more standard + // data structures for returning + return all(results.map(({itemId}) => + send('sdk-places-bookmarks-get', { id: itemId }))); + } + }).then(resolve, reject); + }); +} +exports.query = query; + +function createQuery (query) { + query = query || {}; + let queryObj = historyService.getNewQuery(); + + safeMerge(queryObj, omit(query, MANUAL_QUERY_PROPERTIES)); + + if (query.tags && Array.isArray(query.tags)) + queryObj.tags = query.tags; + if (query.uri || query.url) + queryObj.uri = newURI(query.uri || query.url); + if (query.folder) + queryObj.setFolders([query.folder], 1); + return queryObj; +} + +function queryReceiver (message) { + let queries = message.data.queries || message.data.query; + let options = message.data.options; + let resData = { + id: message.id, + event: message.event + }; + + query(queries, options).then(results => { + resData.data = results; + respond(resData); + }, reason => { + resData.error = reason; + respond(resData); + }); +} + +/* + * Converts a nsINavHistoryResultNode into a plain object + * + * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryResultNode + */ +function normalize (historyObj) { + return PLACES_PROPERTIES.reduce((obj, prop) => { + if (prop === 'uri') + obj.url = historyObj.uri; + else if (prop === 'time') { + // Cast from microseconds to milliseconds + obj.time = Math.floor(historyObj.time / 1000) + } + else if (prop === 'accessCount') + obj.visitCount = historyObj[prop]; + else + obj[prop] = historyObj[prop]; + return obj; + }, {}); +} + +/* + * Hook into host + */ + +var reqStream = filter(request, data => /sdk-places-query/.test(data.event)); +on(reqStream, 'data', function (e) { + if (EVENT_MAP[e.event]) EVENT_MAP[e.event](e); +}); + +function respond (data) { + emit(response, 'data', data); +} diff --git a/addon-sdk/source/lib/sdk/places/host/host-tags.js b/addon-sdk/source/lib/sdk/places/host/host-tags.js new file mode 100644 index 000000000..929a5d5af --- /dev/null +++ b/addon-sdk/source/lib/sdk/places/host/host-tags.js @@ -0,0 +1,92 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cc, Ci } = require('chrome'); +const taggingService = Cc["@mozilla.org/browser/tagging-service;1"]. + getService(Ci.nsITaggingService); +const ios = Cc['@mozilla.org/network/io-service;1']. + getService(Ci.nsIIOService); +const { URL } = require('../../url'); +const { newURI } = require('../../url/utils'); +const { request, response } = require('../../addon/host'); +const { on, emit } = require('../../event/core'); +const { filter } = require('../../event/utils'); + +const EVENT_MAP = { + 'sdk-places-tags-tag': tag, + 'sdk-places-tags-untag': untag, + 'sdk-places-tags-get-tags-by-url': getTagsByURL, + 'sdk-places-tags-get-urls-by-tag': getURLsByTag +}; + +function tag (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService.tagURI(newURI(data.url), data.tags); + respond(resData); +} + +function untag (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService.untagURI(newURI(data.url), data.tags); + respond(resData); +} + +function getURLsByTag (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService + .getURIsForTag(data.tag).map(uri => uri.spec); + respond(resData); +} + +function getTagsByURL (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService.getTagsForURI(newURI(data.url), {}); + respond(resData); +} + +/* + * Hook into host + */ + +var reqStream = filter(request, function (data) { + return /sdk-places-tags/.test(data.event); +}); + +on(reqStream, 'data', function (e) { + if (EVENT_MAP[e.event]) EVENT_MAP[e.event](e); +}); + +function respond (data) { + emit(response, 'data', data); +} diff --git a/addon-sdk/source/lib/sdk/places/utils.js b/addon-sdk/source/lib/sdk/places/utils.js new file mode 100644 index 000000000..44366d2aa --- /dev/null +++ b/addon-sdk/source/lib/sdk/places/utils.js @@ -0,0 +1,268 @@ +/* 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'; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cc, Ci, Cu } = require('chrome'); +const { Class } = require('../core/heritage'); +const { method } = require('../lang/functional'); +const { defer, promised, all } = require('../core/promise'); +const { send } = require('../addon/events'); +const { EventTarget } = require('../event/target'); +const { merge } = require('../util/object'); +const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); + +Cu.importGlobalProperties(["URL"]); + +/* + * TreeNodes are used to construct dependency trees + * for BookmarkItems + */ +var TreeNode = Class({ + initialize: function (value) { + this.value = value; + this.children = []; + }, + add: function (values) { + [].concat(values).forEach(value => { + this.children.push(value instanceof TreeNode ? value : TreeNode(value)); + }); + }, + get length () { + let count = 0; + this.walk(() => count++); + // Do not count the current node + return --count; + }, + get: method(get), + walk: method(walk), + toString: () => '[object TreeNode]' +}); +exports.TreeNode = TreeNode; + +/* + * Descends down from `node` applying `fn` to each in order. + * `fn` can return values or promises -- if promise returned, + * children are not processed until resolved. `fn` is passed + * one argument, the current node, `curr`. + */ +function walk (curr, fn) { + return promised(fn)(curr).then(val => { + return all(curr.children.map(child => walk(child, fn))); + }); +} + +/* + * Descends from the TreeNode `node`, returning + * the node with value `value` if found or `null` + * otherwise + */ +function get (node, value) { + if (node.value === value) return node; + for (let child of node.children) { + let found = get(child, value); + if (found) return found; + } + return null; +} + +/* + * Constructs a tree of bookmark nodes + * returning the root (value: null); + */ + +function constructTree (items) { + let root = TreeNode(null); + items.forEach(treeify.bind(null, root)); + + function treeify (root, item) { + // If node already exists, skip + let node = root.get(item); + if (node) return node; + node = TreeNode(item); + + let parentNode = item.group ? treeify(root, item.group) : root; + parentNode.add(node); + + return node; + } + + return root; +} +exports.constructTree = constructTree; + +/* + * Shortcut for converting an id, or an object with an id, into + * an object with corresponding bookmark data + */ +function fetchItem (item) { + return send('sdk-places-bookmarks-get', { id: item.id || item }); +} +exports.fetchItem = fetchItem; + +/* + * Takes an ID or an object with ID and checks it against + * the root bookmark folders + */ +function isRootGroup (id) { + id = id && id.id; + return ~[bmsrv.bookmarksMenuFolder, bmsrv.toolbarFolder, + bmsrv.unfiledBookmarksFolder + ].indexOf(id); +} +exports.isRootGroup = isRootGroup; + +/* + * Merges appropriate options into query based off of url + * 4 scenarios: + * + * 'moz.com' // domain: moz.com, domainIsHost: true + * --> 'http://moz.com', 'http://moz.com/thunderbird' + * '*.moz.com' // domain: moz.com, domainIsHost: false + * --> 'http://moz.com', 'http://moz.com/index', 'http://ff.moz.com/test' + * 'http://moz.com' // uri: http://moz.com/ + * --> 'http://moz.com/' + * 'http://moz.com/*' // uri: http://moz.com/, domain: moz.com, domainIsHost: true + * --> 'http://moz.com/', 'http://moz.com/thunderbird' + */ + +function urlQueryParser (query, url) { + if (!url) return; + if (/^https?:\/\//.test(url)) { + query.uri = url.charAt(url.length - 1) === '/' ? url : url + '/'; + if (/\*$/.test(url)) { + // Wildcard searches on URIs are not supported, so try to extract a + // domain and filter the data later. + url = url.replace(/\*$/, ''); + try { + query.domain = new URL(url).hostname; + query.domainIsHost = true; + // Unfortunately here we cannot use an expando to store the wildcard, + // cause the query is a wrapped native XPCOM object, so we reuse uri. + // We clearly don't want to query for both uri and domain, thus we'll + // have to handle this in host-query.js::execute() + query.uri = url; + } catch (ex) { + // Cannot extract an host cause it's not a valid uri, the query will + // just return nothing. + } + } + } else { + if (/^\*/.test(url)) { + query.domain = url.replace(/^\*\./, ''); + query.domainIsHost = false; + } else { + query.domain = url; + query.domainIsHost = true; + } + } +} +exports.urlQueryParser = urlQueryParser; + +/* + * Takes an EventEmitter and returns a promise that + * aggregates results and handles a bulk resolve and reject + */ + +function promisedEmitter (emitter) { + let { promise, resolve, reject } = defer(); + let errors = []; + emitter.on('error', error => errors.push(error)); + emitter.on('end', (items) => { + if (errors.length) reject(errors[0]); + else resolve(items); + }); + return promise; +} +exports.promisedEmitter = promisedEmitter; + + +// https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions +function createQuery (type, query) { + query = query || {}; + let qObj = { + searchTerms: query.query + }; + + urlQueryParser(qObj, query.url); + + // 0 === history + if (type === 0) { + // PRTime used by query is in microseconds, not milliseconds + qObj.beginTime = (query.from || 0) * 1000; + qObj.endTime = (query.to || new Date()) * 1000; + + // Set reference time to Epoch + qObj.beginTimeReference = 0; + qObj.endTimeReference = 0; + } + // 1 === bookmarks + else if (type === 1) { + qObj.tags = query.tags; + qObj.folder = query.group && query.group.id; + } + // 2 === unified (not implemented on platform) + else if (type === 2) { + + } + + return qObj; +} +exports.createQuery = createQuery; + +// https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions + +const SORT_MAP = { + title: 1, + date: 3, // sort by visit date + url: 5, + visitCount: 7, + // keywords currently unsupported + // keyword: 9, + dateAdded: 11, // bookmarks only + lastModified: 13 // bookmarks only +}; + +function createQueryOptions (type, options) { + options = options || {}; + let oObj = {}; + oObj.sortingMode = SORT_MAP[options.sort] || 0; + if (options.descending && options.sort) + oObj.sortingMode++; + + // Resolve to default sort if ineligible based on query type + if (type === 0 && // history + (options.sort === 'dateAdded' || options.sort === 'lastModified')) + oObj.sortingMode = 0; + + oObj.maxResults = typeof options.count === 'number' ? options.count : 0; + + oObj.queryType = type; + + return oObj; +} +exports.createQueryOptions = createQueryOptions; + + +function mapBookmarkItemType (type) { + if (typeof type === 'number') { + if (bmsrv.TYPE_BOOKMARK === type) return 'bookmark'; + if (bmsrv.TYPE_FOLDER === type) return 'group'; + if (bmsrv.TYPE_SEPARATOR === type) return 'separator'; + } else { + if ('bookmark' === type) return bmsrv.TYPE_BOOKMARK; + if ('group' === type) return bmsrv.TYPE_FOLDER; + if ('separator' === type) return bmsrv.TYPE_SEPARATOR; + } +} +exports.mapBookmarkItemType = mapBookmarkItemType; diff --git a/addon-sdk/source/lib/sdk/platform/xpcom.js b/addon-sdk/source/lib/sdk/platform/xpcom.js new file mode 100644 index 000000000..383baf67a --- /dev/null +++ b/addon-sdk/source/lib/sdk/platform/xpcom.js @@ -0,0 +1,241 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci, Cr, Cm, components: { classesByID } } = require('chrome'); +const { registerFactory, unregisterFactory, isCIDRegistered } = + Cm.QueryInterface(Ci.nsIComponentRegistrar); + +const { merge } = require('../util/object'); +const { Class, extend, mix } = require('../core/heritage'); +const { uuid } = require('../util/uuid'); + +// This is a base prototype, that provides bare bones of XPCOM. JS based +// components can be easily implement by extending it. +const Unknown = new function() { + function hasInterface(component, iid) { + return component && component.interfaces && + ( component.interfaces.some(id => iid.equals(Ci[id])) || + component.implements.some($ => hasInterface($, iid)) || + hasInterface(Object.getPrototypeOf(component), iid)); + } + + return Class({ + /** + * The `QueryInterface` method provides runtime type discovery used by XPCOM. + * This method return queried instance of `this` if given `iid` is listed in + * the `interfaces` property or in equivalent properties of objects in it's + * prototype chain. In addition it will look up in the prototypes under + * `implements` array property, this ways compositions made via `Class` + * utility will carry interfaces implemented by composition components. + */ + QueryInterface: function QueryInterface(iid) { + // For some reason there are cases when `iid` is `null`. In such cases we + // just return `this`. Otherwise we verify that component implements given + // `iid` interface. This will be no longer necessary once Bug 748003 is + // fixed. + if (iid && !hasInterface(this, iid)) + throw Cr.NS_ERROR_NO_INTERFACE; + + return this; + }, + /** + * Array of `XPCOM` interfaces (as strings) implemented by this component. + * All components implement `nsISupports` by default which is default value + * here. Provide array of interfaces implemented by an object when + * extending, to append them to this list (Please note that there is no + * need to repeat interfaces implemented by super as they will be added + * automatically). + */ + interfaces: Object.freeze([ 'nsISupports' ]) + }); +} +exports.Unknown = Unknown; + +// Base exemplar for creating instances implementing `nsIFactory` interface, +// that maybe registered into runtime via `register` function. Instances of +// this factory create instances of enclosed component on `createInstance`. +const Factory = Class({ + extends: Unknown, + interfaces: [ 'nsIFactory' ], + /** + * All the descendants will get auto generated `id` (also known as `classID` + * in XPCOM world) unless one is manually provided. + */ + get id() { throw Error('Factory must implement `id` property') }, + /** + * XPCOM `contractID` may optionally be provided to associate this factory + * with it. `contract` is a unique string that has a following format: + * '@vendor.com/unique/id;1'. + */ + contract: null, + /** + * Class description that is being registered. This value is intended as a + * human-readable description for the given class and does not needs to be + * globally unique. + */ + description: 'Jetpack generated factory', + /** + * This method is required by `nsIFactory` interfaces, but as in most + * implementations it does nothing interesting. + */ + lockFactory: function lockFactory(lock) { + return undefined; + }, + /** + * If property is `true` XPCOM service / factory will be registered + * automatically on creation. + */ + register: true, + /** + * If property is `true` XPCOM factory will be unregistered prior to add-on + * unload. + */ + unregister: true, + /** + * Method is called on `Service.new(options)` passing given `options` to + * it. Options is expected to have `component` property holding XPCOM + * component implementation typically decedent of `Unknown` or any custom + * implementation with a `new` method and optional `register`, `unregister` + * flags. Unless `register` is `false` Service / Factory will be + * automatically registered. Unless `unregister` is `false` component will + * be automatically unregistered on add-on unload. + */ + initialize: function initialize(options) { + merge(this, { + id: 'id' in options ? options.id : uuid(), + register: 'register' in options ? options.register : this.register, + unregister: 'unregister' in options ? options.unregister : this.unregister, + contract: 'contract' in options ? options.contract : null, + Component: options.Component + }); + + // If service / factory has auto registration enabled then register. + if (this.register) + register(this); + }, + /** + * Creates an instance of the class associated with this factory. + */ + createInstance: function createInstance(outer, iid) { + try { + if (outer) + throw Cr.NS_ERROR_NO_AGGREGATION; + return this.create().QueryInterface(iid); + } + catch (error) { + throw error instanceof Ci.nsIException ? error : Cr.NS_ERROR_FAILURE; + } + }, + create: function create() { + return this.Component(); + } +}); +exports.Factory = Factory; + +// Exemplar for creating services that implement `nsIFactory` interface, that +// can be registered into runtime via call to `register`. This services return +// enclosed `component` on `getService`. +const Service = Class({ + extends: Factory, + initialize: function initialize(options) { + this.component = options.Component(); + Factory.prototype.initialize.call(this, options); + }, + description: 'Jetpack generated service', + /** + * Creates an instance of the class associated with this factory. + */ + create: function create() { + return this.component; + } +}); +exports.Service = Service; + +function isRegistered({ id }) { + return isCIDRegistered(id); +} +exports.isRegistered = isRegistered; + +/** + * Registers given `component` object to be used to instantiate a particular + * class identified by `component.id`, and creates an association of class + * name and `component.contract` with the class. + */ +function register(factory) { + if (!(factory instanceof Factory)) { + throw new Error("xpcom.register() expect a Factory instance.\n" + + "Please refactor your code to new xpcom module if you" + + " are repacking an addon from SDK <= 1.5:\n" + + "https://developer.mozilla.org/en-US/Add-ons/SDK/Low-Level_APIs/platform_xpcom"); + } + + registerFactory(factory.id, factory.description, factory.contract, factory); + + if (factory.unregister) + require('../system/unload').when(unregister.bind(null, factory)); +} +exports.register = register; + +/** + * Unregister a factory associated with a particular class identified by + * `factory.classID`. + */ +function unregister(factory) { + if (isRegistered(factory)) + unregisterFactory(factory.id, factory); +} +exports.unregister = unregister; + +function autoRegister(path) { + // TODO: This assumes that the url points to a directory + // that contains subdirectories corresponding to OS/ABI and then + // further subdirectories corresponding to Gecko platform version. + // we should probably either behave intelligently here or allow + // the caller to pass-in more options if e.g. there aren't + // Gecko-specific binaries for a component (which will be the case + // if only frozen interfaces are used). + + var runtime = require("../system/runtime"); + var osDirName = runtime.OS + "_" + runtime.XPCOMABI; + var platformVersion = require("../system/xul-app").platformVersion.substring(0, 5); + + var file = Cc['@mozilla.org/file/local;1'] + .createInstance(Ci.nsILocalFile); + file.initWithPath(path); + file.append(osDirName); + file.append(platformVersion); + + if (!(file.exists() && file.isDirectory())) + throw new Error("component not available for OS/ABI " + + osDirName + " and platform " + platformVersion); + + Cm.QueryInterface(Ci.nsIComponentRegistrar); + Cm.autoRegister(file); +} +exports.autoRegister = autoRegister; + +/** + * Returns registered factory that has a given `id` or `null` if not found. + */ +function factoryByID(id) { + return classesByID[id] || null; +} +exports.factoryByID = factoryByID; + +/** + * Returns factory registered with a given `contract` or `null` if not found. + * In contrast to `Cc[contract]` that does ignores new factory registration + * with a given `contract` this will return a factory currently associated + * with a `contract`. + */ +function factoryByContract(contract) { + return factoryByID(Cm.contractIDToCID(contract)); +} +exports.factoryByContract = factoryByContract; diff --git a/addon-sdk/source/lib/sdk/preferences/event-target.js b/addon-sdk/source/lib/sdk/preferences/event-target.js new file mode 100644 index 000000000..b64ba303c --- /dev/null +++ b/addon-sdk/source/lib/sdk/preferences/event-target.js @@ -0,0 +1,61 @@ +/* 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'; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci } = require('chrome'); +const { Class } = require('../core/heritage'); +const { EventTarget } = require('../event/target'); +const { Branch } = require('./service'); +const { emit, off } = require('../event/core'); +const { when: unload } = require('../system/unload'); + +const prefTargetNS = require('../core/namespace').ns(); + +const PrefsTarget = Class({ + extends: EventTarget, + initialize: function(options) { + options = options || {}; + EventTarget.prototype.initialize.call(this, options); + + let branchName = options.branchName || ''; + let branch = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefService). + getBranch(branchName). + QueryInterface(Ci.nsIPrefBranch2); + prefTargetNS(this).branch = branch; + + // provides easy access to preference values + this.prefs = Branch(branchName); + + // start listening to preference changes + let observer = prefTargetNS(this).observer = onChange.bind(this); + branch.addObserver('', observer, false); + + // Make sure to destroy this on unload + unload(destroy.bind(this)); + } +}); +exports.PrefsTarget = PrefsTarget; + +/* HELPERS */ + +function onChange(subject, topic, name) { + if (topic === 'nsPref:changed') { + emit(this, name, name); + emit(this, '', name); + } +} + +function destroy() { + off(this); + + // stop listening to preference changes + let branch = prefTargetNS(this).branch; + branch.removeObserver('', prefTargetNS(this).observer, false); + prefTargetNS(this).observer = null; +} diff --git a/addon-sdk/source/lib/sdk/preferences/native-options.js b/addon-sdk/source/lib/sdk/preferences/native-options.js new file mode 100644 index 000000000..840997df9 --- /dev/null +++ b/addon-sdk/source/lib/sdk/preferences/native-options.js @@ -0,0 +1,193 @@ +/* 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'; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci, Cu } = require('chrome'); +const { on } = require('../system/events'); +const { id, preferencesBranch } = require('../self'); +const { localizeInlineOptions } = require('../l10n/prefs'); +const { Services } = require("resource://gre/modules/Services.jsm"); +const { AddonManager } = Cu.import("resource://gre/modules/AddonManager.jsm"); +const { defer } = require("sdk/core/promise"); + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";; +const DEFAULT_OPTIONS_URL = 'data:text/xml,<placeholder/>'; + +const VALID_PREF_TYPES = ['bool', 'boolint', 'integer', 'string', 'color', + 'file', 'directory', 'control', 'menulist', 'radio']; + +const isFennec = require("sdk/system/xul-app").is("Fennec"); + +function enable({ preferences, id }) { + let enabled = defer(); + + validate(preferences); + + setDefaults(preferences, preferencesBranch); + + // allow the use of custom options.xul + AddonManager.getAddonByID(id, (addon) => { + on('addon-options-displayed', onAddonOptionsDisplayed, true); + enabled.resolve({ id: id }); + }); + + function onAddonOptionsDisplayed({ subject: doc, data }) { + if (data === id) { + let parent; + + if (isFennec) { + parent = doc.querySelector('.options-box'); + + // NOTE: This disable the CSS rule that makes the options invisible + let item = doc.querySelector('#addons-details .addon-item'); + item.removeAttribute("optionsURL"); + } else { + parent = doc.getElementById('detail-downloads').parentNode; + } + + if (parent) { + injectOptions({ + preferences: preferences, + preferencesBranch: preferencesBranch, + document: doc, + parent: parent, + id: id + }); + localizeInlineOptions(doc); + } else { + throw Error("Preferences parent node not found in Addon Details. The configured custom preferences will not be visible."); + } + } + } + + return enabled.promise; +} +exports.enable = enable; + +// centralized sanity checks +function validate(preferences) { + for (let { name, title, type, label, options } of preferences) { + // make sure the title is set and non-empty + if (!title) + throw Error("The '" + name + "' pref requires a title"); + + // make sure that pref type is a valid inline option type + if (!~VALID_PREF_TYPES.indexOf(type)) + throw Error("The '" + name + "' pref must be of valid type"); + + // if it's a control, make sure it has a label + if (type === 'control' && !label) + throw Error("The '" + name + "' control requires a label"); + + // if it's a menulist or radio, make sure it has options + if (type === 'menulist' || type === 'radio') { + if (!options) + throw Error("The '" + name + "' pref requires options"); + + // make sure each option has a value and a label + for (let item of options) { + if (!('value' in item) || !('label' in item)) + throw Error("Each option requires both a value and a label"); + } + } + + // TODO: check that pref type matches default value type + } +} +exports.validate = validate; + +// initializes default preferences, emulates defaults/prefs.js +function setDefaults(preferences, preferencesBranch) { + const branch = Cc['@mozilla.org/preferences-service;1']. + getService(Ci.nsIPrefService). + getDefaultBranch('extensions.' + preferencesBranch + '.'); + for (let { name, value } of preferences) { + switch (typeof value) { + case 'boolean': + branch.setBoolPref(name, value); + break; + case 'number': + // must be integer, ignore otherwise + if (value % 1 === 0) { + branch.setIntPref(name, value); + } + break; + case 'string': + let str = Cc["@mozilla.org/supports-string;1"]. + createInstance(Ci.nsISupportsString); + str.data = value; + branch.setComplexValue(name, Ci.nsISupportsString, str); + break; + } + } +} +exports.setDefaults = setDefaults; + +// dynamically injects inline options into about:addons page at runtime +// NOTE: on Firefox Desktop the about:addons page is a xul page document, +// on Firefox for Android the about:addons page is an xhtml page, to support both +// the XUL xml namespace have to be enforced. +function injectOptions({ preferences, preferencesBranch, document, parent, id }) { + preferences.forEach(({name, type, hidden, title, description, label, options, on, off}) => { + if (hidden) { + return; + } + + let setting = document.createElementNS(XUL_NS, 'setting'); + setting.setAttribute('pref-name', name); + setting.setAttribute('data-jetpack-id', id); + setting.setAttribute('pref', 'extensions.' + preferencesBranch + '.' + name); + setting.setAttribute('type', type); + setting.setAttribute('title', title); + if (description) + setting.setAttribute('desc', description); + + if (type === 'file' || type === 'directory') { + setting.setAttribute('fullpath', 'true'); + } + else if (type === 'control') { + let button = document.createElementNS(XUL_NS, 'button'); + button.setAttribute('pref-name', name); + button.setAttribute('data-jetpack-id', id); + button.setAttribute('label', label); + button.addEventListener('command', function() { + Services.obs.notifyObservers(null, `${id}-cmdPressed`, name); + }, true); + setting.appendChild(button); + } + else if (type === 'boolint') { + setting.setAttribute('on', on); + setting.setAttribute('off', off); + } + else if (type === 'menulist') { + let menulist = document.createElementNS(XUL_NS, 'menulist'); + let menupopup = document.createElementNS(XUL_NS, 'menupopup'); + for (let { value, label } of options) { + let menuitem = document.createElementNS(XUL_NS, 'menuitem'); + menuitem.setAttribute('value', value); + menuitem.setAttribute('label', label); + menupopup.appendChild(menuitem); + } + menulist.appendChild(menupopup); + setting.appendChild(menulist); + } + else if (type === 'radio') { + let radiogroup = document.createElementNS(XUL_NS, 'radiogroup'); + for (let { value, label } of options) { + let radio = document.createElementNS(XUL_NS, 'radio'); + radio.setAttribute('value', value); + radio.setAttribute('label', label); + radiogroup.appendChild(radio); + } + setting.appendChild(radiogroup); + } + + parent.appendChild(setting); + }); +} +exports.injectOptions = injectOptions; diff --git a/addon-sdk/source/lib/sdk/preferences/service.js b/addon-sdk/source/lib/sdk/preferences/service.js new file mode 100644 index 000000000..231cd8e14 --- /dev/null +++ b/addon-sdk/source/lib/sdk/preferences/service.js @@ -0,0 +1,137 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +// 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; +const MIN_INT = -0x80000000; + +const {Cc,Ci,Cr} = require("chrome"); + +const prefService = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefService); +const prefSvc = prefService.getBranch(null); +const defaultBranch = prefService.getDefaultBranch(null); + +const { Preferences } = require("resource://gre/modules/Preferences.jsm"); +const prefs = new Preferences({}); + +const branchKeys = branchName => + keys(branchName).map($ => $.replace(branchName, "")); + +const Branch = function(branchName) { + return new Proxy(Branch.prototype, { + getOwnPropertyDescriptor(target, name, receiver) { + return { + configurable: true, + enumerable: true, + writable: false, + value: this.get(target, name, receiver) + }; + }, + ownKeys(target) { + return branchKeys(branchName); + }, + get(target, name, receiver) { + return get(`${branchName}${name}`); + }, + set(target, name, value, receiver) { + set(`${branchName}${name}`, value); + return true; + }, + has(target, name) { + return this.hasOwn(target, name); + }, + hasOwn(target, name) { + return has(`${branchName}${name}`); + }, + deleteProperty(target, name) { + reset(`${branchName}${name}`); + return true; + } + }); +} + + +function get(name, defaultValue) { + return prefs.get(name, defaultValue); +} +exports.get = get; + + +function set(name, value) { + var prefType; + if (typeof value != "undefined" && value != null) + prefType = value.constructor.name; + + switch (prefType) { + case "Number": + if (value % 1 != 0) + throw new Error("cannot store non-integer number: " + value); + } + + prefs.set(name, value); +} +exports.set = set; + +const has = prefs.has.bind(prefs) +exports.has = has; + +function keys(root) { + return prefSvc.getChildList(root); +} +exports.keys = keys; + +const isSet = prefs.isSet.bind(prefs); +exports.isSet = isSet; + +function reset(name) { + try { + prefSvc.clearUserPref(name); + } + catch (e) { + // The pref service throws NS_ERROR_UNEXPECTED when the caller tries + // to reset a pref that doesn't exist or is already set to its default + // value. This interface fails silently in those cases, so callers + // can unconditionally reset a pref without having to check if it needs + // resetting first or trap exceptions after the fact. It passes through + // other exceptions, however, so callers know about them, since we don't + // know what other exceptions might be thrown and what they might mean. + if (e.result != Cr.NS_ERROR_UNEXPECTED) { + throw e; + } + } +} +exports.reset = reset; + +function getLocalized(name, defaultValue) { + let value = null; + try { + value = prefSvc.getComplexValue(name, Ci.nsIPrefLocalizedString).data; + } + finally { + return value || defaultValue; + } +} +exports.getLocalized = getLocalized; + +function setLocalized(name, value) { + // We can't use `prefs.set` here as we have to use `getDefaultBranch` + // (instead of `getBranch`) in order to have `mIsDefault` set to true, here: + // http://mxr.mozilla.org/mozilla-central/source/modules/libpref/src/nsPrefBranch.cpp#233 + // Otherwise, we do not enter into this expected condition: + // http://mxr.mozilla.org/mozilla-central/source/modules/libpref/src/nsPrefBranch.cpp#244 + defaultBranch.setCharPref(name, value); +} +exports.setLocalized = setLocalized; + +exports.Branch = Branch; + diff --git a/addon-sdk/source/lib/sdk/preferences/utils.js b/addon-sdk/source/lib/sdk/preferences/utils.js new file mode 100644 index 000000000..1d5769c37 --- /dev/null +++ b/addon-sdk/source/lib/sdk/preferences/utils.js @@ -0,0 +1,42 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { openTab, getBrowserForTab, getTabId } = require("sdk/tabs/utils"); +const { on, off } = require("sdk/system/events"); +const { getMostRecentBrowserWindow } = require('../window/utils'); + +// Opens about:addons in a new tab, then displays the inline +// preferences of the provided add-on +const open = ({ id }) => new Promise((resolve, reject) => { + // opening the about:addons page in a new tab + let tab = openTab(getMostRecentBrowserWindow(), "about:addons"); + let browser = getBrowserForTab(tab); + + // waiting for the about:addons page to load + browser.addEventListener("load", function onPageLoad() { + browser.removeEventListener("load", onPageLoad, true); + let window = browser.contentWindow; + + // wait for the add-on's "addon-options-displayed" + on("addon-options-displayed", function onPrefDisplayed({ subject: doc, data }) { + if (data === id) { + off("addon-options-displayed", onPrefDisplayed); + resolve({ + id: id, + tabId: getTabId(tab), + "document": doc + }); + } + }, true); + + // display the add-on inline preferences page + window.gViewController.commands.cmd_showItemDetails.doCommand({ id: id }, true); + }, true); +}); +exports.open = open; diff --git a/addon-sdk/source/lib/sdk/private-browsing.js b/addon-sdk/source/lib/sdk/private-browsing.js new file mode 100644 index 000000000..29ca16185 --- /dev/null +++ b/addon-sdk/source/lib/sdk/private-browsing.js @@ -0,0 +1,12 @@ +/* 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'; + +module.metadata = { + "stability": "stable" +}; + +const { isPrivate } = require('./private-browsing/utils'); + +exports.isPrivate = isPrivate; diff --git a/addon-sdk/source/lib/sdk/private-browsing/utils.js b/addon-sdk/source/lib/sdk/private-browsing/utils.js new file mode 100644 index 000000000..8b012f0ce --- /dev/null +++ b/addon-sdk/source/lib/sdk/private-browsing/utils.js @@ -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'; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci, Cu } = require('chrome'); +const { is } = require('../system/xul-app'); +const { isWindowPrivate } = require('../window/utils'); +const { isPrivateBrowsingSupported } = require('../self'); +const { dispatcher } = require("../util/dispatcher"); + +var PrivateBrowsingUtils; + +// Private browsing is only supported in Fx +try { + PrivateBrowsingUtils = Cu.import('resource://gre/modules/PrivateBrowsingUtils.jsm', {}).PrivateBrowsingUtils; +} +catch (e) {} + +exports.isGlobalPBSupported = false; + +// checks that per-window private browsing is implemented +var isWindowPBSupported = exports.isWindowPBSupported = + !!PrivateBrowsingUtils && is('Firefox'); + +// checks that per-tab private browsing is implemented +var isTabPBSupported = exports.isTabPBSupported = + !!PrivateBrowsingUtils && is('Fennec'); + +function isPermanentPrivateBrowsing() { + return !!(PrivateBrowsingUtils && PrivateBrowsingUtils.permanentPrivateBrowsing); +} +exports.isPermanentPrivateBrowsing = isPermanentPrivateBrowsing; + +function ignoreWindow(window) { + return !isPrivateBrowsingSupported && isWindowPrivate(window); +} +exports.ignoreWindow = ignoreWindow; + +var getMode = function getMode(chromeWin) { + return (chromeWin !== undefined && isWindowPrivate(chromeWin)); +}; +exports.getMode = getMode; + +const isPrivate = dispatcher("isPrivate"); +isPrivate.when(isPermanentPrivateBrowsing, _ => true); +isPrivate.when(x => x instanceof Ci.nsIDOMWindow, isWindowPrivate); +isPrivate.when(x => Ci.nsIPrivateBrowsingChannel && x instanceof Ci.nsIPrivateBrowsingChannel, x => x.isChannelPrivate); +isPrivate.define(() => false); +exports.isPrivate = isPrivate; diff --git a/addon-sdk/source/lib/sdk/querystring.js b/addon-sdk/source/lib/sdk/querystring.js new file mode 100644 index 000000000..9982a00ab --- /dev/null +++ b/addon-sdk/source/lib/sdk/querystring.js @@ -0,0 +1,121 @@ +/* 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'; + +module.metadata = { + "stability": "unstable" +}; + +var unescape = decodeURIComponent; +exports.unescape = unescape; + +// encodes a string safely for application/x-www-form-urlencoded +// adheres to RFC 3986 +// see https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/encodeURIComponent +function escape(query) { + return encodeURIComponent(query). + replace(/%20/g, '+'). + replace(/!/g, '%21'). + replace(/'/g, '%27'). + replace(/\(/g, '%28'). + replace(/\)/g, '%29'). + replace(/\*/g, '%2A'); +} +exports.escape = escape; + +// Converts an object of unordered key-vals to a string that can be passed +// as part of a request +function stringify(options, separator, assigner) { + separator = separator || '&'; + assigner = assigner || '='; + // Explicitly return null if we have null, and empty string, or empty object. + if (!options) + return ''; + + // If content is already a string, just return it as is. + if (typeof(options) == 'string') + return options; + + // At this point we have a k:v object. Iterate over it and encode each value. + // Arrays and nested objects will get encoded as needed. For example... + // + // { foo: [1, 2, { omg: 'bbq', 'all your base!': 'are belong to us' }], bar: 'baz' } + // + // will be encoded as + // + // foo[0]=1&foo[1]=2&foo[2][omg]=bbq&foo[2][all+your+base!]=are+belong+to+us&bar=baz + // + // Keys (including '[' and ']') and values will be encoded with + // `escape` before returning. + // + // Execution was inspired by jQuery, but some details have changed and numeric + // array keys are included (whereas they are not in jQuery). + + let encodedContent = []; + function add(key, val) { + encodedContent.push(escape(key) + assigner + escape(val)); + } + + function make(key, value) { + if (value && typeof(value) === 'object') + Object.keys(value).forEach(function(name) { + make(key + '[' + name + ']', value[name]); + }); + else + add(key, value); + } + + Object.keys(options).forEach(function(name) { make(name, options[name]); }); + return encodedContent.join(separator); + + //XXXzpao In theory, we can just use a FormData object on 1.9.3, but I had + // trouble getting that working. It would also be nice to stay + // backwards-compat as long as possible. Keeping this in for now... + // let formData = Cc['@mozilla.org/files/formdata;1']. + // createInstance(Ci.nsIDOMFormData); + // for ([k, v] in Iterator(content)) { + // formData.append(k, v); + // } + // return formData; +} +exports.stringify = stringify; + +// Exporting aliases that nodejs implements just for the sake of +// interoperability. +exports.encode = stringify; +exports.serialize = stringify; + +// Note: That `stringify` and `parse` aren't bijective as we use `stringify` +// as it was implement in request module, but implement `parse` to match nodejs +// behavior. +// TODO: Make `stringify` implement API as in nodejs and figure out backwards +// compatibility. +function parse(query, separator, assigner) { + separator = separator || '&'; + assigner = assigner || '='; + let result = {}; + + if (typeof query !== 'string' || query.length === 0) + return result; + + query.split(separator).forEach(function(chunk) { + let pair = chunk.split(assigner); + let key = unescape(pair[0]); + let value = unescape(pair.slice(1).join(assigner)); + + if (!(key in result)) + result[key] = value; + else if (Array.isArray(result[key])) + result[key].push(value); + else + result[key] = [result[key], value]; + }); + + return result; +}; +exports.parse = parse; +// Exporting aliases that nodejs implements just for the sake of +// interoperability. +exports.decode = parse; diff --git a/addon-sdk/source/lib/sdk/remote/child.js b/addon-sdk/source/lib/sdk/remote/child.js new file mode 100644 index 000000000..4ccfa661a --- /dev/null +++ b/addon-sdk/source/lib/sdk/remote/child.js @@ -0,0 +1,284 @@ +/* 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 { isChildLoader } = require('./core'); +if (!isChildLoader) + throw new Error("Cannot load sdk/remote/child in a main process loader."); + +const { Ci, Cc, Cu } = require('chrome'); +const runtime = require('../system/runtime'); +const { Class } = require('../core/heritage'); +const { Namespace } = require('../core/namespace'); +const { omit } = require('../util/object'); +const { when } = require('../system/unload'); +const { EventTarget } = require('../event/target'); +const { emit } = require('../event/core'); +const { Disposable } = require('../core/disposable'); +const { EventParent } = require('./utils'); +const { addListItem, removeListItem } = require('../util/list'); + +const loaderID = require('@loader/options').loaderID; + +const MAIN_PROCESS = Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + +const mm = Cc['@mozilla.org/childprocessmessagemanager;1']. + getService(Ci.nsISyncMessageSender); + +const ns = Namespace(); + +const process = { + port: new EventTarget(), + get id() { + return runtime.processID; + }, + get isRemote() { + return runtime.processType != MAIN_PROCESS; + } +}; +exports.process = process; + +function definePort(obj, name) { + obj.port.emit = (event, ...args) => { + let manager = ns(obj).messageManager; + if (!manager) + return; + + manager.sendAsyncMessage(name, { loaderID, event, args }); + }; +} + +function messageReceived({ data, objects }) { + // Ignore messages from other loaders + if (data.loaderID != loaderID) + return; + + let keys = Object.keys(objects); + if (keys.length) { + // If any objects are CPOWs then ignore this message. We don't want child + // processes interracting with CPOWs + if (!keys.every(name => !Cu.isCrossProcessWrapper(objects[name]))) + return; + + data.args.push(objects); + } + + emit(this.port, data.event, this, ...data.args); +} + +ns(process).messageManager = mm; +definePort(process, 'sdk/remote/process/message'); +let processMessageReceived = messageReceived.bind(process); +mm.addMessageListener('sdk/remote/process/message', processMessageReceived); + +when(() => { + mm.removeMessageListener('sdk/remote/process/message', processMessageReceived); + frames = null; +}); + +process.port.on('sdk/remote/require', (process, uri) => { + require(uri); +}); + +function listenerEquals(a, b) { + for (let prop of ["type", "callback", "isCapturing"]) { + if (a[prop] != b[prop]) + return false; + } + return true; +} + +function listenerFor(type, callback, isCapturing = false) { + return { + type, + callback, + isCapturing, + registeredCallback: undefined, + get args() { + return [ + this.type, + this.registeredCallback ? this.registeredCallback : this.callback, + this.isCapturing + ]; + } + }; +} + +function removeListenerFromArray(array, listener) { + let index = array.findIndex(l => listenerEquals(l, listener)); + if (index < 0) + return; + array.splice(index, 1); +} + +function getListenerFromArray(array, listener) { + return array.find(l => listenerEquals(l, listener)); +} + +function arrayContainsListener(array, listener) { + return !!getListenerFromArray(array, listener); +} + +function makeFrameEventListener(frame, callback) { + return callback.bind(frame); +} + +var FRAME_ID = 0; +var tabMap = new Map(); + +const Frame = Class({ + implements: [ Disposable ], + extends: EventTarget, + setup: function(contentFrame) { + // This ID should be unique for this loader across all processes + let priv = ns(this); + + priv.id = runtime.processID + ":" + FRAME_ID++; + + priv.contentFrame = contentFrame; + priv.messageManager = contentFrame; + priv.domListeners = []; + + tabMap.set(contentFrame.docShell, this); + + priv.messageReceived = messageReceived.bind(this); + priv.messageManager.addMessageListener('sdk/remote/frame/message', priv.messageReceived); + + this.port = new EventTarget(); + definePort(this, 'sdk/remote/frame/message'); + + priv.messageManager.sendAsyncMessage('sdk/remote/frame/attach', { + loaderID, + frameID: priv.id, + processID: runtime.processID + }); + + frames.attachItem(this); + }, + + dispose: function() { + let priv = ns(this); + + emit(this, 'detach', this); + + for (let listener of priv.domListeners) + priv.contentFrame.removeEventListener(...listener.args); + + priv.messageManager.removeMessageListener('sdk/remote/frame/message', priv.messageReceived); + tabMap.delete(priv.contentFrame.docShell); + priv.contentFrame = null; + }, + + get content() { + return ns(this).contentFrame.content; + }, + + get isTab() { + let docShell = ns(this).contentFrame.docShell; + if (process.isRemote) { + // We don't want to roundtrip to the main process to get this property. + // This hack relies on the host app having defined webBrowserChrome only + // in frames that are part of the tabs. Since only Firefox has remote + // processes right now and does this this works. + let tabchild = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsITabChild); + return !!tabchild.webBrowserChrome; + } + else { + // This is running in the main process so we can break out to the browser + // And check we can find a tab for the browser element directly. + let browser = docShell.chromeEventHandler; + let tab = require('../tabs/utils').getTabForBrowser(browser); + return !!tab; + } + }, + + addEventListener: function(...args) { + let priv = ns(this); + + let listener = listenerFor(...args); + if (arrayContainsListener(priv.domListeners, listener)) + return; + + listener.registeredCallback = makeFrameEventListener(this, listener.callback); + + priv.domListeners.push(listener); + priv.contentFrame.addEventListener(...listener.args); + }, + + removeEventListener: function(...args) { + let priv = ns(this); + + let listener = getListenerFromArray(priv.domListeners, listenerFor(...args)); + if (!listener) + return; + + removeListenerFromArray(priv.domListeners, listener); + priv.contentFrame.removeEventListener(...listener.args); + } +}); + +const FrameList = Class({ + implements: [ EventParent, Disposable ], + extends: EventTarget, + setup: function() { + EventParent.prototype.initialize.call(this); + + this.port = new EventTarget(); + ns(this).domListeners = []; + + this.on('attach', frame => { + for (let listener of ns(this).domListeners) + frame.addEventListener(...listener.args); + }); + }, + + dispose: function() { + // The only case where we get destroyed is when the loader is unloaded in + // which case each frame will clean up its own event listeners. + ns(this).domListeners = null; + }, + + getFrameForWindow: function(window) { + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + + return tabMap.get(docShell) || null; + }, + + addEventListener: function(...args) { + let listener = listenerFor(...args); + if (arrayContainsListener(ns(this).domListeners, listener)) + return; + + ns(this).domListeners.push(listener); + for (let frame of this) + frame.addEventListener(...listener.args); + }, + + removeEventListener: function(...args) { + let listener = listenerFor(...args); + if (!arrayContainsListener(ns(this).domListeners, listener)) + return; + + removeListenerFromArray(ns(this).domListeners, listener); + for (let frame of this) + frame.removeEventListener(...listener.args); + } +}); +var frames = exports.frames = new FrameList(); + +function registerContentFrame(contentFrame) { + let frame = new Frame(contentFrame); +} +exports.registerContentFrame = registerContentFrame; + +function unregisterContentFrame(contentFrame) { + let frame = tabMap.get(contentFrame.docShell); + if (!frame) + return; + + frame.destroy(); +} +exports.unregisterContentFrame = unregisterContentFrame; diff --git a/addon-sdk/source/lib/sdk/remote/core.js b/addon-sdk/source/lib/sdk/remote/core.js new file mode 100644 index 000000000..78bb673fd --- /dev/null +++ b/addon-sdk/source/lib/sdk/remote/core.js @@ -0,0 +1,8 @@ +/* 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 options = require("@loader/options"); + +exports.isChildLoader = options.childLoader; diff --git a/addon-sdk/source/lib/sdk/remote/parent.js b/addon-sdk/source/lib/sdk/remote/parent.js new file mode 100644 index 000000000..f110fe3f6 --- /dev/null +++ b/addon-sdk/source/lib/sdk/remote/parent.js @@ -0,0 +1,338 @@ +/* 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 { isChildLoader } = require('./core'); +if (isChildLoader) + throw new Error("Cannot load sdk/remote/parent in a child loader."); + +const { Cu, Ci, Cc } = require('chrome'); +const runtime = require('../system/runtime'); + +const MAIN_PROCESS = Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + +if (runtime.processType != MAIN_PROCESS) { + throw new Error('Cannot use sdk/remote/parent in a child process.'); +} + +const { Class } = require('../core/heritage'); +const { Namespace } = require('../core/namespace'); +const { Disposable } = require('../core/disposable'); +const { omit } = require('../util/object'); +const { when } = require('../system/unload'); +const { EventTarget } = require('../event/target'); +const { emit } = require('../event/core'); +const system = require('../system/events'); +const { EventParent } = require('./utils'); +const options = require('@loader/options'); +const loaderModule = require('toolkit/loader'); +const { getTabForBrowser } = require('../tabs/utils'); + +const appInfo = Cc["@mozilla.org/xre/app-info;1"]. + getService(Ci.nsIXULRuntime); + +exports.useRemoteProcesses = appInfo.browserTabsRemoteAutostart; + +// Chose the right function for resolving relative a module id +var moduleResolve; +if (options.isNative) { + moduleResolve = (id, requirer) => loaderModule.nodeResolve(id, requirer, { rootURI: options.rootURI }); +} +else { + moduleResolve = loaderModule.resolve; +} +// Build the sorted path mapping structure that resolveURI requires +var pathMapping = Object.keys(options.paths) + .sort((a, b) => b.length - a.length) + .map(p => [p, options.paths[p]]); + +// Load the scripts in the child processes +var { getNewLoaderID } = require('../../framescript/FrameScriptManager.jsm'); +var PATH = options.paths['']; + +const childOptions = omit(options, ['modules', 'globals', 'resolve', 'load']); +childOptions.modules = {}; +// @l10n/data is just JSON data and can be safely sent across to the child loader +try { + childOptions.modules["@l10n/data"] = require("@l10n/data"); +} +catch (e) { + // There may be no l10n data +} +const loaderID = getNewLoaderID(); +childOptions.loaderID = loaderID; +childOptions.childLoader = true; + +const ppmm = Cc['@mozilla.org/parentprocessmessagemanager;1']. + getService(Ci.nsIMessageBroadcaster); +const gmm = Cc['@mozilla.org/globalmessagemanager;1']. + getService(Ci.nsIMessageBroadcaster); + +const ns = Namespace(); + +var processMap = new Map(); + +function definePort(obj, name) { + obj.port.emitCPOW = (event, args, cpows = {}) => { + let manager = ns(obj).messageManager; + if (!manager) + return; + + let method = manager instanceof Ci.nsIMessageBroadcaster ? + "broadcastAsyncMessage" : "sendAsyncMessage"; + + manager[method](name, { loaderID, event, args }, cpows); + }; + + obj.port.emit = (event, ...args) => obj.port.emitCPOW(event, args); +} + +function messageReceived({ target, data }) { + // Ignore messages from other loaders + if (data.loaderID != loaderID) + return; + + emit(this.port, data.event, this, ...data.args); +} + +// Process represents a gecko process that can load webpages. Each process +// contains a number of Frames. This class is used to send and receive messages +// from a single process. +const Process = Class({ + implements: [ Disposable ], + extends: EventTarget, + setup: function(id, messageManager, isRemote) { + ns(this).id = id; + ns(this).isRemote = isRemote; + ns(this).messageManager = messageManager; + ns(this).messageReceived = messageReceived.bind(this); + this.destroy = this.destroy.bind(this); + ns(this).messageManager.addMessageListener('sdk/remote/process/message', ns(this).messageReceived); + ns(this).messageManager.addMessageListener('child-process-shutdown', this.destroy); + + this.port = new EventTarget(); + definePort(this, 'sdk/remote/process/message'); + + // Load any remote modules + for (let module of remoteModules.values()) + this.port.emit('sdk/remote/require', module); + + processMap.set(ns(this).id, this); + processes.attachItem(this); + }, + + dispose: function() { + emit(this, 'detach', this); + processMap.delete(ns(this).id); + ns(this).messageManager.removeMessageListener('sdk/remote/process/message', ns(this).messageReceived); + ns(this).messageManager.removeMessageListener('child-process-shutdown', this.destroy); + ns(this).messageManager = null; + }, + + // Returns true if this process is a child process + get isRemote() { + return ns(this).isRemote; + } +}); + +// Processes gives an API for enumerating an sending and receiving messages from +// all processes as well as detecting when a new process starts. +const Processes = Class({ + implements: [ EventParent ], + extends: EventTarget, + initialize: function() { + EventParent.prototype.initialize.call(this); + ns(this).messageManager = ppmm; + + this.port = new EventTarget(); + definePort(this, 'sdk/remote/process/message'); + }, + + getById: function(id) { + return processMap.get(id); + } +}); +var processes = exports.processes = new Processes(); + +var frameMap = new Map(); + +function setFrameProcess(frame, process) { + ns(frame).process = process; + frames.attachItem(frame); +} + +// Frames display webpages in a process. In the main process every Frame is +// linked with a <browser> or <iframe> element. +const Frame = Class({ + implements: [ Disposable ], + extends: EventTarget, + setup: function(id, node) { + ns(this).id = id; + ns(this).node = node; + + let frameLoader = node.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader; + ns(this).messageManager = frameLoader.messageManager; + + ns(this).messageReceived = messageReceived.bind(this); + ns(this).messageManager.addMessageListener('sdk/remote/frame/message', ns(this).messageReceived); + + this.port = new EventTarget(); + definePort(this, 'sdk/remote/frame/message'); + + frameMap.set(ns(this).messageManager, this); + }, + + dispose: function() { + emit(this, 'detach', this); + ns(this).messageManager.removeMessageListener('sdk/remote/frame/message', ns(this).messageReceived); + + frameMap.delete(ns(this).messageManager); + ns(this).messageManager = null; + }, + + // Returns the browser or iframe element this frame displays in + get frameElement() { + return ns(this).node; + }, + + // Returns the process that this frame loads in + get process() { + return ns(this).process; + }, + + // Returns true if this frame is a tab in a main browser window + get isTab() { + let tab = getTabForBrowser(ns(this).node); + return !!tab; + } +}); + +function managerDisconnected({ subject: manager }) { + let frame = frameMap.get(manager); + if (frame) + frame.destroy(); +} +system.on('message-manager-disconnect', managerDisconnected); + +// Provides an API for enumerating and sending and receiving messages from all +// Frames +const FrameList = Class({ + implements: [ EventParent ], + extends: EventTarget, + initialize: function() { + EventParent.prototype.initialize.call(this); + ns(this).messageManager = gmm; + + this.port = new EventTarget(); + definePort(this, 'sdk/remote/frame/message'); + }, + + // Returns the frame for a browser element + getFrameForBrowser: function(browser) { + for (let frame of this) { + if (frame.frameElement == browser) + return frame; + } + return null; + }, +}); +var frames = exports.frames = new FrameList(); + +// Create the module loader in any existing processes +ppmm.broadcastAsyncMessage('sdk/remote/process/load', { + modulePath: PATH, + loaderID, + options: childOptions, + reason: "broadcast" +}); + +// A loader has started in a remote process +function processLoaderStarted({ target, data }) { + if (data.loaderID != loaderID) + return; + + if (processMap.has(data.processID)) { + console.error("Saw the same process load the same loader twice. This is a bug in the SDK."); + return; + } + + let process = new Process(data.processID, target, data.isRemote); + + if (pendingFrames.has(data.processID)) { + for (let frame of pendingFrames.get(data.processID)) + setFrameProcess(frame, process); + pendingFrames.delete(data.processID); + } +} + +// A new process has started +function processStarted({ target, data: { modulePath } }) { + if (modulePath != PATH) + return; + + // Have it load a loader if it hasn't already + target.sendAsyncMessage('sdk/remote/process/load', { + modulePath, + loaderID, + options: childOptions, + reason: "response" + }); +} + +var pendingFrames = new Map(); + +// A new frame has been created in the remote process +function frameAttached({ target, data }) { + if (data.loaderID != loaderID) + return; + + let frame = new Frame(data.frameID, target); + + let process = processMap.get(data.processID); + if (process) { + setFrameProcess(frame, process); + return; + } + + // In some cases frame messages can arrive earlier than process messages + // causing us to see a new frame appear before its process. In this case + // cache the frame data until we see the process. See bug 1131375. + if (!pendingFrames.has(data.processID)) + pendingFrames.set(data.processID, [frame]); + else + pendingFrames.get(data.processID).push(frame); +} + +// Wait for new processes and frames +ppmm.addMessageListener('sdk/remote/process/attach', processLoaderStarted); +ppmm.addMessageListener('sdk/remote/process/start', processStarted); +gmm.addMessageListener('sdk/remote/frame/attach', frameAttached); + +when(reason => { + ppmm.removeMessageListener('sdk/remote/process/attach', processLoaderStarted); + ppmm.removeMessageListener('sdk/remote/process/start', processStarted); + gmm.removeMessageListener('sdk/remote/frame/attach', frameAttached); + + ppmm.broadcastAsyncMessage('sdk/remote/process/unload', { loaderID, reason }); +}); + +var remoteModules = new Set(); + +// Ensures a module is loaded in every child process. It is safe to send +// messages to this module immediately after calling this. +// Pass a module to resolve the id relatively. +function remoteRequire(id, module = null) { + // Resolve relative to calling module if passed + if (module) + id = moduleResolve(id, module.id); + let uri = loaderModule.resolveURI(id, pathMapping); + + // Don't reload the same module + if (remoteModules.has(uri)) + return; + + remoteModules.add(uri); + processes.port.emit('sdk/remote/require', uri); +} +exports.remoteRequire = remoteRequire; diff --git a/addon-sdk/source/lib/sdk/remote/utils.js b/addon-sdk/source/lib/sdk/remote/utils.js new file mode 100644 index 000000000..5a5e39198 --- /dev/null +++ b/addon-sdk/source/lib/sdk/remote/utils.js @@ -0,0 +1,39 @@ +/* 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 { Class } = require('../core/heritage'); +const { List, addListItem, removeListItem } = require('../util/list'); +const { emit } = require('../event/core'); +const { pipe } = require('../event/utils'); + +// A helper class that maintains a list of EventTargets. Any events emitted +// to an EventTarget are also emitted by the EventParent. Likewise for an +// EventTarget's port property. +const EventParent = Class({ + implements: [ List ], + + attachItem: function(item) { + addListItem(this, item); + + pipe(item.port, this.port); + pipe(item, this); + + item.once('detach', () => { + removeListItem(this, item); + }) + + emit(this, 'attach', item); + }, + + // Calls listener for every object already in the list and every object + // subsequently added to the list. + forEvery: function(listener) { + for (let item of this) + listener(item); + + this.on('attach', listener); + } +}); +exports.EventParent = EventParent; diff --git a/addon-sdk/source/lib/sdk/request.js b/addon-sdk/source/lib/sdk/request.js new file mode 100644 index 000000000..96bb1e6d7 --- /dev/null +++ b/addon-sdk/source/lib/sdk/request.js @@ -0,0 +1,248 @@ +/* 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"; + +module.metadata = { + "stability": "stable" +}; + +const { ns } = require("./core/namespace"); +const { emit } = require("./event/core"); +const { merge } = require("./util/object"); +const { stringify } = require("./querystring"); +const { EventTarget } = require("./event/target"); +const { Class } = require("./core/heritage"); +const { XMLHttpRequest, forceAllowThirdPartyCookie } = require("./net/xhr"); +const apiUtils = require("./deprecated/api-utils"); +const { isValidURI } = require("./url.js"); + +const response = ns(); +const request = ns(); + +// Instead of creating a new validator for each request, just make one and +// reuse it. +const { validateOptions, validateSingleOption } = new OptionsValidator({ + url: { + // Also converts a URL instance to string, bug 857902 + map: url => url.toString(), + ok: isValidURI + }, + headers: { + map: v => v || {}, + is: ["object"], + }, + content: { + map: v => v || null, + is: ["string", "object", "null"], + }, + contentType: { + map: v => v || "application/x-www-form-urlencoded", + is: ["string"], + }, + overrideMimeType: { + map: v => v || null, + is: ["string", "null"], + }, + anonymous: { + map: v => v || false, + is: ["boolean", "null"], + } +}); + +const REUSE_ERROR = "This request object has been used already. You must " + + "create a new one to make a new request." + +// Utility function to prep the request since it's the same between +// request types +function runRequest(mode, target) { + let source = request(target) + let { xhr, url, content, contentType, headers, overrideMimeType, anonymous } = source; + + let isGetOrHead = (mode == "GET" || mode == "HEAD"); + + // If this request has already been used, then we can't reuse it. + // Throw an error. + if (xhr) + throw new Error(REUSE_ERROR); + + xhr = source.xhr = new XMLHttpRequest({ + mozAnon: anonymous + }); + + // Build the data to be set. For GET or HEAD requests, we want to append that + // to the URL before opening the request. + let data = stringify(content); + // If the URL already has ? in it, then we want to just use & + if (isGetOrHead && data) + url = url + (/\?/.test(url) ? "&" : "?") + data; + + // open the request + xhr.open(mode, url); + + + forceAllowThirdPartyCookie(xhr); + + // request header must be set after open, but before send + xhr.setRequestHeader("Content-Type", contentType); + + // set other headers + Object.keys(headers).forEach(function(name) { + xhr.setRequestHeader(name, headers[name]); + }); + + // set overrideMimeType + if (overrideMimeType) + xhr.overrideMimeType(overrideMimeType); + + // handle the readystate, create the response, and call the callback + xhr.onreadystatechange = function onreadystatechange() { + if (xhr.readyState === 4) { + let response = Response(xhr); + source.response = response; + emit(target, 'complete', response); + } + }; + + // actually send the request. + // We don't want to send data on GET or HEAD requests. + xhr.send(!isGetOrHead ? data : null); +} + +const Request = Class({ + extends: EventTarget, + initialize: function initialize(options) { + // `EventTarget.initialize` will set event listeners that are named + // like `onEvent` in this case `onComplete` listener will be set to + // `complete` event. + EventTarget.prototype.initialize.call(this, options); + + // Copy normalized options. + merge(request(this), validateOptions(options)); + }, + get url() { return request(this).url; }, + set url(value) { request(this).url = validateSingleOption('url', value); }, + get headers() { return request(this).headers; }, + set headers(value) { + return request(this).headers = validateSingleOption('headers', value); + }, + get content() { return request(this).content; }, + set content(value) { + request(this).content = validateSingleOption('content', value); + }, + get contentType() { return request(this).contentType; }, + set contentType(value) { + request(this).contentType = validateSingleOption('contentType', value); + }, + get anonymous() { return request(this).anonymous; }, + get response() { return request(this).response; }, + delete: function() { + runRequest('DELETE', this); + return this; + }, + get: function() { + runRequest('GET', this); + return this; + }, + post: function() { + runRequest('POST', this); + return this; + }, + put: function() { + runRequest('PUT', this); + return this; + }, + head: function() { + runRequest('HEAD', this); + return this; + } +}); +exports.Request = Request; + +const Response = Class({ + initialize: function initialize(request) { + response(this).request = request; + }, + // more about responseURL: https://bugzilla.mozilla.org/show_bug.cgi?id=998076 + get url() { + return response(this).request.responseURL; + }, + get text() { + return response(this).request.responseText; + }, + get xml() { + throw new Error("Sorry, the 'xml' property is no longer available. " + + "see bug 611042 for more information."); + }, + get status() { + return response(this).request.status; + }, + get statusText() { + return response(this).request.statusText; + }, + get json() { + try { + return JSON.parse(this.text); + } catch(error) { + return null; + } + }, + get headers() { + let headers = {}, lastKey; + // Since getAllResponseHeaders() will return null if there are no headers, + // defend against it by defaulting to "" + let rawHeaders = response(this).request.getAllResponseHeaders() || ""; + rawHeaders.split("\n").forEach(function (h) { + // According to the HTTP spec, the header string is terminated by an empty + // line, so we can just skip it. + if (!h.length) { + return; + } + + let index = h.indexOf(":"); + // The spec allows for leading spaces, so instead of assuming a single + // leading space, just trim the values. + let key = h.substring(0, index).trim(), + val = h.substring(index + 1).trim(); + + // For empty keys, that means that the header value spanned multiple lines. + // In that case we should append the value to the value of lastKey with a + // new line. We'll assume lastKey will be set because there should never + // be an empty key on the first pass. + if (key) { + headers[key] = val; + lastKey = key; + } + else { + headers[lastKey] += "\n" + val; + } + }); + return headers; + }, + get anonymous() { + return response(this).request.mozAnon; + } +}); + +// apiUtils.validateOptions doesn't give the ability to easily validate single +// options, so this is a wrapper that provides that ability. +function OptionsValidator(rules) { + return { + validateOptions: function (options) { + return apiUtils.validateOptions(options, rules); + }, + validateSingleOption: function (field, value) { + // We need to create a single rule object from our listed rules. To avoid + // JavaScript String warnings, check for the field & default to an empty object. + let singleRule = {}; + if (field in rules) { + singleRule[field] = rules[field]; + } + let singleOption = {}; + singleOption[field] = value; + // This should throw if it's invalid, which will bubble up & out. + return apiUtils.validateOptions(singleOption, singleRule)[field]; + } + }; +} diff --git a/addon-sdk/source/lib/sdk/selection.js b/addon-sdk/source/lib/sdk/selection.js new file mode 100644 index 000000000..8682e8c6d --- /dev/null +++ b/addon-sdk/source/lib/sdk/selection.js @@ -0,0 +1,470 @@ +/* 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"; + +module.metadata = { + "stability": "stable", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Ci, Cc } = require("chrome"), + { setTimeout } = require("./timers"), + { emit, off } = require("./event/core"), + { Class, obscure } = require("./core/heritage"), + { EventTarget } = require("./event/target"), + { ns } = require("./core/namespace"), + { when: unload } = require("./system/unload"), + { ignoreWindow } = require('./private-browsing/utils'), + { getTabs, getTabForContentWindow, + getAllTabContentWindows } = require('./tabs/utils'), + winUtils = require("./window/utils"), + events = require("./system/events"); + +// The selection types +const HTML = 0x01, + TEXT = 0x02, + DOM = 0x03; // internal use only + +// A more developer-friendly message than the caught exception when is not +// possible change a selection. +const ERR_CANNOT_CHANGE_SELECTION = + "It isn't possible to change the selection, as there isn't currently a selection"; + +const selections = ns(); + +const Selection = Class({ + /** + * Creates an object from which a selection can be set, get, etc. Each + * object has an associated with a range number. Range numbers are the + * 0-indexed counter of selection ranges as explained at + * https://developer.mozilla.org/en/DOM/Selection. + * + * @param rangeNumber + * The zero-based range index into the selection + */ + initialize: function initialize(rangeNumber) { + // In order to hide the private `rangeNumber` argument from API consumers + // while still enabling Selection getters/setters to access it, we define + // it as non enumerable, non configurable property. While consumers still + // may discover it they won't be able to do any harm which is good enough + // in this case. + Object.defineProperties(this, { + rangeNumber: { + enumerable: false, + configurable: false, + value: rangeNumber + } + }); + }, + get text() { return getSelection(TEXT, this.rangeNumber); }, + set text(value) { setSelection(TEXT, value, this.rangeNumber); }, + get html() { return getSelection(HTML, this.rangeNumber); }, + set html(value) { setSelection(HTML, value, this.rangeNumber); }, + get isContiguous() { + + // If there are multiple non empty ranges, the selection is definitely + // discontiguous. It returns `false` also if there are no valid selection. + let count = 0; + for (let sel in selectionIterator) + if (++count > 1) + break; + + return count === 1; + } +}); + +const selectionListener = { + notifySelectionChanged: function (document, selection, reason) { + if (!["SELECTALL", "KEYPRESS", "MOUSEUP"].some(type => reason & + Ci.nsISelectionListener[type + "_REASON"]) || selection.toString() == "") + return; + + this.onSelect(); + }, + + onSelect: function() { + emit(module.exports, "select"); + } +} + +/** + * Defines iterators so that discontiguous selections can be iterated. + * Empty selections are skipped - see `safeGetRange` for further details. + * + * If discontiguous selections are in a text field, only the first one + * is returned because the text field selection APIs doesn't support + * multiple selections. + */ +function* forOfIterator() { + let selection = getSelection(DOM); + let count = 0; + + if (selection) + count = selection.rangeCount || (getElementWithSelection() ? 1 : 0); + + for (let i = 0; i < count; i++) { + let sel = Selection(i); + + if (sel.text) + yield Selection(i); + } +} + +const selectionIteratorOptions = { + __iterator__: function() { + for (let item of this) + yield item; + } +} +selectionIteratorOptions[Symbol.iterator] = forOfIterator; +const selectionIterator = obscure(selectionIteratorOptions); + +/** + * Returns the most recent focused window. + * if private browsing window is most recent and not supported, + * then ignore it and return `null`, because the focused window + * can't be targeted. + */ +function getFocusedWindow() { + let window = winUtils.getFocusedWindow(); + + return ignoreWindow(window) ? null : window; +} + +/** + * Returns the focused element in the most recent focused window + * if private browsing window is most recent and not supported, + * then ignore it and return `null`, because the focused element + * can't be targeted. + */ +function getFocusedElement() { + let element = winUtils.getFocusedElement(); + + if (!element || ignoreWindow(element.ownerDocument.defaultView)) + return null; + + return element; +} + +/** + * Returns the current selection from most recent content window. Depending on + * the specified |type|, the value returned can be a string of text, stringified + * HTML, or a DOM selection object as described at + * https://developer.mozilla.org/en/DOM/Selection. + * + * @param type + * Specifies the return type of the selection. Valid values are the one + * of the constants HTML, TEXT, or DOM. + * + * @param rangeNumber + * Specifies the zero-based range index of the returned selection. + */ +function getSelection(type, rangeNumber) { + let window, selection; + try { + window = getFocusedWindow(); + selection = window.getSelection(); + } + catch (e) { + return null; + } + + // Get the selected content as the specified type + if (type == DOM) { + return selection; + } + else if (type == TEXT) { + let range = safeGetRange(selection, rangeNumber); + + if (range) + return range.toString(); + + let node = getElementWithSelection(); + + if (!node) + return null; + + return node.value.substring(node.selectionStart, node.selectionEnd); + } + else if (type == HTML) { + let range = safeGetRange(selection, rangeNumber); + // Another way, but this includes the xmlns attribute for all elements in + // Gecko 1.9.2+ : + // return Cc["@mozilla.org/xmlextras/xmlserializer;1"]. + // createInstance(Ci.nsIDOMSerializer).serializeToSTring(range. + // cloneContents()); + if (!range) + return null; + + let node = window.document.createElement("span"); + node.appendChild(range.cloneContents()); + return node.innerHTML; + } + + throw new Error("Type " + type + " is unrecognized."); +} + +/** + * Sets the current selection of the most recent content document by changing + * the existing selected text/HTML range to the specified value. + * + * @param val + * The value for the new selection + * + * @param rangeNumber + * The zero-based range index of the selection to be set + * + */ +function setSelection(type, val, rangeNumber) { + // Make sure we have a window context & that there is a current selection. + // Selection cannot be set unless there is an existing selection. + let window, selection; + + try { + window = getFocusedWindow(); + selection = window.getSelection(); + } + catch (e) { + throw new Error(ERR_CANNOT_CHANGE_SELECTION); + } + + let range = safeGetRange(selection, rangeNumber); + + if (range) { + let fragment; + + if (type === HTML) + fragment = range.createContextualFragment(val); + else { + fragment = range.createContextualFragment(""); + fragment.textContent = val; + } + + range.deleteContents(); + range.insertNode(fragment); + } + else { + let node = getElementWithSelection(); + + if (!node) + throw new Error(ERR_CANNOT_CHANGE_SELECTION); + + let { value, selectionStart, selectionEnd } = node; + + let newSelectionEnd = selectionStart + val.length; + + node.value = value.substring(0, selectionStart) + + val + + value.substring(selectionEnd, value.length); + + node.setSelectionRange(selectionStart, newSelectionEnd); + } +} + +/** + * Returns the specified range in a selection without throwing an exception. + * + * @param selection + * A selection object as described at + * https://developer.mozilla.org/en/DOM/Selection + * + * @param [rangeNumber] + * Specifies the zero-based range index of the returned selection. + * If it's not provided the function will return the first non empty + * range, if any. + */ +function safeGetRange(selection, rangeNumber) { + try { + let { rangeCount } = selection; + let range = null; + + if (typeof rangeNumber === "undefined") + rangeNumber = 0; + else + rangeCount = rangeNumber + 1; + + for (; rangeNumber < rangeCount; rangeNumber++ ) { + range = selection.getRangeAt(rangeNumber); + + if (range && range.toString()) + break; + + range = null; + } + + return range; + } + catch (e) { + return null; + } +} + +/** + * Returns a reference of the DOM's active element for the window given, if it + * supports the text field selection API and has a text selected. + * + * Note: + * we need this method because window.getSelection doesn't return a selection + * for text selected in a form field (see bug 85686) + */ +function getElementWithSelection() { + let element = getFocusedElement(); + + if (!element) + return null; + + try { + // Accessing selectionStart and selectionEnd on e.g. a button + // results in an exception thrown as per the HTML5 spec. See + // http://www.whatwg.org/specs/web-apps/current-work/multipage/association-of-controls-and-forms.html#textFieldSelection + + let { value, selectionStart, selectionEnd } = element; + + let hasSelection = typeof value === "string" && + !isNaN(selectionStart) && + !isNaN(selectionEnd) && + selectionStart !== selectionEnd; + + return hasSelection ? element : null; + } + catch (err) { + return null; + } + +} + +/** + * Adds the Selection Listener to the content's window given + */ +function addSelectionListener(window) { + let selection = window.getSelection(); + + // Don't add the selection's listener more than once to the same window, + // if the selection object is the same + if ("selection" in selections(window) && selections(window).selection === selection) + return; + + // We ensure that the current selection is an instance of + // `nsISelectionPrivate` before working on it, in case is `null`. + // + // If it's `null` it's likely too early to add the listener, and we demand + // that operation to `document-shown` - it can easily happens for frames + if (selection instanceof Ci.nsISelectionPrivate) + selection.addSelectionListener(selectionListener); + + // nsISelectionListener implementation seems not fire a notification if + // a selection is in a text field, therefore we need to add a listener to + // window.onselect, that is fired only for text fields. + // For consistency, we add it only when the nsISelectionListener is added. + // + // https://developer.mozilla.org/en/DOM/window.onselect + window.addEventListener("select", selectionListener.onSelect, true); + + selections(window).selection = selection; +}; + +/** + * Removes the Selection Listener to the content's window given + */ +function removeSelectionListener(window) { + // Don't remove the selection's listener to a window that wasn't handled. + if (!("selection" in selections(window))) + return; + + let selection = window.getSelection(); + let isSameSelection = selection === selections(window).selection; + + // Before remove the listener, we ensure that the current selection is an + // instance of `nsISelectionPrivate` (it could be `null`), and that is still + // the selection we managed for this window (it could be detached). + if (selection instanceof Ci.nsISelectionPrivate && isSameSelection) + selection.removeSelectionListener(selectionListener); + + window.removeEventListener("select", selectionListener.onSelect, true); + + delete selections(window).selection; +}; + +function onContent(event) { + let window = event.subject.defaultView; + + // We are not interested in documents without valid defaultView (e.g. XML) + // that aren't in a tab (e.g. Panel); or in private windows + if (window && getTabForContentWindow(window) && !ignoreWindow(window)) { + addSelectionListener(window); + } +} + +// Adds Selection listener to new documents +// Note that strong reference is needed for documents that are loading slowly or +// where the server didn't close the connection (e.g. "comet"). +events.on("document-element-inserted", onContent, true); + +// Adds Selection listeners to existing documents +getAllTabContentWindows().forEach(addSelectionListener); + +// When a document is not visible anymore the selection object is detached, and +// a new selection object is created when it becomes visible again. +// That makes the previous selection's listeners added previously totally +// useless – the listeners are not notified anymore. +// To fix that we're listening for `document-shown` event in order to add +// the listeners to the new selection object created. +// +// See bug 665386 for further details. + +function onShown(event) { + let window = event.subject.defaultView; + + // We are not interested in documents without valid defaultView. + // For example XML documents don't have windows and we don't yet support them. + if (!window) + return; + + // We want to handle only the windows where we added selection's listeners + if ("selection" in selections(window)) { + let currentSelection = window.getSelection(); + let { selection } = selections(window); + + // If the current selection for the window given is different from the one + // stored in the namespace, we need to add the listeners again, and replace + // the previous selection in our list with the new one. + // + // Notice that we don't have to remove the listeners from the old selection, + // because is detached. An attempt to remove the listener, will raise an + // error (see http://mxr.mozilla.org/mozilla-central/source/layout/generic/nsSelection.cpp#5343 ) + // + // We ensure that the current selection is an instance of + // `nsISelectionPrivate` before working on it, in case is `null`. + if (currentSelection instanceof Ci.nsISelectionPrivate && + currentSelection !== selection) { + + window.addEventListener("select", selectionListener.onSelect, true); + currentSelection.addSelectionListener(selectionListener); + selections(window).selection = currentSelection; + } + } +} + +events.on("document-shown", onShown, true); + +// Removes Selection listeners when the add-on is unloaded +unload(function(){ + getAllTabContentWindows().forEach(removeSelectionListener); + + events.off("document-element-inserted", onContent); + events.off("document-shown", onShown); + + off(exports); +}); + +const selection = Class({ + extends: EventTarget, + implements: [ Selection, selectionIterator ] +})(); + +module.exports = selection; diff --git a/addon-sdk/source/lib/sdk/self.js b/addon-sdk/source/lib/sdk/self.js new file mode 100644 index 000000000..c2114a926 --- /dev/null +++ b/addon-sdk/source/lib/sdk/self.js @@ -0,0 +1,61 @@ +/* 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"; + +module.metadata = { + "stability": "stable" +}; + +const { CC } = require('chrome'); +const options = require('@loader/options'); + +const { get } = require("./preferences/service"); +const { readURISync } = require('./net/url'); + +const id = options.id; + +const readPref = key => get("extensions." + id + ".sdk." + key); + +const name = readPref("name") || options.name; +const version = readPref("version") || options.version; +const loadReason = readPref("load.reason") || options.loadReason; +const rootURI = readPref("rootURI") || options.rootURI || ""; +const baseURI = readPref("baseURI") || options.prefixURI + name + "/" +const addonDataURI = baseURI + "data/"; +const metadata = options.metadata || {}; +const permissions = metadata.permissions || {}; +const isPacked = rootURI && rootURI.indexOf("jar:") === 0; + +const isPrivateBrowsingSupported = 'private-browsing' in permissions && + permissions['private-browsing'] === true; + +const uri = (path="") => + path.includes(":") ? path : addonDataURI + path.replace(/^\.\//, ""); + +var preferencesBranch = ("preferences-branch" in metadata) + ? metadata["preferences-branch"] + : options.preferencesBranch + +if (/[^\w{@}.-]/.test(preferencesBranch)) { + preferencesBranch = id; + console.warn("Ignoring preferences-branch (not a valid branch name)"); +} + +// Some XPCOM APIs require valid URIs as an argument for certain operations +// (see `nsILoginManager` for example). This property represents add-on +// associated unique URI string that can be used for that. +exports.uri = 'addon:' + id; +exports.id = id; +exports.preferencesBranch = preferencesBranch || id; +exports.name = name; +exports.loadReason = loadReason; +exports.version = version; +exports.packed = isPacked; +exports.data = Object.freeze({ + url: uri, + load: function read(path) { + return readURISync(uri(path)); + } +}); +exports.isPrivateBrowsingSupported = isPrivateBrowsingSupported; diff --git a/addon-sdk/source/lib/sdk/simple-prefs.js b/addon-sdk/source/lib/sdk/simple-prefs.js new file mode 100644 index 000000000..3472f4418 --- /dev/null +++ b/addon-sdk/source/lib/sdk/simple-prefs.js @@ -0,0 +1,26 @@ +/* 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'; + +module.metadata = { + "stability": "experimental" +}; + +const { emit, off } = require("./event/core"); +const { PrefsTarget } = require("./preferences/event-target"); +const { preferencesBranch, id } = require("./self"); +const { on } = require("./system/events"); + +const ADDON_BRANCH = "extensions." + preferencesBranch + "."; +const BUTTON_PRESSED = id + "-cmdPressed"; + +const target = PrefsTarget({ branchName: ADDON_BRANCH }); + +// Listen to clicks on buttons +function buttonClick({ data }) { + emit(target, data); +} +on(BUTTON_PRESSED, buttonClick); + +module.exports = target; diff --git a/addon-sdk/source/lib/sdk/simple-storage.js b/addon-sdk/source/lib/sdk/simple-storage.js new file mode 100644 index 000000000..bcf9b1cb9 --- /dev/null +++ b/addon-sdk/source/lib/sdk/simple-storage.js @@ -0,0 +1,235 @@ +/* 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"; + +module.metadata = { + "stability": "stable" +}; + +const { Cc, Ci } = require("chrome"); +const file = require("./io/file"); +const prefs = require("./preferences/service"); +const jpSelf = require("./self"); +const timer = require("./timers"); +const unload = require("./system/unload"); +const { emit, on, off } = require("./event/core"); + +const WRITE_PERIOD_PREF = "extensions.addon-sdk.simple-storage.writePeriod"; +const WRITE_PERIOD_DEFAULT = 300000; // 5 minutes + +const QUOTA_PREF = "extensions.addon-sdk.simple-storage.quota"; +const QUOTA_DEFAULT = 5242880; // 5 MiB + +const JETPACK_DIR_BASENAME = "jetpack"; + +Object.defineProperties(exports, { + storage: { + enumerable: true, + get: function() { return manager.root; }, + set: function(value) { manager.root = value; } + }, + quotaUsage: { + get: function() { return manager.quotaUsage; } + } +}); + +// A generic JSON store backed by a file on disk. This should be isolated +// enough to move to its own module if need be... +function JsonStore(options) { + this.filename = options.filename; + this.quota = options.quota; + this.writePeriod = options.writePeriod; + this.onOverQuota = options.onOverQuota; + this.onWrite = options.onWrite; + + unload.ensure(this); + + this.writeTimer = timer.setInterval(this.write.bind(this), + this.writePeriod); +} + +JsonStore.prototype = { + // The store's root. + get root() { + return this.isRootInited ? this._root : {}; + }, + + // Performs some type checking. + set root(val) { + let types = ["array", "boolean", "null", "number", "object", "string"]; + if (types.indexOf(typeof(val)) < 0) { + throw new Error("storage must be one of the following types: " + + types.join(", ")); + } + this._root = val; + return val; + }, + + // True if the root has ever been set (either via the root setter or by the + // backing file's having been read). + get isRootInited() { + return this._root !== undefined; + }, + + // Percentage of quota used, as a number [0, Inf). > 1 implies over quota. + // Undefined if there is no quota. + get quotaUsage() { + return this.quota > 0 ? + JSON.stringify(this.root).length / this.quota : + undefined; + }, + + // Removes the backing file and all empty subdirectories. + purge: function JsonStore_purge() { + try { + // This'll throw if the file doesn't exist. + file.remove(this.filename); + let parentPath = this.filename; + do { + parentPath = file.dirname(parentPath); + // This'll throw if the dir isn't empty. + file.rmdir(parentPath); + } while (file.basename(parentPath) !== JETPACK_DIR_BASENAME); + } + catch (err) {} + }, + + // Initializes the root by reading the backing file. + read: function JsonStore_read() { + try { + let str = file.read(this.filename); + + // Ideally we'd log the parse error with console.error(), but logged + // errors cause tests to fail. Supporting "known" errors in the test + // harness appears to be non-trivial. Maybe later. + this.root = JSON.parse(str); + } + catch (err) { + this.root = {}; + } + }, + + // If the store is under quota, writes the root to the backing file. + // Otherwise quota observers are notified and nothing is written. + write: function JsonStore_write() { + if (this.quotaUsage > 1) + this.onOverQuota(this); + else + this._write(); + }, + + // Cleans up on unload. If unloading because of uninstall, the store is + // purged; otherwise it's written. + unload: function JsonStore_unload(reason) { + timer.clearInterval(this.writeTimer); + this.writeTimer = null; + + if (reason === "uninstall") + this.purge(); + else + this._write(); + }, + + // True if the root is an empty object. + get _isEmpty() { + if (this.root && typeof(this.root) === "object") { + let empty = true; + for (let key in this.root) { + empty = false; + break; + } + return empty; + } + return false; + }, + + // Writes the root to the backing file, notifying write observers when + // complete. If the store is over quota or if it's empty and the store has + // never been written, nothing is written and write observers aren't notified. + _write: function JsonStore__write() { + // Don't write if the root is uninitialized or if the store is empty and the + // backing file doesn't yet exist. + if (!this.isRootInited || (this._isEmpty && !file.exists(this.filename))) + return; + + // If the store is over quota, don't write. The current under-quota state + // should persist. + if (this.quotaUsage > 1) + return; + + // Finally, write. + let stream = file.open(this.filename, "w"); + try { + stream.writeAsync(JSON.stringify(this.root), function writeAsync(err) { + if (err) + console.error("Error writing simple storage file: " + this.filename); + else if (this.onWrite) + this.onWrite(this); + }.bind(this)); + } + catch (err) { + // writeAsync closes the stream after it's done, so only close on error. + stream.close(); + } + } +}; + + +// This manages a JsonStore singleton and tailors its use to simple storage. +// The root of the JsonStore is lazy-loaded: The backing file is only read the +// first time the root's gotten. +var manager = ({ + jsonStore: null, + + // The filename of the store, based on the profile dir and extension ID. + get filename() { + let storeFile = Cc["@mozilla.org/file/directory_service;1"]. + getService(Ci.nsIProperties). + get("ProfD", Ci.nsIFile); + storeFile.append(JETPACK_DIR_BASENAME); + storeFile.append(jpSelf.id); + storeFile.append("simple-storage"); + file.mkpath(storeFile.path); + storeFile.append("store.json"); + return storeFile.path; + }, + + get quotaUsage() { + return this.jsonStore.quotaUsage; + }, + + get root() { + if (!this.jsonStore.isRootInited) + this.jsonStore.read(); + return this.jsonStore.root; + }, + + set root(val) { + return this.jsonStore.root = val; + }, + + unload: function manager_unload() { + off(this); + }, + + new: function manager_constructor() { + let manager = Object.create(this); + unload.ensure(manager); + + manager.jsonStore = new JsonStore({ + filename: manager.filename, + writePeriod: prefs.get(WRITE_PERIOD_PREF, WRITE_PERIOD_DEFAULT), + quota: prefs.get(QUOTA_PREF, QUOTA_DEFAULT), + onOverQuota: emit.bind(null, exports, "OverQuota") + }); + + return manager; + } +}).new(); + +exports.on = on.bind(null, exports); +exports.removeListener = function(type, listener) { + off(exports, type, listener); +}; diff --git a/addon-sdk/source/lib/sdk/stylesheet/style.js b/addon-sdk/source/lib/sdk/stylesheet/style.js new file mode 100644 index 000000000..7ec0787e1 --- /dev/null +++ b/addon-sdk/source/lib/sdk/stylesheet/style.js @@ -0,0 +1,71 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Cc, Ci } = require("chrome"); +const { Class } = require("../core/heritage"); +const { URL, isLocalURL } = require('../url'); +const events = require("../system/events"); +const { loadSheet, removeSheet, isTypeValid } = require("./utils"); +const { isString } = require("../lang/type"); +const { attachTo, detachFrom } = require("../content/mod"); +const { data } = require('../self'); + +const { freeze, create } = Object; + +function Style({ source, uri, type }) { + source = source == null ? null : freeze([].concat(source)); + uri = uri == null ? null : freeze([].concat(uri)); + type = type == null ? "author" : type; + + if (source && !source.every(isString)) + throw new Error('Style.source must be a string or an array of strings.'); + + if (uri && !uri.every(isLocalURL)) + throw new Error('Style.uri must be a local URL or an array of local URLs'); + + if (type && !isTypeValid(type)) + throw new Error('Style.type must be "agent", "user" or "author"'); + + return freeze(create(Style.prototype, { + "source": { value: source, enumerable: true }, + "uri": { value: uri, enumerable: true }, + "type": { value: type, enumerable: true } + })); +}; + +exports.Style = Style; + +attachTo.define(Style, function (style, window) { + if (style.uri) { + for (let uri of style.uri) + loadSheet(window, data.url(uri), style.type); + } + + if (style.source) { + let uri = "data:text/css;charset=utf-8,"; + + uri += encodeURIComponent(style.source.join("")); + + loadSheet(window, uri, style.type); + } +}); + +detachFrom.define(Style, function (style, window) { + if (style.uri) + for (let uri of style.uri) + removeSheet(window, data.url(uri)); + + if (style.source) { + let uri = "data:text/css;charset=utf-8,"; + + uri += encodeURIComponent(style.source.join("")); + + removeSheet(window, uri, style.type); + } +}); diff --git a/addon-sdk/source/lib/sdk/stylesheet/utils.js b/addon-sdk/source/lib/sdk/stylesheet/utils.js new file mode 100644 index 000000000..844996bf3 --- /dev/null +++ b/addon-sdk/source/lib/sdk/stylesheet/utils.js @@ -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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Ci } = require("chrome"); + +const SHEET_TYPE = { + "agent": "AGENT_SHEET", + "user": "USER_SHEET", + "author": "AUTHOR_SHEET" +}; + +function getDOMWindowUtils(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindowUtils); +}; + +/** + * Synchronously loads a style sheet from `uri` and adds it to the list of + * additional style sheets of the document. + * The sheets added takes effect immediately, and only on the document of the + * `window` given. + */ +function loadSheet(window, url, type) { + if (!(type && type in SHEET_TYPE)) + type = "author"; + + type = SHEET_TYPE[type]; + + if (url instanceof Ci.nsIURI) + url = url.spec; + + let winUtils = getDOMWindowUtils(window); + try { + winUtils.loadSheetUsingURIString(url, winUtils[type]); + } + catch (e) {}; +}; +exports.loadSheet = loadSheet; + +/** + * Remove the document style sheet at `sheetURI` from the list of additional + * style sheets of the document. The removal takes effect immediately. + */ +function removeSheet(window, url, type) { + if (!(type && type in SHEET_TYPE)) + type = "author"; + + type = SHEET_TYPE[type]; + + if (url instanceof Ci.nsIURI) + url = url.spec; + + let winUtils = getDOMWindowUtils(window); + + try { + winUtils.removeSheetUsingURIString(url, winUtils[type]); + } + catch (e) {}; +}; +exports.removeSheet = removeSheet; + +/** + * Returns `true` if the `type` given is valid, otherwise `false`. + * The values currently accepted are: "agent", "user" and "author". + */ +function isTypeValid(type) { + return type in SHEET_TYPE; +} +exports.isTypeValid = isTypeValid; diff --git a/addon-sdk/source/lib/sdk/system.js b/addon-sdk/source/lib/sdk/system.js new file mode 100644 index 000000000..1acfe8c8c --- /dev/null +++ b/addon-sdk/source/lib/sdk/system.js @@ -0,0 +1,172 @@ +/* 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'; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci, CC } = require('chrome'); +const options = require('@loader/options'); +const runtime = require("./system/runtime"); +const { when: unload } = require("./system/unload"); + +const appStartup = Cc['@mozilla.org/toolkit/app-startup;1']. + getService(Ci.nsIAppStartup); +const appInfo = Cc["@mozilla.org/xre/app-info;1"]. + getService(Ci.nsIXULAppInfo); +const directoryService = Cc['@mozilla.org/file/directory_service;1']. + getService(Ci.nsIProperties); + +const PR_WRONLY = parseInt("0x02"); +const PR_CREATE_FILE = parseInt("0x08"); +const PR_APPEND = parseInt("0x10"); +const PR_TRUNCATE = parseInt("0x20"); + +function openFile(path, mode) { + let file = Cc["@mozilla.org/file/local;1"]. + createInstance(Ci.nsILocalFile); + file.initWithPath(path); + let stream = Cc["@mozilla.org/network/file-output-stream;1"]. + createInstance(Ci.nsIFileOutputStream); + stream.init(file, mode, -1, 0); + return stream +} + +const { eAttemptQuit: E_ATTEMPT, eForceQuit: E_FORCE } = appStartup; + +/** + * Parsed JSON object that was passed via `cfx --static-args "{ foo: 'bar' }"` + */ +exports.staticArgs = options.staticArgs; + +/** + * Environment variables. Environment variables are non-enumerable properties + * of this object (key is name and value is value). + */ +exports.env = require('./system/environment').env; + +/** + * Ends the process with the specified `code`. If omitted, exit uses the + * 'success' code 0. To exit with failure use `1`. + * TODO: Improve platform to actually quit with an exit code. + */ +var forcedExit = false; +exports.exit = function exit(code) { + if (forcedExit) { + // a forced exit was already tried + // NOTE: exit(0) is called twice sometimes (ex when using cfx testaddons) + return; + } + + let resultsFile = 'resultFile' in options && options.resultFile; + function unloader() { + if (!options.resultFile) { + return; + } + + // This is used by 'cfx' to find out exit code. + let mode = PR_WRONLY | PR_CREATE_FILE | PR_TRUNCATE; + let stream = openFile(options.resultFile, mode); + let status = code ? 'FAIL' : 'OK'; + stream.write(status, status.length); + stream.flush(); + stream.close(); + return; + } + + if (code == 0) { + forcedExit = true; + } + + // Bug 856999: Prevent automatic kill of Firefox when running tests + if (options.noQuit) { + return unload(unloader); + } + + unloader(); + appStartup.quit(code ? E_ATTEMPT : E_FORCE); +}; + +// Adapter for nodejs's stdout & stderr: +// http://nodejs.org/api/process.html#process_process_stdout +var stdout = Object.freeze({ write: dump, end: dump }); +exports.stdout = stdout; +exports.stderr = stdout; + +/** + * Returns a path of the system's or application's special directory / file + * associated with a given `id`. For list of possible `id`s please see: + * https://developer.mozilla.org/en-US/docs/Code_snippets/File_I_O#Getting_files_in_special_directories + * http://dxr.mozilla.org/mozilla-central/source/xpcom/io/nsAppDirectoryServiceDefs.h + * @example + * + * // get firefox profile path + * let profilePath = require('system').pathFor('ProfD'); + * // get OS temp files directory (/tmp) + * let temps = require('system').pathFor('TmpD'); + * // get OS desktop path for an active user (~/Desktop on linux + * // or C:\Documents and Settings\username\Desktop on windows). + * let desktopPath = require('system').pathFor('Desk'); + */ +exports.pathFor = function pathFor(id) { + return directoryService.get(id, Ci.nsIFile).path; +}; + +/** + * What platform you're running on (all lower case string). + * For possible values see: + * https://developer.mozilla.org/en/OS_TARGET + */ +exports.platform = runtime.OS.toLowerCase(); + +const [, architecture, compiler] = runtime.XPCOMABI ? + runtime.XPCOMABI.match(/^([^-]*)-(.*)$/) : + [, null, null]; + +/** + * What processor architecture you're running on: + * `'arm', 'ia32', or 'x64'`. + */ +exports.architecture = architecture; + +/** + * What compiler used for build: + * `'msvc', 'n32', 'gcc2', 'gcc3', 'sunc', 'ibmc'...` + */ +exports.compiler = compiler; + +/** + * The application's build ID/date, for example "2004051604". + */ +exports.build = appInfo.appBuildID; + +/** + * The XUL application's UUID. + * This has traditionally been in the form + * `{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}` but for some applications it may + * be: "appname@vendor.tld". + */ +exports.id = appInfo.ID; + +/** + * The name of the application. + */ +exports.name = appInfo.name; + +/** + * The XUL application's version, for example "0.8.0+" or "3.7a1pre". + */ +exports.version = appInfo.version; + +/** + * XULRunner version. + */ +exports.platformVersion = appInfo.platformVersion; + + +/** + * The name of the application vendor, for example "Mozilla". + */ +exports.vendor = appInfo.vendor; diff --git a/addon-sdk/source/lib/sdk/system/child_process.js b/addon-sdk/source/lib/sdk/system/child_process.js new file mode 100644 index 000000000..8ea1f4f80 --- /dev/null +++ b/addon-sdk/source/lib/sdk/system/child_process.js @@ -0,0 +1,332 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental' +}; + +var { Ci } = require('chrome'); +var subprocess = require('./child_process/subprocess'); +var { EventTarget } = require('../event/target'); +var { Stream } = require('../io/stream'); +var { on, emit, off } = require('../event/core'); +var { Class } = require('../core/heritage'); +var { platform } = require('../system'); +var { isFunction, isArray } = require('../lang/type'); +var { delay } = require('../lang/functional'); +var { merge } = require('../util/object'); +var { setTimeout, clearTimeout } = require('../timers'); +var isWindows = platform.indexOf('win') === 0; + +var processes = new WeakMap(); + + +/** + * The `Child` class wraps a subprocess command, exposes + * the stdio streams, and methods to manipulate the subprocess + */ +var Child = Class({ + implements: [EventTarget], + initialize: function initialize (options) { + let child = this; + let proc; + + this.killed = false; + this.exitCode = undefined; + this.signalCode = undefined; + + this.stdin = Stream(); + this.stdout = Stream(); + this.stderr = Stream(); + + try { + proc = subprocess.call({ + command: options.file, + arguments: options.cmdArgs, + environment: serializeEnv(options.env), + workdir: options.cwd, + charset: options.encoding, + stdout: data => emit(child.stdout, 'data', data), + stderr: data => emit(child.stderr, 'data', data), + stdin: stream => { + child.stdin.on('data', pumpStdin); + child.stdin.on('end', function closeStdin () { + child.stdin.off('data', pumpStdin); + child.stdin.off('end', closeStdin); + stream.close(); + }); + function pumpStdin (data) { + stream.write(data); + } + }, + done: function (result, error) { + if (error) + return handleError(error); + + // Only emit if child is not killed; otherwise, + // the `kill` method will handle this + if (!child.killed) { + child.exitCode = result.exitCode; + child.signalCode = null; + + // If process exits with < 0, there was an error + if (child.exitCode < 0) { + handleError(new Error('Process exited with exit code ' + child.exitCode)); + } + else { + // Also do 'exit' event as there's not much of + // a difference in our implementation as we're not using + // node streams + emit(child, 'exit', child.exitCode, child.signalCode); + } + + // Emit 'close' event with exit code and signal, + // which is `null`, as it was not a killed process + emit(child, 'close', child.exitCode, child.signalCode); + } + } + }); + processes.set(child, proc); + } catch (e) { + // Delay the error handling so an error handler can be set + // during the same tick that the Child was created + delay(() => handleError(e)); + } + + // `handleError` is called when process could not even + // be spawned + function handleError (e) { + // If error is an nsIObject, make a fresh error object + // so we're not exposing nsIObjects, and we can modify it + // with additional process information, like node + let error = e; + if (e instanceof Ci.nsISupports) { + error = new Error(e.message, e.filename, e.lineNumber); + } + emit(child, 'error', error); + child.exitCode = -1; + child.signalCode = null; + emit(child, 'close', child.exitCode, child.signalCode); + } + }, + kill: function kill (signal) { + let proc = processes.get(this); + proc.kill(signal); + this.killed = true; + this.exitCode = null; + this.signalCode = signal; + emit(this, 'exit', this.exitCode, this.signalCode); + emit(this, 'close', this.exitCode, this.signalCode); + }, + get pid() { return processes.get(this, {}).pid || -1; } +}); + +function spawn (file, ...args) { + let cmdArgs = []; + // Default options + let options = { + cwd: null, + env: null, + encoding: 'UTF-8' + }; + + if (args[1]) { + merge(options, args[1]); + cmdArgs = args[0]; + } + else { + if (isArray(args[0])) + cmdArgs = args[0]; + else + merge(options, args[0]); + } + + if ('gid' in options) + console.warn('`gid` option is not yet supported for `child_process`'); + if ('uid' in options) + console.warn('`uid` option is not yet supported for `child_process`'); + if ('detached' in options) + console.warn('`detached` option is not yet supported for `child_process`'); + + options.file = file; + options.cmdArgs = cmdArgs; + + return Child(options); +} + +exports.spawn = spawn; + +/** + * exec(command, options, callback) + */ +function exec (cmd, ...args) { + let file, cmdArgs, callback, options = {}; + + if (isFunction(args[0])) + callback = args[0]; + else { + merge(options, args[0]); + callback = args[1]; + } + + if (isWindows) { + file = 'C:\\Windows\\System32\\cmd.exe'; + cmdArgs = ['/S/C', cmd || '']; + } + else { + file = '/bin/sh'; + cmdArgs = ['-c', cmd]; + } + + // Undocumented option from node being able to specify shell + if (options && options.shell) + file = options.shell; + + return execFile(file, cmdArgs, options, callback); +} +exports.exec = exec; +/** + * execFile (file, args, options, callback) + */ +function execFile (file, ...args) { + let cmdArgs = [], callback; + // Default options + let options = { + cwd: null, + env: null, + encoding: 'utf8', + timeout: 0, + maxBuffer: 204800, //200 KB (200*1024 bytes) + killSignal: 'SIGTERM' + }; + + if (isFunction(args[args.length - 1])) + callback = args[args.length - 1]; + + if (isArray(args[0])) { + cmdArgs = args[0]; + merge(options, args[1]); + } else if (!isFunction(args[0])) + merge(options, args[0]); + + let child = spawn(file, cmdArgs, options); + let exited = false; + let stdout = ''; + let stderr = ''; + let error = null; + let timeoutId = null; + + child.stdout.setEncoding(options.encoding); + child.stderr.setEncoding(options.encoding); + + on(child.stdout, 'data', pumpStdout); + on(child.stderr, 'data', pumpStderr); + on(child, 'close', exitHandler); + on(child, 'error', errorHandler); + + if (options.timeout > 0) { + setTimeout(() => { + kill(); + timeoutId = null; + }, options.timeout); + } + + function exitHandler (code, signal) { + + // Return if exitHandler called previously, occurs + // when multiple maxBuffer errors thrown and attempt to kill multiple + // times + if (exited) return; + exited = true; + + if (!isFunction(callback)) return; + + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + + if (!error && (code !== 0 || signal !== null)) + error = createProcessError(new Error('Command failed: ' + stderr), { + code: code, + signal: signal, + killed: !!child.killed + }); + + callback(error, stdout, stderr); + + off(child.stdout, 'data', pumpStdout); + off(child.stderr, 'data', pumpStderr); + off(child, 'close', exitHandler); + off(child, 'error', errorHandler); + } + + function errorHandler (e) { + error = e; + exitHandler(); + } + + function kill () { + try { + child.kill(options.killSignal); + } catch (e) { + // In the scenario where the kill signal happens when + // the process is already closing, just abort the kill fail + if (/library is not open/.test(e)) + return; + error = e; + exitHandler(-1, options.killSignal); + } + } + + function pumpStdout (data) { + stdout += data; + if (stdout.length > options.maxBuffer) { + error = new Error('stdout maxBuffer exceeded'); + kill(); + } + } + + function pumpStderr (data) { + stderr += data; + if (stderr.length > options.maxBuffer) { + error = new Error('stderr maxBuffer exceeded'); + kill(); + } + } + + return child; +} +exports.execFile = execFile; + +exports.fork = function fork () { + throw new Error("child_process#fork is not currently supported"); +}; + +function serializeEnv (obj) { + return Object.keys(obj || {}).map(prop => prop + '=' + obj[prop]); +} + +function createProcessError (err, options = {}) { + // If code and signal look OK, this was probably a failure + // attempting to spawn the process (like ENOENT in node) -- use + // the code from the error message + if (!options.code && !options.signal) { + let match = err.message.match(/(NS_ERROR_\w*)/); + if (match && match.length > 1) + err.code = match[1]; + else { + // If no good error message found, use the passed in exit code; + // this occurs when killing a process that's already closing, + // where we want both a valid exit code (0) and the error + err.code = options.code != null ? options.code : null; + } + } + else + err.code = options.code != null ? options.code : null; + err.signal = options.signal || null; + err.killed = options.killed || false; + return err; +} diff --git a/addon-sdk/source/lib/sdk/system/child_process/subprocess.js b/addon-sdk/source/lib/sdk/system/child_process/subprocess.js new file mode 100644 index 000000000..e3454e95b --- /dev/null +++ b/addon-sdk/source/lib/sdk/system/child_process/subprocess.js @@ -0,0 +1,186 @@ +/* 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 { Ci, Cu } = require("chrome"); + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Subprocess.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +const Runtime = require("sdk/system/runtime"); +const Environment = require("sdk/system/environment").env; +const DEFAULT_ENVIRONMENT = []; +if (Runtime.OS == "Linux" && "DISPLAY" in Environment) { + DEFAULT_ENVIRONMENT.push("DISPLAY=" + Environment.DISPLAY); +} + +function awaitPromise(promise) { + let value; + let resolved = null; + promise.then(val => { + resolved = true; + value = val; + }, val => { + resolved = false; + value = val; + }); + + while (resolved === null) + Services.tm.mainThread.processNextEvent(true); + + if (resolved === true) + return value; + throw value; +} + +let readAllData = Task.async(function* (pipe, read, callback) { + let string; + while (string = yield read(pipe)) + callback(string); +}); + +let write = (pipe, data) => { + let buffer = new Uint8Array(Array.from(data, c => c.charCodeAt(0))); + return pipe.write(data); +}; + +var subprocess = { + call: function(options) { + var result; + + let procPromise = Task.spawn(function*() { + let opts = {}; + + if (options.mergeStderr) { + opts.stderr = "stdout" + } else if (options.stderr) { + opts.stderr = "pipe"; + } + + if (options.command instanceof Ci.nsIFile) { + opts.command = options.command.path; + } else { + opts.command = yield Subprocess.pathSearch(options.command); + } + + if (options.workdir) { + opts.workdir = options.workdir; + } + + opts.arguments = options.arguments || []; + + + // Set up environment + + let envVars = options.environment || DEFAULT_ENVIRONMENT; + if (envVars.length) { + let environment = {}; + for (let val of envVars) { + let idx = val.indexOf("="); + if (idx >= 0) + environment[val.slice(0, idx)] = val.slice(idx + 1); + } + + opts.environment = environment; + } + + + let proc = yield Subprocess.call(opts); + + Object.defineProperty(result, "pid", { + value: proc.pid, + enumerable: true, + configurable: true, + }); + + + let promises = []; + + // Set up IO handlers. + + let read = pipe => pipe.readString(); + if (options.charset === null) { + read = pipe => { + return pipe.read().then(buffer => { + return String.fromCharCode(...buffer); + }); + }; + } + + if (options.stdout) + promises.push(readAllData(proc.stdout, read, options.stdout)); + + if (options.stderr && proc.stderr) + promises.push(readAllData(proc.stderr, read, options.stderr)); + + // Process stdin + + if (typeof options.stdin === "string") { + write(proc.stdin, options.stdin); + proc.stdin.close(); + } + + + // Handle process completion + + if (options.done) + Promise.all(promises) + .then(() => proc.wait()) + .then(options.done); + + return proc; + }); + + procPromise.catch(e => { + if (options.done) + options.done({exitCode: -1}, e); + else + Cu.reportError(e instanceof Error ? e : e.message || e); + }); + + if (typeof options.stdin === "function") { + // Unfortunately, some callers (child_process.js) depend on this + // being called synchronously. + options.stdin({ + write(val) { + procPromise.then(proc => { + write(proc.stdin, val); + }); + }, + + close() { + procPromise.then(proc => { + proc.stdin.close(); + }); + }, + }); + } + + result = { + get pid() { + return awaitPromise(procPromise.then(proc => { + return proc.pid; + })); + }, + + wait() { + return awaitPromise(procPromise.then(proc => { + return proc.wait().then(({exitCode}) => exitCode); + })); + }, + + kill(hard = false) { + procPromise.then(proc => { + proc.kill(hard ? 0 : undefined); + }); + }, + }; + + return result; + }, +}; + +module.exports = subprocess; diff --git a/addon-sdk/source/lib/sdk/system/environment.js b/addon-sdk/source/lib/sdk/system/environment.js new file mode 100644 index 000000000..13621a696 --- /dev/null +++ b/addon-sdk/source/lib/sdk/system/environment.js @@ -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'; + +module.metadata = { + "stability": "stable" +}; + +const { Cc, Ci } = require('chrome'); +const { get, set, exists } = Cc['@mozilla.org/process/environment;1']. + getService(Ci.nsIEnvironment); + +exports.env = new Proxy({}, { + deleteProperty(target, property) { + set(property, null); + return true; + }, + + get(target, property, receiver) { + return get(property) || undefined; + }, + + has(target, property) { + return exists(property); + }, + + set(target, property, value, receiver) { + set(property, value); + return true; + } +}); diff --git a/addon-sdk/source/lib/sdk/system/events-shimmed.js b/addon-sdk/source/lib/sdk/system/events-shimmed.js new file mode 100644 index 000000000..14496f1f0 --- /dev/null +++ b/addon-sdk/source/lib/sdk/system/events-shimmed.js @@ -0,0 +1,16 @@ +/* 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'; + +module.metadata = { + 'stability': 'unstable' +}; + +const events = require('./events.js'); + +exports.emit = (type, event) => events.emit(type, event, true); +exports.on = (type, listener, strong) => events.on(type, listener, strong, true); +exports.once = (type, listener) => events.once(type, listener, true); +exports.off = (type, listener) => events.off(type, listener, true); diff --git a/addon-sdk/source/lib/sdk/system/events.js b/addon-sdk/source/lib/sdk/system/events.js new file mode 100644 index 000000000..0cf525aa1 --- /dev/null +++ b/addon-sdk/source/lib/sdk/system/events.js @@ -0,0 +1,181 @@ +/* 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'; + +module.metadata = { + 'stability': 'unstable' +}; + +const { Cc, Ci, Cu } = require('chrome'); +const { Unknown } = require('../platform/xpcom'); +const { Class } = require('../core/heritage'); +const { ns } = require('../core/namespace'); +const observerService = + Cc['@mozilla.org/observer-service;1'].getService(Ci.nsIObserverService); +const { addObserver, removeObserver, notifyObservers } = observerService; +const { ShimWaiver } = Cu.import("resource://gre/modules/ShimWaiver.jsm"); +const addObserverNoShim = ShimWaiver.getProperty(observerService, "addObserver"); +const removeObserverNoShim = ShimWaiver.getProperty(observerService, "removeObserver"); +const notifyObserversNoShim = ShimWaiver.getProperty(observerService, "notifyObservers"); +const unloadSubject = require('@loader/unload'); + +const Subject = Class({ + extends: Unknown, + initialize: function initialize(object) { + // Double-wrap the object and set a property identifying the + // wrappedJSObject as one of our wrappers to distinguish between + // subjects that are one of our wrappers (which we should unwrap + // when notifying our observers) and those that are real JS XPCOM + // components (which we should pass through unaltered). + this.wrappedJSObject = { + observersModuleSubjectWrapper: true, + object: object + }; + }, + getScriptableHelper: function() {}, + getInterfaces: function() {} +}); + +function emit(type, event, shimmed = false) { + // From bug 910599 + // We must test to see if 'subject' or 'data' is a defined property + // of the event object, but also allow primitives to be passed in, + // which the `in` operator breaks, yet `null` is an object, hence + // the long conditional + let subject = event && typeof event === 'object' && 'subject' in event ? + Subject(event.subject) : + null; + let data = event && typeof event === 'object' ? + // An object either returns its `data` property or null + ('data' in event ? event.data : null) : + // All other types return themselves (and cast to strings/null + // via observer service) + event; + if (shimmed) { + notifyObservers(subject, type, data); + } else { + notifyObserversNoShim(subject, type, data); + } +} +exports.emit = emit; + +const Observer = Class({ + extends: Unknown, + initialize: function initialize(listener) { + this.listener = listener; + }, + interfaces: [ 'nsIObserver', 'nsISupportsWeakReference' ], + observe: function(subject, topic, data) { + // Extract the wrapped object for subjects that are one of our + // wrappers around a JS object. This way we support both wrapped + // subjects created using this module and those that are real + // XPCOM components. + if (subject && typeof(subject) == 'object' && + ('wrappedJSObject' in subject) && + ('observersModuleSubjectWrapper' in subject.wrappedJSObject)) + subject = subject.wrappedJSObject.object; + + try { + this.listener({ + type: topic, + subject: subject, + data: data + }); + } + catch (error) { + console.exception(error); + } + } +}); + +const subscribers = ns(); + +function on(type, listener, strong, shimmed = false) { + // Unless last optional argument is `true` we use a weak reference to a + // listener. + let weak = !strong; + // Take list of observers associated with given `listener` function. + let observers = subscribers(listener); + // If `observer` for the given `type` is not registered yet, then + // associate an `observer` and register it. + if (!(type in observers)) { + let observer = Observer(listener); + observers[type] = observer; + if (shimmed) { + addObserver(observer, type, weak); + } else { + addObserverNoShim(observer, type, weak); + } + // WeakRef gymnastics to remove all alive observers on unload + let ref = Cu.getWeakReference(observer); + weakRefs.set(observer, ref); + stillAlive.set(ref, type); + wasShimmed.set(ref, shimmed); + } +} +exports.on = on; + +function once(type, listener, shimmed = false) { + // Note: this code assumes order in which listeners are called, which is fine + // as long as dispatch happens in same order as listener registration which + // is the case now. That being said we should be aware that this may break + // in a future if order will change. + on(type, listener, shimmed); + on(type, function cleanup() { + off(type, listener, shimmed); + off(type, cleanup, shimmed); + }, true, shimmed); +} +exports.once = once; + +function off(type, listener, shimmed = false) { + // Take list of observers as with the given `listener`. + let observers = subscribers(listener); + // If `observer` for the given `type` is registered, then + // remove it & unregister. + if (type in observers) { + let observer = observers[type]; + delete observers[type]; + if (shimmed) { + removeObserver(observer, type); + } else { + removeObserverNoShim(observer, type); + } + stillAlive.delete(weakRefs.get(observer)); + wasShimmed.delete(weakRefs.get(observer)); + } +} +exports.off = off; + +// must use WeakMap to keep reference to all the WeakRefs (!), see bug 986115 +var weakRefs = new WeakMap(); + +// and we're out of beta, we're releasing on time! +var stillAlive = new Map(); + +var wasShimmed = new Map(); + +on('sdk:loader:destroy', function onunload({ subject, data: reason }) { + // using logic from ./unload, to avoid a circular module reference + if (subject.wrappedJSObject === unloadSubject) { + off('sdk:loader:destroy', onunload, false); + + // don't bother + if (reason === 'shutdown') + return; + + stillAlive.forEach( (type, ref) => { + let observer = ref.get(); + if (observer) { + if (wasShimmed.get(ref)) { + removeObserver(observer, type); + } else { + removeObserverNoShim(observer, type); + } + } + }) + } + // a strong reference +}, true, false); diff --git a/addon-sdk/source/lib/sdk/system/globals.js b/addon-sdk/source/lib/sdk/system/globals.js new file mode 100644 index 000000000..a1a6cf9a2 --- /dev/null +++ b/addon-sdk/source/lib/sdk/system/globals.js @@ -0,0 +1,46 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +var { Cc, Ci, CC } = require('chrome'); +var { PlainTextConsole } = require('../console/plain-text'); +var { stdout } = require('../system'); +var ScriptError = CC('@mozilla.org/scripterror;1', 'nsIScriptError'); +var consoleService = Cc['@mozilla.org/consoleservice;1'].getService(Ci.nsIConsoleService); + +// On windows dump does not writes into stdout so cfx can't read thous dumps. +// To workaround this issue we write to a special file from which cfx will +// read and print to the console. +// For more details see: bug-673383 +exports.dump = stdout.write; + +exports.console = new PlainTextConsole(); + +// Provide CommonJS `define` to allow authoring modules in a format that can be +// loaded both into jetpack and into browser via AMD loaders. +Object.defineProperty(exports, 'define', { + // `define` is provided as a lazy getter that binds below defined `define` + // function to the module scope, so that require, exports and module + // variables remain accessible. + configurable: true, + get: function() { + let sandbox = this; + return function define(factory) { + factory = Array.slice(arguments).pop(); + factory.call(sandbox, sandbox.require, sandbox.exports, sandbox.module); + } + }, + set: function(value) { + Object.defineProperty(this, 'define', { + configurable: true, + enumerable: true, + value, + }); + }, +}); diff --git a/addon-sdk/source/lib/sdk/system/process.js b/addon-sdk/source/lib/sdk/system/process.js new file mode 100644 index 000000000..f44a36658 --- /dev/null +++ b/addon-sdk/source/lib/sdk/system/process.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/. */ + +"use strict"; + +module.metadata = { + "stability": "unstable" +}; + +const { + exit, version, stdout, stderr, platform, architecture +} = require("../system"); + +/** + * Supported + */ + +exports.stdout = stdout; +exports.stderr = stderr; +exports.version = version; +exports.versions = {}; +exports.config = {}; +exports.arch = architecture; +exports.platform = platform; +exports.exit = exit; + +/** + * Partial support + */ + +// An alias to `setTimeout(fn, 0)`, which isn't the same as node's `nextTick`, +// but atleast ensures it'll occur asynchronously +exports.nextTick = (callback) => setTimeout(callback, 0); + +/** + * Unsupported + */ + +exports.maxTickDepth = 1000; +exports.pid = 0; +exports.title = ""; +exports.stdin = {}; +exports.argv = []; +exports.execPath = ""; +exports.execArgv = []; +exports.abort = function () {}; +exports.chdir = function () {}; +exports.cwd = function () {}; +exports.env = {}; +exports.getgid = function () {}; +exports.setgid = function () {}; +exports.getuid = function () {}; +exports.setuid = function () {}; +exports.getgroups = function () {}; +exports.setgroups = function () {}; +exports.initgroups = function () {}; +exports.kill = function () {}; +exports.memoryUsage = function () {}; +exports.umask = function () {}; +exports.uptime = function () {}; +exports.hrtime = function () {}; diff --git a/addon-sdk/source/lib/sdk/system/runtime.js b/addon-sdk/source/lib/sdk/system/runtime.js new file mode 100644 index 000000000..9a70f142d --- /dev/null +++ b/addon-sdk/source/lib/sdk/system/runtime.js @@ -0,0 +1,28 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci } = require("chrome"); +const runtime = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime); + +exports.inSafeMode = runtime.inSafeMode; +exports.OS = runtime.OS; +exports.processType = runtime.processType; +exports.widgetToolkit = runtime.widgetToolkit; +exports.processID = runtime.processID; + +// Attempt to access `XPCOMABI` may throw exception, in which case exported +// `XPCOMABI` will be set to `null`. +// https://mxr.mozilla.org/mozilla-central/source/toolkit/xre/nsAppRunner.cpp#732 +try { + exports.XPCOMABI = runtime.XPCOMABI; +} +catch (error) { + exports.XPCOMABI = null; +} diff --git a/addon-sdk/source/lib/sdk/system/unload.js b/addon-sdk/source/lib/sdk/system/unload.js new file mode 100644 index 000000000..98ab5f8f3 --- /dev/null +++ b/addon-sdk/source/lib/sdk/system/unload.js @@ -0,0 +1,104 @@ +/* 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/. */ + +// Parts of this module were taken from narwhal: +// +// http://narwhaljs.org + +module.metadata = { + "stability": "experimental" +}; + +const { Cu } = require('chrome'); +const { on, off } = require('./events'); +const unloadSubject = require('@loader/unload'); + +const observers = []; +const unloaders = []; + +function WeakObserver(inner) { + this._inner = Cu.getWeakReference(inner); +} + +Object.defineProperty(WeakObserver.prototype, 'value', { + get: function() { this._inner.get() } +}); + +var when = exports.when = function when(observer, opts) { + opts = opts || {}; + for (var i = 0; i < observers.length; ++i) { + if (observers[i] === observer || observers[i].value === observer) { + return; + } + } + if (opts.weak) { + observers.unshift(new WeakObserver(observer)); + } else { + observers.unshift(observer); + } +}; + +var ensure = exports.ensure = function ensure(obj, destructorName) { + if (!destructorName) + destructorName = "unload"; + if (!(destructorName in obj)) + throw new Error("object has no '" + destructorName + "' property"); + + let called = false; + let originalDestructor = obj[destructorName]; + + function unloadWrapper(reason) { + if (!called) { + called = true; + let index = unloaders.indexOf(unloadWrapper); + if (index == -1) + throw new Error("internal error: unloader not found"); + unloaders.splice(index, 1); + originalDestructor.call(obj, reason); + originalDestructor = null; + destructorName = null; + obj = null; + } + }; + + // TODO: Find out why the order is inverted here. It seems that + // it may be causing issues! + unloaders.push(unloadWrapper); + + obj[destructorName] = unloadWrapper; +}; + +function unload(reason) { + observers.forEach(function(observer) { + try { + if (observer instanceof WeakObserver) { + observer = observer.value; + } + if (typeof observer === 'function') { + observer(reason); + } + } + catch (error) { + console.exception(error); + } + }); +} + +when(function(reason) { + unloaders.slice().forEach(function(unloadWrapper) { + unloadWrapper(reason); + }); +}); + +on('sdk:loader:destroy', function onunload({ subject, data: reason }) { + // If this loader is unload then `subject.wrappedJSObject` will be + // `destructor`. + if (subject.wrappedJSObject === unloadSubject) { + off('sdk:loader:destroy', onunload); + unload(reason); + } +// Note that we use strong reference to listener here to make sure it's not +// GC-ed, which may happen otherwise since nothing keeps reference to `onunolad` +// function. +}, true); diff --git a/addon-sdk/source/lib/sdk/system/xul-app.js b/addon-sdk/source/lib/sdk/system/xul-app.js new file mode 100644 index 000000000..612386f77 --- /dev/null +++ b/addon-sdk/source/lib/sdk/system/xul-app.js @@ -0,0 +1,12 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { XulApp } = require("./xul-app.jsm"); + +Object.keys(XulApp).forEach(k => exports[k] = XulApp[k]); diff --git a/addon-sdk/source/lib/sdk/system/xul-app.jsm b/addon-sdk/source/lib/sdk/system/xul-app.jsm new file mode 100644 index 000000000..90681bb1b --- /dev/null +++ b/addon-sdk/source/lib/sdk/system/xul-app.jsm @@ -0,0 +1,242 @@ +/* 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 = [ "XulApp" ]; + +var { classes: Cc, interfaces: Ci } = Components; + +var exports = {}; +this.XulApp = exports; + +var appInfo; + +// NOTE: below is required to avoid failing xpcshell tests, +// which do not implement nsIXULAppInfo +// See Bug 1114752 https://bugzilla.mozilla.org/show_bug.cgi?id=1114752 +try { + appInfo = Cc["@mozilla.org/xre/app-info;1"] + .getService(Ci.nsIXULAppInfo); +} +catch (e) { + // xpcshell test case + appInfo = {}; +} +var vc = Cc["@mozilla.org/xpcom/version-comparator;1"] + .getService(Ci.nsIVersionComparator); + +var ID = exports.ID = appInfo.ID; +var name = exports.name = appInfo.name; +var version = exports.version = appInfo.version; +var platformVersion = exports.platformVersion = appInfo.platformVersion; + +// The following mapping of application names to GUIDs was taken from: +// +// https://addons.mozilla.org/en-US/firefox/pages/appversions +// +// Using the GUID instead of the app's name is preferable because sometimes +// re-branded versions of a product have different names: for instance, +// Firefox, Minefield, Iceweasel, and Shiretoko all have the same +// GUID. + +var ids = exports.ids = { + Firefox: "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}", + Mozilla: "{86c18b42-e466-45a9-ae7a-9b95ba6f5640}", + SeaMonkey: "{92650c4d-4b8e-4d2a-b7eb-24ecf4f6b63a}", + Fennec: "{aa3c5121-dab2-40e2-81ca-7ea25febc110}", + Thunderbird: "{3550f703-e582-4d05-9a08-453d09bdfdc6}", + Instantbird: "{33cb9019-c295-46dd-be21-8c4936574bee}" +}; + +function is(name) { + if (!(name in ids)) + throw new Error("Unkown Mozilla Application: " + name); + return ID == ids[name]; +}; +exports.is = is; + +function isOneOf(names) { + for (var i = 0; i < names.length; i++) + if (is(names[i])) + return true; + return false; +}; +exports.isOneOf = isOneOf; + +/** + * Use this to check whether the given version (e.g. xulApp.platformVersion) + * is in the given range. Versions must be in version comparator-compatible + * format. See MDC for details: + * https://developer.mozilla.org/en/XPCOM_Interface_Reference/nsIVersionComparator + */ +var versionInRange = exports.versionInRange = +function versionInRange(version, lowInclusive, highExclusive) { + return (vc.compare(version, lowInclusive) >= 0) && + (vc.compare(version, highExclusive) < 0); +} + +const reVersionRange = /^((?:<|>)?=?)?\s*((?:\d+[\S]*)|\*)(?:\s+((?:<|>)=?)?(\d+[\S]+))?$/; +const reOnlyInifinity = /^[<>]?=?\s*[*x]$/; +const reSubInfinity = /\.[*x]/g; +const reHyphenRange = /^(\d+.*?)\s*-\s*(\d+.*?)$/; +const reRangeSeparator = /\s*\|\|\s*/; + +const compares = { + "=": function (c) { return c === 0 }, + ">=": function (c) { return c >= 0 }, + "<=": function (c) { return c <= 0}, + "<": function (c) { return c < 0 }, + ">": function (c) { return c > 0 } +} + +function normalizeRange(range) { + return range + .replace(reOnlyInifinity, "") + .replace(reSubInfinity, ".*") + .replace(reHyphenRange, ">=$1 <=$2") +} + +/** + * Compare the versions given, using the comparison operator provided. + * Internal use only. + * + * @example + * compareVersion("1.2", "<=", "1.*") // true + * + * @param {String} version + * A version to compare + * + * @param {String} comparison + * The comparison operator + * + * @param {String} compareVersion + * A version to compare + */ +function compareVersion(version, comparison, compareVersion) { + let hasWildcard = compareVersion.indexOf("*") !== -1; + + comparison = comparison || "="; + + if (hasWildcard) { + switch (comparison) { + case "=": + let zeroVersion = compareVersion.replace(reSubInfinity, ".0"); + return versionInRange(version, zeroVersion, compareVersion); + case ">=": + compareVersion = compareVersion.replace(reSubInfinity, ".0"); + break; + } + } + + let compare = compares[comparison]; + + return typeof compare === "function" && compare(vc.compare(version, compareVersion)); +} + +/** + * Returns `true` if `version` satisfies the `versionRange` given. + * If only an argument is passed, is used as `versionRange` and compared against + * `xulApp.platformVersion`. + * + * `versionRange` is either a string which has one or more space-separated + * descriptors, or a range like "fromVersion - toVersion". + * Version range descriptors may be any of the following styles: + * + * - "version" Must match `version` exactly + * - "=version" Same as just `version` + * - ">version" Must be greater than `version` + * - ">=version" Must be greater or equal than `version` + * - "<version" Must be less than `version` + * - "<=version" Must be less or equal than `version` + * - "1.2.x" or "1.2.*" See 'X version ranges' below + * - "*" or "" (just an empty string) Matches any version + * - "version1 - version2" Same as ">=version1 <=version2" + * - "range1 || range2" Passes if either `range1` or `range2` are satisfied + * + * For example, these are all valid: + * - "1.0.0 - 2.9999.9999" + * - ">=1.0.2 <2.1.2" + * - ">1.0.2 <=2.3.4" + * - "2.0.1" + * - "<1.0.0 || >=2.3.1 <2.4.5 || >=2.5.2 <3.0.0" + * - "2.x" (equivalent to "2.*") + * - "1.2.x" (equivalent to "1.2.*" and ">=1.2.0 <1.3.0") + */ +function satisfiesVersion(version, versionRange) { + if (arguments.length === 1) { + versionRange = version; + version = appInfo.version; + } + + let ranges = versionRange.trim().split(reRangeSeparator); + + return ranges.some(function(range) { + range = normalizeRange(range); + + // No versions' range specified means that any version satisfies the + // requirements. + if (range === "") + return true; + + let matches = range.match(reVersionRange); + + if (!matches) + return false; + + let [, lowMod, lowVer, highMod, highVer] = matches; + + return compareVersion(version, lowMod, lowVer) && (highVer !== undefined + ? compareVersion(version, highMod, highVer) + : true); + }); +} +exports.satisfiesVersion = satisfiesVersion; + +/** + * Ensure the current application satisfied the requirements specified in the + * module given. If not, an exception related to the incompatibility is + * returned; `null` otherwise. + * + * @param {Object} module + * The module to check + * @returns {Error} + */ +function incompatibility(module) { + let { metadata, id } = module; + + // if metadata or engines are not specified we assume compatibility is not + // an issue. + if (!metadata || !("engines" in metadata)) + return null; + + let { engines } = metadata; + + if (engines === null || typeof(engines) !== "object") + return new Error("Malformed engines' property in metadata"); + + let applications = Object.keys(engines); + + let versionRange; + applications.forEach(function(name) { + if (is(name)) { + versionRange = engines[name]; + // Continue iteration. We want to ensure the module doesn't + // contain a typo in the applications' name or some unknown + // application - `is` function throws an exception in that case. + } + }); + + if (typeof(versionRange) === "string") { + if (satisfiesVersion(versionRange)) + return null; + + return new Error("Unsupported Application version: The module " + id + + " currently supports only version " + versionRange + " of " + + name + "."); + } + + return new Error("Unsupported Application: The module " + id + + " currently supports only " + applications.join(", ") + ".") +} +exports.incompatibility = incompatibility; diff --git a/addon-sdk/source/lib/sdk/tab/events.js b/addon-sdk/source/lib/sdk/tab/events.js new file mode 100644 index 000000000..e431cc9d2 --- /dev/null +++ b/addon-sdk/source/lib/sdk/tab/events.js @@ -0,0 +1,74 @@ +/* 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 provides temporary shim until Bug 843901 is shipped. +// It basically registers tab event listeners on all windows that get +// opened and forwards them through observer notifications. + +module.metadata = { + "stability": "experimental" +}; + +const { Ci } = require("chrome"); +const { windows, isInteractive } = require("../window/utils"); +const { events } = require("../browser/events"); +const { open } = require("../event/dom"); +const { filter, map, merge, expand } = require("../event/utils"); +const isFennec = require("sdk/system/xul-app").is("Fennec"); + +// Module provides event stream (in nodejs style) that emits data events +// for all the tab events that happen in running firefox. At the moment +// it does it by registering listeners on all browser windows and then +// forwarding events when they occur to a stream. This will become obsolete +// once Bug 843901 is fixed, and we'll just leverage observer notifications. + +// Set of tab events that this module going to aggregate and expose. +const TYPES = ["TabOpen","TabClose","TabSelect","TabMove","TabPinned", + "TabUnpinned"]; + +// Utility function that given a browser `window` returns stream of above +// defined tab events for all tabs on the given window. +function tabEventsFor(window) { + // Map supported event types to a streams of those events on the given + // `window` and than merge these streams into single form stream off + // all events. + let channels = TYPES.map(type => open(window, type)); + return merge(channels); +} + +// Create our event channels. We do this in a separate function to +// minimize the chance of leaking intermediate objects on the global. +function makeEvents() { + // Filter DOMContentLoaded events from all the browser events. + var readyEvents = filter(events, e => e.type === "DOMContentLoaded"); + // Map DOMContentLoaded events to it's target browser windows. + var futureWindows = map(readyEvents, e => e.target); + // Expand all browsers that will become interactive to supported tab events + // on these windows. Result will be a tab events from all tabs of all windows + // that will become interactive. + var eventsFromFuture = expand(futureWindows, tabEventsFor); + + // Above covers only windows that will become interactive in a future, but some + // windows may already be interactive so we pick those and expand to supported + // tab events for them too. + var interactiveWindows = windows("navigator:browser", { includePrivate: true }). + filter(isInteractive); + var eventsFromInteractive = merge(interactiveWindows.map(tabEventsFor)); + + + // Finally merge stream of tab events from future windows and current windows + // to cover all tab events on all windows that will open. + return merge([eventsFromInteractive, eventsFromFuture]); +} + +// Map events to Fennec format if necessary +exports.events = map(makeEvents(), function (event) { + return !isFennec ? event : { + type: event.type, + target: event.target.ownerDocument.defaultView.BrowserApp + .getTabForBrowser(event.target) + }; +}); diff --git a/addon-sdk/source/lib/sdk/tabs.js b/addon-sdk/source/lib/sdk/tabs.js new file mode 100644 index 000000000..f61cad478 --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs.js @@ -0,0 +1,17 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +if (require("./system/xul-app").is("Fennec")) { + module.exports = require("./windows/tabs-fennec").tabs; +} +else { + module.exports = require("./tabs/tabs-firefox"); +} + +const tabs = module.exports; diff --git a/addon-sdk/source/lib/sdk/tabs/common.js b/addon-sdk/source/lib/sdk/tabs/common.js new file mode 100644 index 000000000..9ee512a7b --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs/common.js @@ -0,0 +1,34 @@ +/* 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 { validateOptions } = require("../deprecated/api-utils"); +const { data } = require("../self"); + +function Options(options) { + if ('string' === typeof options) + options = { url: options }; + + return validateOptions(options, { + url: { + is: ["string"], + map: (v) => v ? data.url(v) : v + }, + inBackground: { + map: Boolean, + is: ["undefined", "boolean"] + }, + isPinned: { is: ["undefined", "boolean"] }, + isPrivate: { is: ["undefined", "boolean"] }, + inNewWindow: { is: ["undefined", "boolean"] }, + onOpen: { is: ["undefined", "function"] }, + onClose: { is: ["undefined", "function"] }, + onReady: { is: ["undefined", "function"] }, + onLoad: { is: ["undefined", "function"] }, + onPageShow: { is: ["undefined", "function"] }, + onActivate: { is: ["undefined", "function"] }, + onDeactivate: { is: ["undefined", "function"] } + }); +} +exports.Options = Options; diff --git a/addon-sdk/source/lib/sdk/tabs/events.js b/addon-sdk/source/lib/sdk/tabs/events.js new file mode 100644 index 000000000..65650f9dc --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs/events.js @@ -0,0 +1,39 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const ON_PREFIX = "on"; +const TAB_PREFIX = "Tab"; + +const EVENTS = { + ready: "DOMContentLoaded", + load: "load", // Used for non-HTML content + pageshow: "pageshow", // Used for cached content + open: "TabOpen", + close: "TabClose", + activate: "TabSelect", + deactivate: null, + pinned: "TabPinned", + unpinned: "TabUnpinned" +} +exports.EVENTS = EVENTS; + +Object.keys(EVENTS).forEach(function(name) { + EVENTS[name] = { + name: name, + listener: createListenerName(name), + dom: EVENTS[name] + } +}); + +function createListenerName (name) { + if (name === 'pageshow') + return 'onPageShow'; + else + return ON_PREFIX + name.charAt(0).toUpperCase() + name.substr(1); +} diff --git a/addon-sdk/source/lib/sdk/tabs/helpers.js b/addon-sdk/source/lib/sdk/tabs/helpers.js new file mode 100644 index 000000000..b2c8aa013 --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs/helpers.js @@ -0,0 +1,22 @@ +/* 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'; + +module.metadata = { + 'stability': 'unstable' +}; + + +// NOTE: This file should only export Tab instances + + +const { getTabForBrowser: getRawTabForBrowser } = require('./utils'); +const { modelFor } = require('../model/core'); + +exports.getTabForRawTab = modelFor; + +function getTabForBrowser(browser) { + return modelFor(getRawTabForBrowser(browser)) || null; +} +exports.getTabForBrowser = getTabForBrowser; diff --git a/addon-sdk/source/lib/sdk/tabs/namespace.js b/addon-sdk/source/lib/sdk/tabs/namespace.js new file mode 100644 index 000000000..3553b1a99 --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs/namespace.js @@ -0,0 +1,10 @@ +/* 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 { ns } = require('../core/namespace'); + +exports.tabsNS = ns(); +exports.tabNS = ns(); +exports.rawTabNS = ns(); diff --git a/addon-sdk/source/lib/sdk/tabs/observer.js b/addon-sdk/source/lib/sdk/tabs/observer.js new file mode 100644 index 000000000..4e935cd62 --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs/observer.js @@ -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'; + +module.metadata = { + "stability": "unstable" +}; + +const { EventTarget } = require("../event/target"); +const { emit } = require("../event/core"); +const { DOMEventAssembler } = require("../deprecated/events/assembler"); +const { Class } = require("../core/heritage"); +const { getActiveTab, getTabs } = require("./utils"); +const { browserWindowIterator } = require("../deprecated/window-utils"); +const { isBrowser, windows, getMostRecentBrowserWindow } = require("../window/utils"); +const { observer: windowObserver } = require("../windows/observer"); +const { when } = require("../system/unload"); + +const EVENTS = { + "TabOpen": "open", + "TabClose": "close", + "TabSelect": "select", + "TabMove": "move", + "TabPinned": "pinned", + "TabUnpinned": "unpinned" +}; + +const selectedTab = Symbol("observer/state/selectedTab"); + +// Event emitter objects used to register listeners and emit events on them +// when they occur. +const Observer = Class({ + implements: [EventTarget, DOMEventAssembler], + initialize() { + this[selectedTab] = null; + // Currently Gecko does not dispatch any event on the previously selected + // tab before / after "TabSelect" is dispatched. In order to work around this + // limitation we keep track of selected tab and emit "deactivate" event with + // that before emitting "activate" on selected tab. + this.on("select", tab => { + const selected = this[selectedTab]; + if (selected !== tab) { + if (selected) { + emit(this, 'deactivate', selected); + } + + if (tab) { + this[selectedTab] = tab; + emit(this, 'activate', this[selectedTab]); + } + } + }); + + + // We also observe opening / closing windows in order to add / remove it's + // containers to the observed list. + windowObserver.on("open", chromeWindow => { + if (isBrowser(chromeWindow)) { + this.observe(chromeWindow); + } + }); + + windowObserver.on("close", chromeWindow => { + if (isBrowser(chromeWindow)) { + // Bug 751546: Emit `deactivate` event on window close immediatly + // Otherwise we are going to face "dead object" exception on `select` event + if (getActiveTab(chromeWindow) === this[selectedTab]) { + emit(this, "deactivate", this[selectedTab]); + this[selectedTab] = null; + } + this.ignore(chromeWindow); + } + }); + + + // Currently gecko does not dispatches "TabSelect" events when different + // window gets activated. To work around this limitation we emulate "select" + // event for this case. + windowObserver.on("activate", chromeWindow => { + if (isBrowser(chromeWindow)) { + emit(this, "select", getActiveTab(chromeWindow)); + } + }); + + // We should synchronize state, since probably we already have at least one + // window open. + for (let chromeWindow of browserWindowIterator()) { + this.observe(chromeWindow); + } + + when(_ => { + // Don't dispatch a deactivate event during unload. + this[selectedTab] = null; + }); + }, + /** + * Events that are supported and emitted by the module. + */ + supportedEventsTypes: Object.keys(EVENTS), + /** + * Function handles all the supported events on all the windows that are + * observed. Method is used to proxy events to the listeners registered on + * this event emitter. + * @param {Event} event + * Keyboard event being emitted. + */ + handleEvent: function handleEvent(event) { + emit(this, EVENTS[event.type], event.target, event); + } +}); + +exports.observer = new Observer(); diff --git a/addon-sdk/source/lib/sdk/tabs/tab-fennec.js b/addon-sdk/source/lib/sdk/tabs/tab-fennec.js new file mode 100644 index 000000000..3927337f6 --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs/tab-fennec.js @@ -0,0 +1,249 @@ +/* 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, Ci } = require('chrome'); +const { Class } = require('../core/heritage'); +const { tabNS, rawTabNS } = require('./namespace'); +const { EventTarget } = require('../event/target'); +const { activateTab, getTabTitle, setTabTitle, closeTab, getTabURL, + getTabContentWindow, getTabForBrowser, setTabURL, getOwnerWindow, + getTabContentDocument, getTabContentType, getTabId, isTab } = require('./utils'); +const { emit } = require('../event/core'); +const { isPrivate } = require('../private-browsing/utils'); +const { isWindowPrivate } = require('../window/utils'); +const { when: unload } = require('../system/unload'); +const { BLANK } = require('../content/thumbnail'); +const { viewFor } = require('../view/core'); +const { EVENTS } = require('./events'); +const { modelFor } = require('../model/core'); + +const ERR_FENNEC_MSG = 'This method is not yet supported by Fennec'; + +const Tab = Class({ + extends: EventTarget, + initialize: function initialize(options) { + options = options.tab ? options : { tab: options }; + let tab = options.tab; + + EventTarget.prototype.initialize.call(this, options); + let tabInternals = tabNS(this); + rawTabNS(tab).tab = this; + + let window = tabInternals.window = options.window || getOwnerWindow(tab); + tabInternals.tab = tab; + + // TabReady + let onReady = tabInternals.onReady = onTabReady.bind(this); + tab.browser.addEventListener(EVENTS.ready.dom, onReady, false); + + // TabPageShow + let onPageShow = tabInternals.onPageShow = onTabPageShow.bind(this); + tab.browser.addEventListener(EVENTS.pageshow.dom, onPageShow, false); + + // TabLoad + let onLoad = tabInternals.onLoad = onTabLoad.bind(this); + tab.browser.addEventListener(EVENTS.load.dom, onLoad, true); + + // TabClose + let onClose = tabInternals.onClose = onTabClose.bind(this); + window.BrowserApp.deck.addEventListener(EVENTS.close.dom, onClose, false); + + unload(cleanupTab.bind(null, this)); + }, + + /** + * The title of the page currently loaded in the tab. + * Changing this property changes an actual title. + * @type {String} + */ + get title() { + return getTabTitle(tabNS(this).tab); + }, + set title(title) { + setTabTitle(tabNS(this).tab, title); + }, + + /** + * Location of the page currently loaded in this tab. + * Changing this property will loads page under under the specified location. + * @type {String} + */ + get url() { + return tabNS(this).closed ? undefined : getTabURL(tabNS(this).tab); + }, + set url(url) { + setTabURL(tabNS(this).tab, url); + }, + + getThumbnail: function() { + // TODO: implement! + console.error(ERR_FENNEC_MSG); + + // return 80x45 blank default + return BLANK; + }, + + /** + * tab's document readyState, or 'uninitialized' if it doesn't even exist yet. + */ + get readyState() { + let doc = getTabContentDocument(tabNS(this).tab); + return doc && doc.readyState || 'uninitialized'; + }, + + get id() { + return getTabId(tabNS(this).tab); + }, + + /** + * The index of the tab relative to other tabs in the application window. + * Changing this property will change order of the actual position of the tab. + * @type {Number} + */ + get index() { + if (tabNS(this).closed) return undefined; + + let tabs = tabNS(this).window.BrowserApp.tabs; + let tab = tabNS(this).tab; + for (var i = tabs.length; i >= 0; i--) { + if (tabs[i] === tab) + return i; + } + return null; + }, + set index(value) { + console.error(ERR_FENNEC_MSG); // TODO + }, + + /** + * Whether or not tab is pinned (Is an app-tab). + * @type {Boolean} + */ + get isPinned() { + console.error(ERR_FENNEC_MSG); // TODO + return false; // TODO + }, + pin: function pin() { + console.error(ERR_FENNEC_MSG); // TODO + }, + unpin: function unpin() { + console.error(ERR_FENNEC_MSG); // TODO + }, + + /** + * Returns the MIME type that the document loaded in the tab is being + * rendered as. + * @type {String} + */ + get contentType() { + return getTabContentType(tabNS(this).tab); + }, + + /** + * Create a worker for this tab, first argument is options given to Worker. + * @type {Worker} + */ + attach: function attach(options) { + // BUG 792946 https://bugzilla.mozilla.org/show_bug.cgi?id=792946 + // TODO: fix this circular dependency + let { Worker } = require('./worker'); + return Worker(options, getTabContentWindow(tabNS(this).tab)); + }, + + /** + * Make this tab active. + */ + activate: function activate() { + activateTab(tabNS(this).tab, tabNS(this).window); + }, + + /** + * Close the tab + */ + close: function close(callback) { + let tab = this; + this.once(EVENTS.close.name, function () { + tabNS(tab).closed = true; + if (callback) callback(); + }); + + closeTab(tabNS(this).tab); + }, + + /** + * Reload the tab + */ + reload: function reload() { + tabNS(this).tab.browser.reload(); + } +}); +exports.Tab = Tab; + +// Implement `viewFor` polymorphic function for the Tab +// instances. +viewFor.define(Tab, x => tabNS(x).tab); + +function cleanupTab(tab) { + let tabInternals = tabNS(tab); + if (!tabInternals.tab) + return; + + if (tabInternals.tab.browser) { + tabInternals.tab.browser.removeEventListener(EVENTS.ready.dom, tabInternals.onReady, false); + tabInternals.tab.browser.removeEventListener(EVENTS.pageshow.dom, tabInternals.onPageShow, false); + tabInternals.tab.browser.removeEventListener(EVENTS.load.dom, tabInternals.onLoad, true); + } + tabInternals.onReady = null; + tabInternals.onPageShow = null; + tabInternals.onLoad = null; + tabInternals.window.BrowserApp.deck.removeEventListener(EVENTS.close.dom, tabInternals.onClose, false); + tabInternals.onClose = null; + rawTabNS(tabInternals.tab).tab = null; + tabInternals.tab = null; + tabInternals.window = null; +} + +function onTabReady(event) { + let win = event.target.defaultView; + + // ignore frames + if (win === win.top) { + emit(this, 'ready', this); + } +} + +function onTabLoad (event) { + let win = event.target.defaultView; + + // ignore frames + if (win === win.top) { + emit(this, 'load', this); + } +} + +function onTabPageShow(event) { + let win = event.target.defaultView; + if (win === win.top) + emit(this, 'pageshow', this, event.persisted); +} + +// TabClose +function onTabClose(event) { + let rawTab = getTabForBrowser(event.target); + if (tabNS(this).tab !== rawTab) + return; + + emit(this, EVENTS.close.name, this); + cleanupTab(this); +}; + +isPrivate.implement(Tab, tab => { + return isWindowPrivate(getTabContentWindow(tabNS(tab).tab)); +}); + +// Implement `modelFor` function for the Tab instances. +modelFor.when(isTab, rawTab => { + return rawTabNS(rawTab).tab; +}); diff --git a/addon-sdk/source/lib/sdk/tabs/tab-firefox.js b/addon-sdk/source/lib/sdk/tabs/tab-firefox.js new file mode 100644 index 000000000..f1da92379 --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs/tab-firefox.js @@ -0,0 +1,353 @@ +/* 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 { Class } = require('../core/heritage'); +const { observer } = require('./observer'); +const { observer: windowObserver } = require('../windows/observer'); +const { addListItem, removeListItem } = require('../util/list'); +const { viewFor } = require('../view/core'); +const { modelFor } = require('../model/core'); +const { emit, setListeners } = require('../event/core'); +const { EventTarget } = require('../event/target'); +const { getBrowserForTab, setTabURL, getTabId, getTabURL, getTabForBrowser, + getTabs, getTabTitle, setTabTitle, getIndex, closeTab, reload, move, + activateTab, pin, unpin, isTab } = require('./utils'); +const { isBrowser, getInnerId, isWindowPrivate } = require('../window/utils'); +const { getThumbnailURIForWindow, BLANK } = require("../content/thumbnail"); +const { when } = require('../system/unload'); +const { ignoreWindow, isPrivate } = require('../private-browsing/utils') +const { defer } = require('../lang/functional'); +const { getURL } = require('../url/utils'); +const { frames, remoteRequire } = require('../remote/parent'); +remoteRequire('sdk/content/tab-events'); + +const modelsFor = new WeakMap(); +const viewsFor = new WeakMap(); +const destroyed = new WeakMap(); + +const tabEvents = {}; +exports.tabEvents = tabEvents; + +function browser(tab) { + return getBrowserForTab(viewsFor.get(tab)); +} + +function isDestroyed(tab) { + return destroyed.has(tab); +} + +function isClosed(tab) { + if (!viewsFor.has(tab)) + return true; + return viewsFor.get(tab).closing; +} + +// private tab attribute where the remote cached value is stored +const remoteReadyStateCached = Symbol("remoteReadyStateCached"); + +const Tab = Class({ + implements: [EventTarget], + initialize: function(tabElement, options = null) { + modelsFor.set(tabElement, this); + viewsFor.set(this, tabElement); + + if (options) { + EventTarget.prototype.initialize.call(this, options); + + if (options.isPinned) + this.pin(); + + // Note that activate is defered and so will run after any open event + // is sent out + if (!options.inBackground) + this.activate(); + } + + getURL.implement(this, tab => tab.url); + isPrivate.implement(this, tab => { + return isWindowPrivate(viewsFor.get(tab).ownerDocument.defaultView); + }); + }, + + get id() { + return isDestroyed(this) ? undefined : getTabId(viewsFor.get(this)); + }, + + get title() { + return isDestroyed(this) ? undefined : getTabTitle(viewsFor.get(this)); + }, + + set title(val) { + if (isDestroyed(this)) + return; + + setTabTitle(viewsFor.get(this), val); + }, + + get url() { + return isDestroyed(this) ? undefined : getTabURL(viewsFor.get(this)); + }, + + set url(val) { + if (isDestroyed(this)) + return; + + setTabURL(viewsFor.get(this), val); + }, + + get contentType() { + return isDestroyed(this) ? undefined : browser(this).documentContentType; + }, + + get index() { + return isDestroyed(this) ? undefined : getIndex(viewsFor.get(this)); + }, + + set index(val) { + if (isDestroyed(this)) + return; + + move(viewsFor.get(this), val); + }, + + get isPinned() { + return isDestroyed(this) ? undefined : viewsFor.get(this).pinned; + }, + + get window() { + if (isClosed(this)) + return undefined; + + // TODO: Remove the dependency on the windows module, see bug 792670 + require('../windows'); + let tabElement = viewsFor.get(this); + let domWindow = tabElement.ownerDocument.defaultView; + return modelFor(domWindow); + }, + + get readyState() { + return isDestroyed(this) ? undefined : this[remoteReadyStateCached] || "uninitialized"; + }, + + pin: function() { + if (isDestroyed(this)) + return; + + pin(viewsFor.get(this)); + }, + + unpin: function() { + if (isDestroyed(this)) + return; + + unpin(viewsFor.get(this)); + }, + + close: function(callback) { + let tabElement = viewsFor.get(this); + + if (isDestroyed(this) || !tabElement || !tabElement.parentNode) { + if (callback) + callback(); + return; + } + + this.once('close', () => { + this.destroy(); + if (callback) + callback(); + }); + + closeTab(tabElement); + }, + + reload: function() { + if (isDestroyed(this)) + return; + + reload(viewsFor.get(this)); + }, + + activate: defer(function() { + if (isDestroyed(this)) + return; + + activateTab(viewsFor.get(this)); + }), + + getThumbnail: function() { + if (isDestroyed(this)) + return BLANK; + + // TODO: This is unimplemented in e10s: bug 1148601 + if (browser(this).isRemoteBrowser) { + console.error('This method is not supported with E10S'); + return BLANK; + } + return getThumbnailURIForWindow(browser(this).contentWindow); + }, + + attach: function(options) { + if (isDestroyed(this)) + return; + + let { Worker } = require('../content/worker'); + let { connect, makeChildOptions } = require('../content/utils'); + + let worker = Worker(options); + worker.once("detach", () => { + worker.destroy(); + }); + + let attach = frame => { + let childOptions = makeChildOptions(options); + frame.port.emit("sdk/tab/attach", childOptions); + connect(worker, frame, { id: childOptions.id, url: this.url }); + }; + + // Do this synchronously if possible + let frame = frames.getFrameForBrowser(browser(this)); + if (frame) { + attach(frame); + } + else { + let listener = (frame) => { + if (frame.frameElement != browser(this)) + return; + + frames.off("attach", listener); + attach(frame); + }; + frames.on("attach", listener); + } + + return worker; + }, + + destroy: function() { + if (isDestroyed(this)) + return; + + destroyed.set(this, true); + } +}); +exports.Tab = Tab; + +viewFor.define(Tab, tab => viewsFor.get(tab)); + +// Returns the high-level window for this DOM window if the windows module has +// ever been loaded otherwise returns null +function maybeWindowFor(domWindow) { + try { + return modelFor(domWindow); + } + catch (e) { + return null; + } +} + +function tabEmit(tab, event, ...args) { + // Don't emit events for destroyed tabs + if (isDestroyed(tab)) + return; + + // If the windows module was never loaded this will return null. We don't need + // to emit to the window.tabs object in this case as nothing can be listening. + let tabElement = viewsFor.get(tab); + let window = maybeWindowFor(tabElement.ownerDocument.defaultView); + if (window) + emit(window.tabs, event, tab, ...args); + + emit(tabEvents, event, tab, ...args); + emit(tab, event, tab, ...args); +} + +function windowClosed(domWindow) { + if (!isBrowser(domWindow)) + return; + + for (let tabElement of getTabs(domWindow)) { + tabEventListener("close", tabElement); + } +} +windowObserver.on('close', windowClosed); + +// Don't want to send close events after unloaded +when(_ => { + windowObserver.off('close', windowClosed); +}); + +// Listen for tabbrowser events +function tabEventListener(event, tabElement, ...args) { + let domWindow = tabElement.ownerDocument.defaultView; + + if (ignoreWindow(domWindow)) + return; + + // Don't send events for tabs that are already closing + if (event != "close" && (tabElement.closing || !tabElement.parentNode)) + return; + + let tab = modelsFor.get(tabElement); + if (!tab) + tab = new Tab(tabElement); + + let window = maybeWindowFor(domWindow); + + if (event == "open") { + // Note, add to the window tabs first because if this is the first access to + // window.tabs it will be prefilling itself with everything from tabs + if (window) + addListItem(window.tabs, tab); + // The tabs module will take care of adding to its internal list + } + else if (event == "close") { + if (window) + removeListItem(window.tabs, tab); + // The tabs module will take care of removing from its internal list + } + else if (event == "init" || event == "create" || event == "ready" || event == "load") { + // Ignore load events from before browser windows have fully loaded, these + // are for about:blank in the initial tab + if (isBrowser(domWindow) && !domWindow.gBrowserInit.delayedStartupFinished) + return; + + // update the cached remote readyState value + let { readyState } = args[0] || {}; + tab[remoteReadyStateCached] = readyState; + } + + if (event == "init") { + // Do not emit events for the detected existent tabs, we only need to cache + // their current document.readyState value. + return; + } + + tabEmit(tab, event, ...args); + + // The tab object shouldn't be reachable after closed + if (event == "close") { + viewsFor.delete(tab); + modelsFor.delete(tabElement); + } +} +observer.on('*', tabEventListener); + +// Listen for tab events from content +frames.port.on('sdk/tab/event', (frame, event, ...args) => { + if (!frame.isTab) + return; + + let tabElement = getTabForBrowser(frame.frameElement); + if (!tabElement) + return; + + tabEventListener(event, tabElement, ...args); +}); + +// Implement `modelFor` function for the Tab instances.. +modelFor.when(isTab, view => { + return modelsFor.get(view); +}); diff --git a/addon-sdk/source/lib/sdk/tabs/tab.js b/addon-sdk/source/lib/sdk/tabs/tab.js new file mode 100644 index 000000000..fa2272494 --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs/tab.js @@ -0,0 +1,24 @@ +/* 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'; + +module.metadata = { + 'stability': 'unstable' +}; + +const { getTargetWindow } = require("../content/mod"); +const { getTabContentWindow, isTab } = require("./utils"); +const { viewFor } = require("../view/core"); + +if (require('../system/xul-app').name == 'Fennec') { + module.exports = require('./tab-fennec'); +} +else { + module.exports = require('./tab-firefox'); +} + +getTargetWindow.when(isTab, tab => getTabContentWindow(tab)); + +getTargetWindow.when(x => x instanceof module.exports.Tab, + tab => getTabContentWindow(viewFor(tab))); diff --git a/addon-sdk/source/lib/sdk/tabs/tabs-firefox.js b/addon-sdk/source/lib/sdk/tabs/tabs-firefox.js new file mode 100644 index 000000000..1eefecb4c --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs/tabs-firefox.js @@ -0,0 +1,135 @@ +/* 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 { Class } = require('../core/heritage'); +const { Tab, tabEvents } = require('./tab'); +const { EventTarget } = require('../event/target'); +const { emit, setListeners } = require('../event/core'); +const { pipe } = require('../event/utils'); +const { observer: windowObserver } = require('../windows/observer'); +const { List, addListItem, removeListItem } = require('../util/list'); +const { modelFor } = require('../model/core'); +const { viewFor } = require('../view/core'); +const { getTabs, getSelectedTab } = require('./utils'); +const { getMostRecentBrowserWindow, isBrowser } = require('../window/utils'); +const { Options } = require('./common'); +const { isPrivate } = require('../private-browsing'); +const { ignoreWindow, isWindowPBSupported } = require('../private-browsing/utils') +const { isPrivateBrowsingSupported } = require('sdk/self'); + +const supportPrivateTabs = isPrivateBrowsingSupported && isWindowPBSupported; + +const Tabs = Class({ + implements: [EventTarget], + extends: List, + initialize: function() { + List.prototype.initialize.call(this); + + // We must do the list manipulation here where the object is extensible + this.on("open", tab => { + addListItem(this, tab); + }); + + this.on("close", tab => { + removeListItem(this, tab); + }); + }, + + get activeTab() { + let activeDomWin = getMostRecentBrowserWindow(); + if (!activeDomWin) + return null; + return modelFor(getSelectedTab(activeDomWin)); + }, + + open: function(options) { + options = Options(options); + + // TODO: Remove the dependency on the windows module: bug 792670 + let windows = require('../windows').browserWindows; + let activeWindow = windows.activeWindow; + + let privateState = supportPrivateTabs && options.isPrivate; + // When no isPrivate option was passed use the private state of the active + // window + if (activeWindow && privateState === undefined) + privateState = isPrivate(activeWindow); + + function getWindow(privateState) { + for (let window of windows) { + if (privateState === isPrivate(window)) { + return window; + } + } + return null; + } + + function openNewWindowWithTab() { + windows.open({ + url: options.url, + isPrivate: privateState, + onOpen: function(newWindow) { + let tab = newWindow.tabs[0]; + setListeners(tab, options); + + if (options.isPinned) + tab.pin(); + + // We don't emit the open event for the first tab in a new window so + // do it now the listeners are attached + emit(tab, "open", tab); + } + }); + } + + if (options.inNewWindow) + return openNewWindowWithTab(); + + // if the active window is in the state that we need then use it + if (activeWindow && (privateState === isPrivate(activeWindow))) + return activeWindow.tabs.open(options); + + // find a window in the state that we need + let window = getWindow(privateState); + if (window) + return window.tabs.open(options); + + return openNewWindowWithTab(); + } +}); + +const allTabs = new Tabs(); +// Export a new object with allTabs as the prototype, otherwise allTabs becomes +// frozen and addListItem and removeListItem don't work correctly. +module.exports = Object.create(allTabs); +pipe(tabEvents, module.exports); + +function addWindowTab(window, tabElement) { + let tab = new Tab(tabElement); + if (window) + addListItem(window.tabs, tab); + addListItem(allTabs, tab); + emit(allTabs, "open", tab); +} + +// Find tabs in already open windows +for (let tabElement of getTabs()) + addWindowTab(null, tabElement); + +// Detect tabs in new windows +windowObserver.on('open', domWindow => { + if (!isBrowser(domWindow) || ignoreWindow(domWindow)) + return; + + let window = null; + try { + modelFor(domWindow); + } + catch (e) { } + + for (let tabElement of getTabs(domWindow)) { + addWindowTab(window, tabElement); + } +}); diff --git a/addon-sdk/source/lib/sdk/tabs/utils.js b/addon-sdk/source/lib/sdk/tabs/utils.js new file mode 100644 index 000000000..eae3d41fe --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs/utils.js @@ -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'; + +module.metadata = { + 'stability': 'unstable' +}; + + +// NOTE: This file should only deal with xul/native tabs + + +const { Ci, Cu } = require('chrome'); +const { defer } = require("../lang/functional"); +const { windows, isBrowser } = require('../window/utils'); +const { isPrivateBrowsingSupported } = require('../self'); +const { ShimWaiver } = Cu.import("resource://gre/modules/ShimWaiver.jsm"); + +// Bug 834961: ignore private windows when they are not supported +function getWindows() { + return windows(null, { includePrivate: isPrivateBrowsingSupported }); +} + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +// Define predicate functions that can be used to detech weather +// we deal with fennec tabs or firefox tabs. + +// Predicate to detect whether tab is XUL "Tab" node. +const isXULTab = tab => + tab instanceof Ci.nsIDOMNode && + tab.nodeName === "tab" && + tab.namespaceURI === XUL_NS; +exports.isXULTab = isXULTab; + +// Predicate to detecet whether given tab is a fettec tab. +// Unfortunately we have to guess via duck typinng of: +// http://mxr.mozilla.org/mozilla-central/source/mobile/android/chrome/content/browser.js#2583 +const isFennecTab = tab => + tab && + tab.QueryInterface && + Ci.nsIBrowserTab && + tab.QueryInterface(Ci.nsIBrowserTab) === tab; +exports.isFennecTab = isFennecTab; + +const isTab = x => isXULTab(x) || isFennecTab(x); +exports.isTab = isTab; + +function activateTab(tab, window) { + let gBrowser = getTabBrowserForTab(tab); + + // normal case + if (gBrowser) { + gBrowser.selectedTab = tab; + } + // fennec ? + else if (window && window.BrowserApp) { + window.BrowserApp.selectTab(tab); + } + return null; +} +exports.activateTab = activateTab; + +function getTabBrowser(window) { + // bug 1009938 - may be null in SeaMonkey + return window.gBrowser || window.getBrowser(); +} +exports.getTabBrowser = getTabBrowser; + +function getTabContainer(window) { + return getTabBrowser(window).tabContainer; +} +exports.getTabContainer = getTabContainer; + +/** + * Returns the tabs for the `window` if given, or the tabs + * across all the browser's windows otherwise. + * + * @param {nsIWindow} [window] + * A reference to a window + * + * @returns {Array} an array of Tab objects + */ +function getTabs(window) { + if (arguments.length === 0) { + return getWindows(). + filter(isBrowser). + reduce((tabs, window) => tabs.concat(getTabs(window)), []); + } + + // fennec + if (window.BrowserApp) + return window.BrowserApp.tabs; + + // firefox - default + return Array.filter(getTabContainer(window).children, t => !t.closing); +} +exports.getTabs = getTabs; + +function getActiveTab(window) { + return getSelectedTab(window); +} +exports.getActiveTab = getActiveTab; + +function getOwnerWindow(tab) { + // normal case + if (tab.ownerDocument) + return tab.ownerDocument.defaultView; + + // try fennec case + return getWindowHoldingTab(tab); +} +exports.getOwnerWindow = getOwnerWindow; + +// fennec +function getWindowHoldingTab(rawTab) { + for (let window of getWindows()) { + // this function may be called when not using fennec, + // but BrowserApp is only defined on Fennec + if (!window.BrowserApp) + continue; + + for (let tab of window.BrowserApp.tabs) { + if (tab === rawTab) + return window; + } + } + + return null; +} + +function openTab(window, url, options) { + options = options || {}; + + // fennec? + if (window.BrowserApp) { + return window.BrowserApp.addTab(url, { + selected: options.inBackground ? false : true, + pinned: options.isPinned || false, + isPrivate: options.isPrivate || false, + parentId: window.BrowserApp.selectedTab.id + }); + } + + // firefox + let newTab = window.gBrowser.addTab(url); + if (!options.inBackground) { + activateTab(newTab); + } + return newTab; +}; +exports.openTab = openTab; + +function isTabOpen(tab) { + // try normal case then fennec case + return !!((tab.linkedBrowser) || getWindowHoldingTab(tab)); +} +exports.isTabOpen = isTabOpen; + +function closeTab(tab) { + let gBrowser = getTabBrowserForTab(tab); + // normal case? + if (gBrowser) { + // Bug 699450: the tab may already have been detached + if (!tab.parentNode) + return; + return gBrowser.removeTab(tab); + } + + let window = getWindowHoldingTab(tab); + // fennec? + if (window && window.BrowserApp) { + // Bug 699450: the tab may already have been detached + if (!tab.browser) + return; + return window.BrowserApp.closeTab(tab); + } + return null; +} +exports.closeTab = closeTab; + +function getURI(tab) { + if (tab.browser) // fennec + return tab.browser.currentURI.spec; + return tab.linkedBrowser.currentURI.spec; +} +exports.getURI = getURI; + +function getTabBrowserForTab(tab) { + let outerWin = getOwnerWindow(tab); + if (outerWin) + return getOwnerWindow(tab).gBrowser; + return null; +} +exports.getTabBrowserForTab = getTabBrowserForTab; + +function getBrowserForTab(tab) { + if (tab.browser) // fennec + return tab.browser; + + return tab.linkedBrowser; +} +exports.getBrowserForTab = getBrowserForTab; + +function getTabId(tab) { + if (tab.browser) // fennec + return tab.id + + return String.split(tab.linkedPanel, 'panel').pop(); +} +exports.getTabId = getTabId; + +function getTabForId(id) { + return getTabs().find(tab => getTabId(tab) === id) || null; +} +exports.getTabForId = getTabForId; + +function getTabTitle(tab) { + return getBrowserForTab(tab).contentTitle || tab.label || ""; +} +exports.getTabTitle = getTabTitle; + +function setTabTitle(tab, title) { + title = String(title); + if (tab.browser) { + // Fennec + tab.browser.contentDocument.title = title; + } + else { + let browser = getBrowserForTab(tab); + // Note that we aren't actually setting the document title in e10s, just + // the title the browser thinks the content has + if (browser.isRemoteBrowser) + browser._contentTitle = title; + else + browser.contentDocument.title = title; + } + tab.label = String(title); +} +exports.setTabTitle = setTabTitle; + +function getTabContentDocument(tab) { + return getBrowserForTab(tab).contentDocument; +} +exports.getTabContentDocument = getTabContentDocument; + +function getTabContentWindow(tab) { + return getBrowserForTab(tab).contentWindow; +} +exports.getTabContentWindow = getTabContentWindow; + +/** + * Returns all tabs' content windows across all the browsers' windows + */ +function getAllTabContentWindows() { + return getTabs().map(getTabContentWindow); +} +exports.getAllTabContentWindows = getAllTabContentWindows; + +// gets the tab containing the provided window +function getTabForContentWindow(window) { + return getTabs().find(tab => getTabContentWindow(tab) === window.top) || null; +} +exports.getTabForContentWindow = getTabForContentWindow; + +// only sdk/selection.js is relying on shims +function getTabForContentWindowNoShim(window) { + function getTabContentWindowNoShim(tab) { + let browser = getBrowserForTab(tab); + return ShimWaiver.getProperty(browser, "contentWindow"); + } + return getTabs().find(tab => getTabContentWindowNoShim(tab) === window.top) || null; +} +exports.getTabForContentWindowNoShim = getTabForContentWindowNoShim; + +function getTabURL(tab) { + return String(getBrowserForTab(tab).currentURI.spec); +} +exports.getTabURL = getTabURL; + +function setTabURL(tab, url) { + let browser = getBrowserForTab(tab); + browser.loadURI(String(url)); +} +// "TabOpen" event is fired when it's still "about:blank" is loaded in the +// changing `location` property of the `contentDocument` has no effect since +// seems to be either ignored or overridden by internal listener, there for +// location change is enqueued for the next turn of event loop. +exports.setTabURL = defer(setTabURL); + +function getTabContentType(tab) { + return getBrowserForTab(tab).contentDocument.contentType; +} +exports.getTabContentType = getTabContentType; + +function getSelectedTab(window) { + if (window.BrowserApp) // fennec? + return window.BrowserApp.selectedTab; + if (window.gBrowser) + return window.gBrowser.selectedTab; + return null; +} +exports.getSelectedTab = getSelectedTab; + + +function getTabForBrowser(browser) { + for (let window of getWindows()) { + // this function may be called when not using fennec + if (!window.BrowserApp) + continue; + + for (let tab of window.BrowserApp.tabs) { + if (tab.browser === browser) + return tab; + } + } + + let tabbrowser = browser.getTabBrowser && browser.getTabBrowser() + return !!tabbrowser && tabbrowser.getTabForBrowser(browser); +} +exports.getTabForBrowser = getTabForBrowser; + +function pin(tab) { + let gBrowser = getTabBrowserForTab(tab); + // TODO: Implement Fennec support + if (gBrowser) gBrowser.pinTab(tab); +} +exports.pin = pin; + +function unpin(tab) { + let gBrowser = getTabBrowserForTab(tab); + // TODO: Implement Fennec support + if (gBrowser) gBrowser.unpinTab(tab); +} +exports.unpin = unpin; + +function isPinned(tab) { + return !!tab.pinned; +} +exports.isPinned = isPinned; + +function reload(tab) { + getBrowserForTab(tab).reload(); +} +exports.reload = reload + +function getIndex(tab) { + let gBrowser = getTabBrowserForTab(tab); + // Firefox + if (gBrowser) { + return tab._tPos; + } + // Fennec + else { + let window = getWindowHoldingTab(tab) + let tabs = window.BrowserApp.tabs; + for (let i = tabs.length; i >= 0; i--) + if (tabs[i] === tab) return i; + } +} +exports.getIndex = getIndex; + +function move(tab, index) { + let gBrowser = getTabBrowserForTab(tab); + // Firefox + if (gBrowser) gBrowser.moveTabTo(tab, index); + // TODO: Implement fennec support +} +exports.move = move; diff --git a/addon-sdk/source/lib/sdk/tabs/worker.js b/addon-sdk/source/lib/sdk/tabs/worker.js new file mode 100644 index 000000000..d2ba33696 --- /dev/null +++ b/addon-sdk/source/lib/sdk/tabs/worker.js @@ -0,0 +1,17 @@ +/* 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 ContentWorker = require('../content/worker').Worker; + +function Worker(options, window) { + options.window = window; + + let worker = ContentWorker(options); + worker.once("detach", function detach() { + worker.destroy(); + }); + return worker; +} +exports.Worker = Worker;
\ No newline at end of file diff --git a/addon-sdk/source/lib/sdk/test.js b/addon-sdk/source/lib/sdk/test.js new file mode 100644 index 000000000..e7e3df840 --- /dev/null +++ b/addon-sdk/source/lib/sdk/test.js @@ -0,0 +1,114 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cu } = require("chrome"); +const { Task } = require("resource://gre/modules/Task.jsm", {}); +const { defer } = require("sdk/core/promise"); +const BaseAssert = require("sdk/test/assert").Assert; +const { isFunction, isObject, isGenerator } = require("sdk/lang/type"); +const { extend } = require("sdk/util/object"); + +exports.Assert = BaseAssert; + +/** + * Function takes test `suite` object in CommonJS format and defines all of the + * tests from that suite and nested suites in a jetpack format on a given + * `target` object. Optionally third argument `prefix` can be passed to prefix + * all the test names. + */ +function defineTestSuite(target, suite, prefix) { + prefix = prefix || ""; + // If suite defines `Assert` that's what `assert` object have to be created + // from and passed to a test function (This allows custom assertion functions) + // See for details: http://wiki.commonjs.org/wiki/Unit_Testing/1.1 + let Assert = suite.Assert || BaseAssert; + // Going through each item in the test suite and wrapping it into a + // Jetpack test format. + Object.keys(suite).forEach(function(key) { + // If name starts with test then it's a test function or suite. + if (key.indexOf("test") === 0) { + let test = suite[key]; + + // For each test function so we create a wrapper test function in a + // jetpack format and copy that to a `target` exports. + if (isFunction(test)) { + + // Since names of the test may match across suites we use full object + // path as a name to avoid overriding same function. + target[prefix + key] = function(options) { + + // Creating `assert` functions for this test. + let assert = Assert(options); + assert.end = () => options.done(); + + // If test function is a generator use a task JS to allow yield-ing + // style test runs. + if (isGenerator(test)) { + options.waitUntilDone(); + Task.spawn(test.bind(null, assert)). + catch(assert.fail). + then(assert.end); + } + + // If CommonJS test function expects more than one argument + // it means that test is async and second argument is a callback + // to notify that test is finished. + else if (1 < test.length) { + // Letting test runner know that test is executed async and + // creating a callback function that CommonJS tests will call + // once it's done. + options.waitUntilDone(); + test(assert, function() { + options.done(); + }); + } + + // Otherwise CommonJS test is synchronous so we call it only with + // one argument. + else { + test(assert); + } + } + } + + // If it's an object then it's a test suite containing test function + // and / or nested test suites. In that case we just extend prefix used + // and call this function to copy and wrap tests from nested suite. + else if (isObject(test)) { + // We need to clone `tests` instead of modifying it, since it's very + // likely that it is frozen (usually test suites imported modules). + test = extend(Object.prototype, test, { + Assert: test.Assert || Assert + }); + defineTestSuite(target, test, prefix + key + "."); + } + } + }); +} + +/** + * This function is a CommonJS test runner function, but since Jetpack test + * runner and test format is different from CommonJS this function shims given + * `exports` with all its tests into a Jetpack test format so that the built-in + * test runner will be able to run CommonJS test without manual changes. + */ +exports.run = function run(exports) { + // We can't leave old properties on exports since those are test in a CommonJS + // format that why we move everything to a new `suite` object. + let suite = {}; + Object.keys(exports).forEach(function(key) { + suite[key] = exports[key]; + delete exports[key]; + }); + + // Now we wrap all the CommonJS tests to a Jetpack format and define + // those to a given `exports` object since that where jetpack test runner + // will look for them. + defineTestSuite(exports, suite); +}; diff --git a/addon-sdk/source/lib/sdk/test/assert.js b/addon-sdk/source/lib/sdk/test/assert.js new file mode 100644 index 000000000..8478c8414 --- /dev/null +++ b/addon-sdk/source/lib/sdk/test/assert.js @@ -0,0 +1,366 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { isFunction, isNull, isObject, isString, + isRegExp, isArray, isDate, isPrimitive, + isUndefined, instanceOf, source } = require("../lang/type"); + +/** + * The `AssertionError` is defined in assert. + * @extends Error + * @example + * new assert.AssertionError({ + * message: message, + * actual: actual, + * expected: expected + * }) + */ +function AssertionError(options) { + let assertionError = Object.create(AssertionError.prototype); + + if (isString(options)) + options = { message: options }; + if ("actual" in options) + assertionError.actual = options.actual; + if ("expected" in options) + assertionError.expected = options.expected; + if ("operator" in options) + assertionError.operator = options.operator; + + assertionError.message = options.message; + assertionError.stack = new Error().stack; + return assertionError; +} +AssertionError.prototype = Object.create(Error.prototype, { + constructor: { value: AssertionError }, + name: { value: "AssertionError", enumerable: true }, + toString: { value: function toString() { + let value; + if (this.message) { + value = this.name + " : " + this.message; + } + else { + value = [ + this.name + " : ", + source(this.expected), + this.operator, + source(this.actual) + ].join(" "); + } + return value; + }} +}); +exports.AssertionError = AssertionError; + +function Assert(logger) { + let assert = Object.create(Assert.prototype, { _log: { value: logger }}); + + assert.fail = assert.fail.bind(assert); + assert.pass = assert.pass.bind(assert); + + return assert; +} + +Assert.prototype = { + fail: function fail(e) { + if (!e || typeof(e) !== 'object') { + this._log.fail(e); + return; + } + let message = e.message; + try { + if ('operator' in e) { + message += [ + " -", + source(e.actual), + e.operator, + source(e.expected) + ].join(" "); + } + } + catch(e) {} + this._log.fail(message); + }, + pass: function pass(message) { + this._log.pass(message); + return true; + }, + error: function error(e) { + this._log.exception(e); + }, + ok: function ok(value, message) { + if (!!!value) { + this.fail({ + actual: value, + expected: true, + message: message, + operator: "==" + }); + return false; + } + + this.pass(message); + return true; + }, + + /** + * The equality assertion tests shallow, coercive equality with `==`. + * @example + * assert.equal(1, 1, "one is one"); + */ + equal: function equal(actual, expected, message) { + if (actual == expected) { + this.pass(message); + return true; + } + + this.fail({ + actual: actual, + expected: expected, + message: message, + operator: "==" + }); + return false; + }, + + /** + * The non-equality assertion tests for whether two objects are not equal + * with `!=`. + * @example + * assert.notEqual(1, 2, "one is not two"); + */ + notEqual: function notEqual(actual, expected, message) { + if (actual != expected) { + this.pass(message); + return true; + } + + this.fail({ + actual: actual, + expected: expected, + message: message, + operator: "!=", + }); + return false; + }, + + /** + * The equivalence assertion tests a deep (with `===`) equality relation. + * @example + * assert.deepEqual({ a: "foo" }, { a: "foo" }, "equivalent objects") + */ + deepEqual: function deepEqual(actual, expected, message) { + if (isDeepEqual(actual, expected)) { + this.pass(message); + return true; + } + + this.fail({ + actual: actual, + expected: expected, + message: message, + operator: "deepEqual" + }); + return false; + }, + + /** + * The non-equivalence assertion tests for any deep (with `===`) inequality. + * @example + * assert.notDeepEqual({ a: "foo" }, Object.create({ a: "foo" }), + * "object's inherit from different prototypes"); + */ + notDeepEqual: function notDeepEqual(actual, expected, message) { + if (!isDeepEqual(actual, expected)) { + this.pass(message); + return true; + } + + this.fail({ + actual: actual, + expected: expected, + message: message, + operator: "notDeepEqual" + }); + return false; + }, + + /** + * The strict equality assertion tests strict equality, as determined by + * `===`. + * @example + * assert.strictEqual(null, null, "`null` is `null`") + */ + strictEqual: function strictEqual(actual, expected, message) { + if (actual === expected) { + this.pass(message); + return true; + } + + this.fail({ + actual: actual, + expected: expected, + message: message, + operator: "===" + }); + return false; + }, + + /** + * The strict non-equality assertion tests for strict inequality, as + * determined by `!==`. + * @example + * assert.notStrictEqual(null, undefined, "`null` is not `undefined`"); + */ + notStrictEqual: function notStrictEqual(actual, expected, message) { + if (actual !== expected) { + this.pass(message); + return true; + } + + this.fail({ + actual: actual, + expected: expected, + message: message, + operator: "!==" + }); + return false; + }, + + /** + * The assertion whether or not given `block` throws an exception. If optional + * `Error` argument is provided and it's type of function thrown error is + * asserted to be an instance of it, if type of `Error` is string then message + * of throw exception is asserted to contain it. + * @param {Function} block + * Function that is expected to throw. + * @param {Error|RegExp} [Error] + * Error constructor that is expected to be thrown or a string that + * must be contained by a message of the thrown exception, or a RegExp + * matching a message of the thrown exception. + * @param {String} message + * Description message + * + * @examples + * + * assert.throws(function block() { + * doSomething(4) + * }, "Object is expected", "Incorrect argument is passed"); + * + * assert.throws(function block() { + * Object.create(5) + * }, TypeError, "TypeError is thrown"); + */ + throws: function throws(block, Error, message) { + let threw = false; + let exception = null; + + // If third argument is not provided and second argument is a string it + // means that optional `Error` argument was not passed, so we shift + // arguments. + if (isString(Error) && isUndefined(message)) { + message = Error; + Error = undefined; + } + + // Executing given `block`. + try { + block(); + } + catch (e) { + threw = true; + exception = e; + } + + // If exception was thrown and `Error` argument was not passed assert is + // passed. + if (threw && (isUndefined(Error) || + // If passed `Error` is RegExp using it's test method to + // assert thrown exception message. + (isRegExp(Error) && (Error.test(exception.message) || Error.test(exception.toString()))) || + // If passed `Error` is a constructor function testing if + // thrown exception is an instance of it. + (isFunction(Error) && instanceOf(exception, Error)))) + { + this.pass(message); + return true; + } + + // Otherwise we report assertion failure. + let failure = { + message: message, + operator: "matches" + }; + + if (exception) { + failure.actual = exception.message || exception.toString(); + } + + if (Error) { + failure.expected = Error.toString(); + } + + this.fail(failure); + return false; + } +}; +exports.Assert = Assert; + +function isDeepEqual(actual, expected) { + // 7.1. All identical values are equivalent, as determined by ===. + if (actual === expected) { + return true; + } + + // 7.2. If the expected value is a Date object, the actual value is + // equivalent if it is also a Date object that refers to the same time. + else if (isDate(actual) && isDate(expected)) { + return actual.getTime() === expected.getTime(); + } + + // XXX specification bug: this should be specified + else if (isPrimitive(actual) || isPrimitive(expected)) { + return expected === actual; + } + + // 7.3. Other pairs that do not both pass typeof value == "object", + // equivalence is determined by ==. + else if (!isObject(actual) && !isObject(expected)) { + return actual == expected; + } + + // 7.4. 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. + else { + return actual.prototype === expected.prototype && + isEquivalent(actual, expected); + } +} + +function isEquivalent(a, b, stack) { + let aKeys = Object.keys(a); + let bKeys = Object.keys(b); + + return aKeys.length === bKeys.length && + isArrayEquivalent(aKeys.sort(), bKeys.sort()) && + aKeys.every(function(key) { + return isDeepEqual(a[key], b[key], stack) + }); +} + +function isArrayEquivalent(a, b, stack) { + return isArray(a) && isArray(b) && + a.every(function(value, index) { + return isDeepEqual(value, b[index]); + }); +} diff --git a/addon-sdk/source/lib/sdk/test/harness.js b/addon-sdk/source/lib/sdk/test/harness.js new file mode 100644 index 000000000..1b31a1c79 --- /dev/null +++ b/addon-sdk/source/lib/sdk/test/harness.js @@ -0,0 +1,645 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Cc, Ci, Cu } = require("chrome"); +const { Loader } = require('./loader'); +const { serializeStack, parseStack } = require("toolkit/loader"); +const { setTimeout } = require('../timers'); +const { PlainTextConsole } = require("../console/plain-text"); +const { when: unload } = require("../system/unload"); +const { format, fromException } = require("../console/traceback"); +const system = require("../system"); +const { gc: gcPromise } = require('./memory'); +const { defer } = require('../core/promise'); +const { extend } = require('../core/heritage'); + +// Trick manifest builder to make it think we need these modules ? +const unit = require("../deprecated/unit-test"); +const test = require("../../test"); +const url = require("../url"); + +function emptyPromise() { + let { promise, resolve } = defer(); + resolve(); + return promise; +} + +var cService = Cc['@mozilla.org/consoleservice;1'].getService(Ci.nsIConsoleService); + +// The console used to log messages +var testConsole; + +// Cuddlefish loader in which we load and execute tests. +var loader; + +// Function to call when we're done running tests. +var onDone; + +// Function to print text to a console, w/o CR at the end. +var print; + +// How many more times to run all tests. +var iterationsLeft; + +// Whether to report memory profiling information. +var profileMemory; + +// Whether we should stop as soon as a test reports a failure. +var stopOnError; + +// Function to call to retrieve a list of tests to execute +var findAndRunTests; + +// Combined information from all test runs. +var results; + +// A list of the compartments and windows loaded after startup +var startLeaks; + +// JSON serialization of last memory usage stats; we keep it stringified +// so we don't actually change the memory usage stats (in terms of objects) +// of the JSRuntime we're profiling. +var lastMemoryUsage; + +function analyzeRawProfilingData(data) { + var graph = data.graph; + var shapes = {}; + + // Convert keys in the graph from strings to ints. + // TODO: Can we get rid of this ridiculousness? + var newGraph = {}; + for (id in graph) { + newGraph[parseInt(id)] = graph[id]; + } + graph = newGraph; + + var modules = 0; + var moduleIds = []; + var moduleObjs = {UNKNOWN: 0}; + for (let name in data.namedObjects) { + moduleObjs[name] = 0; + moduleIds[data.namedObjects[name]] = name; + modules++; + } + + var count = 0; + for (id in graph) { + var parent = graph[id].parent; + while (parent) { + if (parent in moduleIds) { + var name = moduleIds[parent]; + moduleObjs[name]++; + break; + } + if (!(parent in graph)) { + moduleObjs.UNKNOWN++; + break; + } + parent = graph[parent].parent; + } + count++; + } + + print("\nobject count is " + count + " in " + modules + " modules" + + " (" + data.totalObjectCount + " across entire JS runtime)\n"); + if (lastMemoryUsage) { + var last = JSON.parse(lastMemoryUsage); + var diff = { + moduleObjs: dictDiff(last.moduleObjs, moduleObjs), + totalObjectClasses: dictDiff(last.totalObjectClasses, + data.totalObjectClasses) + }; + + for (let name in diff.moduleObjs) + print(" " + diff.moduleObjs[name] + " in " + name + "\n"); + for (let name in diff.totalObjectClasses) + print(" " + diff.totalObjectClasses[name] + " instances of " + + name + "\n"); + } + lastMemoryUsage = JSON.stringify( + {moduleObjs: moduleObjs, + totalObjectClasses: data.totalObjectClasses} + ); +} + +function dictDiff(last, curr) { + var diff = {}; + + for (let name in last) { + var result = (curr[name] || 0) - last[name]; + if (result) + diff[name] = (result > 0 ? "+" : "") + result; + } + for (let name in curr) { + var result = curr[name] - (last[name] || 0); + if (result) + diff[name] = (result > 0 ? "+" : "") + result; + } + return diff; +} + +function reportMemoryUsage() { + if (!profileMemory) { + return emptyPromise(); + } + + return gcPromise().then((() => { + var mgr = Cc["@mozilla.org/memory-reporter-manager;1"] + .getService(Ci.nsIMemoryReporterManager); + let count = 0; + function logReporter(process, path, kind, units, amount, description) { + print(((++count == 1) ? "\n" : "") + description + ": " + amount + "\n"); + } + mgr.getReportsForThisProcess(logReporter, null, /* anonymize = */ false); + })); +} + +var gWeakrefInfo; + +function checkMemory() { + return gcPromise().then(_ => { + let leaks = getPotentialLeaks(); + + let compartmentURLs = Object.keys(leaks.compartments).filter(function(url) { + return !(url in startLeaks.compartments); + }); + + let windowURLs = Object.keys(leaks.windows).filter(function(url) { + return !(url in startLeaks.windows); + }); + + for (let url of compartmentURLs) + console.warn("LEAKED", leaks.compartments[url]); + + for (let url of windowURLs) + console.warn("LEAKED", leaks.windows[url]); + }).then(showResults); +} + +function showResults() { + let { promise, resolve } = defer(); + + if (gWeakrefInfo) { + gWeakrefInfo.forEach( + function(info) { + var ref = info.weakref.get(); + if (ref !== null) { + var data = ref.__url__ ? ref.__url__ : ref; + var warning = data == "[object Object]" + ? "[object " + data.constructor.name + "(" + + Object.keys(data).join(", ") + ")]" + : data; + console.warn("LEAK", warning, info.bin); + } + } + ); + } + + onDone(results); + + resolve(); + return promise; +} + +function cleanup() { + let coverObject = {}; + try { + loader.unload(); + + if (loader.globals.console.errorsLogged && !results.failed) { + results.failed++; + console.error("warnings and/or errors were logged."); + } + + if (consoleListener.errorsLogged && !results.failed) { + console.warn(consoleListener.errorsLogged + " " + + "warnings or errors were logged to the " + + "platform's nsIConsoleService, which could " + + "be of no consequence; however, they could also " + + "be indicative of aberrant behavior."); + } + + // read the code coverage object, if it exists, from CoverJS-moz + if (typeof loader.globals.global == "object") { + coverObject = loader.globals.global['__$coverObject'] || {}; + } + + consoleListener.errorsLogged = 0; + loader = null; + + consoleListener.unregister(); + + Cu.forceGC(); + } + catch (e) { + results.failed++; + console.error("unload.send() threw an exception."); + console.exception(e); + }; + + setTimeout(require("./options").checkMemory ? checkMemory : showResults, 1); + + // dump the coverobject + if (Object.keys(coverObject).length){ + const self = require('sdk/self'); + const {pathFor} = require("sdk/system"); + let file = require('sdk/io/file'); + const {env} = require('sdk/system/environment'); + console.log("CWD:", env.PWD); + let out = file.join(env.PWD,'coverstats-'+self.id+'.json'); + console.log('coverstats:', out); + let outfh = file.open(out,'w'); + outfh.write(JSON.stringify(coverObject,null,2)); + outfh.flush(); + outfh.close(); + } +} + +function getPotentialLeaks() { + Cu.forceGC(); + + // Things we can assume are part of the platform and so aren't leaks + let GOOD_BASE_URLS = [ + "chrome://", + "resource:///", + "resource://app/", + "resource://gre/", + "resource://gre-resources/", + "resource://pdf.js/", + "resource://pdf.js.components/", + "resource://services-common/", + "resource://services-crypto/", + "resource://services-sync/" + ]; + + let ioService = Cc["@mozilla.org/network/io-service;1"]. + getService(Ci.nsIIOService); + let uri = ioService.newURI("chrome://global/content/", "UTF-8", null); + let chromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"]. + getService(Ci.nsIChromeRegistry); + uri = chromeReg.convertChromeURL(uri); + let spec = uri.spec; + let pos = spec.indexOf("!/"); + GOOD_BASE_URLS.push(spec.substring(0, pos + 2)); + + let zoneRegExp = new RegExp("^explicit/js-non-window/zones/zone[^/]+/compartment\\((.+)\\)"); + let compartmentRegexp = new RegExp("^explicit/js-non-window/compartments/non-window-global/compartment\\((.+)\\)/"); + let compartmentDetails = new RegExp("^([^,]+)(?:, (.+?))?(?: \\(from: (.*)\\))?$"); + let windowRegexp = new RegExp("^explicit/window-objects/top\\((.*)\\)/active"); + let windowDetails = new RegExp("^(.*), id=.*$"); + + function isPossibleLeak(item) { + if (!item.location) + return false; + + for (let url of GOOD_BASE_URLS) { + if (item.location.substring(0, url.length) == url) { + return false; + } + } + + return true; + } + + let compartments = {}; + let windows = {}; + function logReporter(process, path, kind, units, amount, description) { + let matches; + + if ((matches = compartmentRegexp.exec(path)) || (matches = zoneRegExp.exec(path))) { + if (matches[1] in compartments) + return; + + let details = compartmentDetails.exec(matches[1]); + if (!details) { + console.error("Unable to parse compartment detail " + matches[1]); + return; + } + + let item = { + path: matches[1], + principal: details[1], + location: details[2] ? details[2].replace(/\\/g, "/") : undefined, + source: details[3] ? details[3].split(" -> ").reverse() : undefined, + toString: function() { + return this.location; + } + }; + + if (!isPossibleLeak(item)) + return; + + compartments[matches[1]] = item; + return; + } + + if ((matches = windowRegexp.exec(path))) { + if (matches[1] in windows) + return; + + let details = windowDetails.exec(matches[1]); + if (!details) { + console.error("Unable to parse window detail " + matches[1]); + return; + } + + let item = { + path: matches[1], + location: details[1].replace(/\\/g, "/"), + source: [details[1].replace(/\\/g, "/")], + toString: function() { + return this.location; + } + }; + + if (!isPossibleLeak(item)) + return; + + windows[matches[1]] = item; + } + } + + Cc["@mozilla.org/memory-reporter-manager;1"] + .getService(Ci.nsIMemoryReporterManager) + .getReportsForThisProcess(logReporter, null, /* anonymize = */ false); + + return { compartments: compartments, windows: windows }; +} + +function nextIteration(tests) { + if (tests) { + results.passed += tests.passed; + results.failed += tests.failed; + + reportMemoryUsage().then(_ => { + let testRun = []; + for (let test of tests.testRunSummary) { + let testCopy = {}; + for (let info in test) { + testCopy[info] = test[info]; + } + testRun.push(testCopy); + } + + results.testRuns.push(testRun); + iterationsLeft--; + + checkForEnd(); + }) + } + else { + checkForEnd(); + } +} + +function checkForEnd() { + if (iterationsLeft && (!stopOnError || results.failed == 0)) { + // Pass the loader which has a hooked console that doesn't dispatch + // errors to the JS console and avoid firing false alarm in our + // console listener + findAndRunTests(loader, nextIteration); + } + else { + setTimeout(cleanup, 0); + } +} + +var POINTLESS_ERRORS = [ + 'Invalid chrome URI:', + 'OpenGL LayerManager Initialized Succesfully.', + '[JavaScript Error: "TelemetryStopwatch:', + 'reference to undefined property', + '[JavaScript Error: "The character encoding of the HTML document was ' + + 'not declared.', + '[Javascript Warning: "Error: Failed to preserve wrapper of wrapped ' + + 'native weak map key', + '[JavaScript Warning: "Duplicate resource declaration for', + 'file: "chrome://browser/content/', + 'file: "chrome://global/content/', + '[JavaScript Warning: "The character encoding of a framed document was ' + + 'not declared.', + 'file: "chrome://browser/skin/' +]; + +// These are messages that will cause a test to fail if logged through the +// console service +var IMPORTANT_ERRORS = [ + 'Sending message that cannot be cloned. Are you trying to send an XPCOM object?', +]; + +var consoleListener = { + registered: false, + + register: function() { + if (this.registered) + return; + cService.registerListener(this); + this.registered = true; + }, + + unregister: function() { + if (!this.registered) + return; + cService.unregisterListener(this); + this.registered = false; + }, + + errorsLogged: 0, + + observe: function(object) { + if (!(object instanceof Ci.nsIScriptError)) + return; + this.errorsLogged++; + var message = object.QueryInterface(Ci.nsIConsoleMessage).message; + if (IMPORTANT_ERRORS.find(msg => message.indexOf(msg) >= 0)) { + testConsole.error(message); + return; + } + var pointless = POINTLESS_ERRORS.filter(err => message.indexOf(err) >= 0); + if (pointless.length == 0 && message) + testConsole.log(message); + } +}; + +function TestRunnerConsole(base, options) { + let proto = extend(base, { + errorsLogged: 0, + warn: function warn() { + this.errorsLogged++; + base.warn.apply(base, arguments); + }, + error: function error() { + this.errorsLogged++; + base.error.apply(base, arguments); + }, + info: function info(first) { + if (options.verbose) + base.info.apply(base, arguments); + else + if (first == "pass:") + print("."); + }, + }); + return Object.create(proto); +} + +function stringify(arg) { + try { + return String(arg); + } + catch(ex) { + return "<toString() error>"; + } +} + +function stringifyArgs(args) { + return Array.map(args, stringify).join(" "); +} + +function TestRunnerTinderboxConsole(base, options) { + this.base = base; + this.print = options.print; + this.verbose = options.verbose; + this.errorsLogged = 0; + + // Binding all the public methods to an instance so that they can be used + // as callback / listener functions straightaway. + this.log = this.log.bind(this); + this.info = this.info.bind(this); + this.warn = this.warn.bind(this); + this.error = this.error.bind(this); + this.debug = this.debug.bind(this); + this.exception = this.exception.bind(this); + this.trace = this.trace.bind(this); +}; + +TestRunnerTinderboxConsole.prototype = { + testMessage: function testMessage(pass, expected, test, message) { + let type = "TEST-"; + if (expected) { + if (pass) + type += "PASS"; + else + type += "KNOWN-FAIL"; + } + else { + this.errorsLogged++; + if (pass) + type += "UNEXPECTED-PASS"; + else + type += "UNEXPECTED-FAIL"; + } + + this.print(type + " | " + test + " | " + message + "\n"); + if (!expected) + this.trace(); + }, + + log: function log() { + this.print("TEST-INFO | " + stringifyArgs(arguments) + "\n"); + }, + + info: function info(first) { + this.print("TEST-INFO | " + stringifyArgs(arguments) + "\n"); + }, + + warn: function warn() { + this.errorsLogged++; + this.print("TEST-UNEXPECTED-FAIL | " + stringifyArgs(arguments) + "\n"); + }, + + error: function error() { + this.errorsLogged++; + this.print("TEST-UNEXPECTED-FAIL | " + stringifyArgs(arguments) + "\n"); + this.base.error.apply(this.base, arguments); + }, + + debug: function debug() { + this.print("TEST-INFO | " + stringifyArgs(arguments) + "\n"); + }, + + exception: function exception(e) { + this.print("An exception occurred.\n" + + require("../console/traceback").format(e) + "\n" + e + "\n"); + }, + + trace: function trace() { + var traceback = require("../console/traceback"); + var stack = traceback.get(); + stack.splice(-1, 1); + this.print("TEST-INFO | " + stringify(traceback.format(stack)) + "\n"); + } +}; + +var runTests = exports.runTests = function runTests(options) { + iterationsLeft = options.iterations; + profileMemory = options.profileMemory; + stopOnError = options.stopOnError; + onDone = options.onDone; + print = options.print; + findAndRunTests = options.findAndRunTests; + + results = { + passed: 0, + failed: 0, + testRuns: [] + }; + + try { + consoleListener.register(); + print("Running tests on " + system.name + " " + system.version + + "/Gecko " + system.platformVersion + " (Build " + + system.build + ") (" + system.id + ") under " + + system.platform + "/" + system.architecture + ".\n"); + + if (options.parseable) + testConsole = new TestRunnerTinderboxConsole(new PlainTextConsole(), options); + else + testConsole = new TestRunnerConsole(new PlainTextConsole(), options); + + loader = Loader(module, { + console: testConsole, + global: {} // useful for storing things like coverage testing. + }); + + // Load these before getting initial leak stats as they will still be in + // memory when we check later + require("../deprecated/unit-test"); + require("../deprecated/unit-test-finder"); + if (profileMemory) + startLeaks = getPotentialLeaks(); + + nextIteration(); + } catch (e) { + let frames = fromException(e).reverse().reduce(function(frames, frame) { + if (frame.fileName.split("/").pop() === "unit-test-finder.js") + frames.done = true + if (!frames.done) frames.push(frame) + + return frames + }, []) + + let prototype = typeof(e) === "object" ? e.constructor.prototype : + Error.prototype; + let stack = serializeStack(frames.reverse()); + + let error = Object.create(prototype, { + message: { value: e.message, writable: true, configurable: true }, + fileName: { value: e.fileName, writable: true, configurable: true }, + lineNumber: { value: e.lineNumber, writable: true, configurable: true }, + stack: { value: stack, writable: true, configurable: true }, + toString: { value: () => String(e), writable: true, configurable: true }, + }); + + print("Error: " + error + " \n " + format(error)); + onDone({passed: 0, failed: 1}); + } +}; + +unload(_ => consoleListener.unregister()); diff --git a/addon-sdk/source/lib/sdk/test/httpd.js b/addon-sdk/source/lib/sdk/test/httpd.js new file mode 100644 index 000000000..218493924 --- /dev/null +++ b/addon-sdk/source/lib/sdk/test/httpd.js @@ -0,0 +1,6 @@ +/* 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/. */ + +throw new Error(`This file was removed. A copy can be obtained from: + https://github.com/mozilla/addon-sdk/blob/master/test/lib/httpd.js`); diff --git a/addon-sdk/source/lib/sdk/test/loader.js b/addon-sdk/source/lib/sdk/test/loader.js new file mode 100644 index 000000000..33ba2ca5a --- /dev/null +++ b/addon-sdk/source/lib/sdk/test/loader.js @@ -0,0 +1,123 @@ +/* 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 { resolveURI, Require, + unload, override, descriptor } = require('../../toolkit/loader'); +const { ensure } = require('../system/unload'); +const addonWindow = require('../addon/window'); +const { PlainTextConsole } = require('sdk/console/plain-text'); + +var defaultGlobals = override(require('../system/globals'), { + console: console +}); + +function CustomLoader(module, globals, packaging, overrides={}) { + let options = packaging || require("@loader/options"); + options = override(options, { + id: overrides.id || options.id, + globals: override(defaultGlobals, globals || {}), + modules: override(override(options.modules || {}, overrides.modules || {}), { + 'sdk/addon/window': addonWindow + }) + }); + + let loaderModule = options.isNative ? '../../toolkit/loader' : '../loader/cuddlefish'; + let { Loader } = require(loaderModule); + let loader = Loader(options); + let wrapper = Object.create(loader, descriptor({ + require: Require(loader, module), + sandbox: function(id) { + let requirement = loader.resolve(id, module.id); + if (!requirement) + requirement = id; + let uri = resolveURI(requirement, loader.mapping); + return loader.sandboxes[uri]; + }, + unload: function(reason) { + unload(loader, reason); + } + })); + ensure(wrapper); + return wrapper; +}; +exports.Loader = CustomLoader; + +function HookedPlainTextConsole(hook, print, innerID) { + this.log = hook.bind(null, "log", innerID); + this.info = hook.bind(null, "info", innerID); + this.warn = hook.bind(null, "warn", innerID); + this.error = hook.bind(null, "error", innerID); + this.debug = hook.bind(null, "debug", innerID); + this.exception = hook.bind(null, "exception", innerID); + this.time = hook.bind(null, "time", innerID); + this.timeEnd = hook.bind(null, "timeEnd", innerID); + + this.__exposedProps__ = { + log: "rw", info: "rw", warn: "rw", error: "rw", debug: "rw", + exception: "rw", time: "rw", timeEnd: "rw" + }; +} + +// Creates a custom loader instance whose console module is hooked in order +// to avoid printing messages to the console, and instead, expose them in the +// returned `messages` array attribute +exports.LoaderWithHookedConsole = function (module, callback) { + let messages = []; + function hook(type, innerID, msg) { + messages.push({ type: type, msg: msg, innerID: innerID }); + if (callback) + callback(type, msg, innerID); + } + + return { + loader: CustomLoader(module, { + console: new HookedPlainTextConsole(hook, null, null) + }, null, { + modules: { + 'sdk/console/plain-text': { + PlainTextConsole: HookedPlainTextConsole.bind(null, hook) + } + } + }), + messages: messages + }; +} + +// Same than LoaderWithHookedConsole with lower level, instead we get what is +// actually printed to the command line console +exports.LoaderWithHookedConsole2 = function (module, callback) { + let messages = []; + return { + loader: CustomLoader(module, { + console: new PlainTextConsole(function (msg) { + messages.push(msg); + if (callback) + callback(msg); + }) + }), + messages: messages + }; +} + +// Creates a custom loader with a filtered console. The callback is passed every +// console message type and message and if it returns false the message will +// not be logged normally +exports.LoaderWithFilteredConsole = function (module, callback) { + function hook(type, innerID, msg) { + if (callback && callback(type, msg, innerID) == false) + return; + console[type](msg); + } + + return CustomLoader(module, { + console: new HookedPlainTextConsole(hook, null, null) + }, null, { + modules: { + 'sdk/console/plain-text': { + PlainTextConsole: HookedPlainTextConsole.bind(null, hook) + } + } + }); +} diff --git a/addon-sdk/source/lib/sdk/test/memory.js b/addon-sdk/source/lib/sdk/test/memory.js new file mode 100644 index 000000000..bd1198bfe --- /dev/null +++ b/addon-sdk/source/lib/sdk/test/memory.js @@ -0,0 +1,11 @@ +/* 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 } = require("chrome"); + +function gc() { + return new Promise(resolve => Cu.schedulePreciseGC(resolve)); +} +exports.gc = gc; diff --git a/addon-sdk/source/lib/sdk/test/options.js b/addon-sdk/source/lib/sdk/test/options.js new file mode 100644 index 000000000..9bc611ca5 --- /dev/null +++ b/addon-sdk/source/lib/sdk/test/options.js @@ -0,0 +1,23 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const options = require("@test/options"); +const { id } = require("../self"); +const { get } = require("../preferences/service"); + +const readPref = (key) => get("extensions." + id + ".sdk." + key); + +exports.iterations = readPref("test.iterations") || options.iterations; +exports.filter = readPref("test.filter") || options.filter; +exports.profileMemory = readPref("profile.memory") || options.profileMemory; +exports.stopOnError = readPref("test.stop") || options.stopOnError; +exports.keepOpen = readPref("test.keepOpen") || false; +exports.verbose = (readPref("output.logLevel") == "verbose") || options.verbose; +exports.parseable = (readPref("output.format") == "tbpl") || options.parseable; +exports.checkMemory = readPref("profile.leaks") || options.check_memory; diff --git a/addon-sdk/source/lib/sdk/test/runner.js b/addon-sdk/source/lib/sdk/test/runner.js new file mode 100644 index 000000000..ea37ac84f --- /dev/null +++ b/addon-sdk/source/lib/sdk/test/runner.js @@ -0,0 +1,131 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +var { exit, stdout } = require("../system"); +var cfxArgs = require("../test/options"); +var events = require("../system/events"); +const { resolve } = require("../core/promise"); + +function runTests(findAndRunTests) { + var harness = require("./harness"); + + function onDone(tests) { + stdout.write("\n"); + var total = tests.passed + tests.failed; + stdout.write(tests.passed + " of " + total + " tests passed.\n"); + + events.emit("sdk:test:results", { data: JSON.stringify(tests) }); + + if (tests.failed == 0) { + if (tests.passed === 0) + stdout.write("No tests were run\n"); + if (!cfxArgs.keepOpen) + exit(0); + } else { + if (cfxArgs.verbose || cfxArgs.parseable) + printFailedTests(tests, stdout.write); + if (!cfxArgs.keepOpen) + exit(1); + } + }; + + // We may have to run test on next cycle, otherwise XPCOM components + // are not correctly updated. + // For ex: nsIFocusManager.getFocusedElementForWindow may throw + // NS_ERROR_ILLEGAL_VALUE exception. + require("../timers").setTimeout(_ => harness.runTests({ + findAndRunTests: findAndRunTests, + iterations: cfxArgs.iterations || 1, + filter: cfxArgs.filter, + profileMemory: cfxArgs.profileMemory, + stopOnError: cfxArgs.stopOnError, + verbose: cfxArgs.verbose, + parseable: cfxArgs.parseable, + print: stdout.write, + onDone: onDone + })); +} + +function printFailedTests(tests, print) { + let iterationNumber = 0; + let singleIteration = (tests.testRuns || []).length == 1; + let padding = singleIteration ? "" : " "; + + print("\nThe following tests failed:\n"); + + for (let testRun of tests.testRuns) { + iterationNumber++; + + if (!singleIteration) + print(" Iteration " + iterationNumber + ":\n"); + + for (let test of testRun) { + if (test.failed > 0) { + print(padding + " " + test.name + ": " + test.errors +"\n"); + } + } + print("\n"); + } +} + +function main() { + var testsStarted = false; + + if (!testsStarted) { + testsStarted = true; + runTests(function findAndRunTests(loader, nextIteration) { + loader.require("../deprecated/unit-test").findAndRunTests({ + testOutOfProcess: false, + testInProcess: true, + stopOnError: cfxArgs.stopOnError, + filter: cfxArgs.filter, + onDone: nextIteration + }); + }); + } +}; + +if (require.main === module) + main(); + +exports.runTestsFromModule = function runTestsFromModule(module) { + let id = module.id; + // Make a copy of exports as it may already be frozen by module loader + let exports = {}; + Object.keys(module.exports).forEach(key => { + exports[key] = module.exports[key]; + }); + + runTests(function findAndRunTests(loader, nextIteration) { + // Consider that all these tests are CommonJS ones + loader.require('../../test').run(exports); + + // Reproduce what is done in sdk/deprecated/unit-test-finder.findTests() + let tests = []; + for (let name of Object.keys(exports).sort()) { + tests.push({ + setup: exports.setup, + teardown: exports.teardown, + testFunction: exports[name], + name: id + "." + name + }); + } + + // Reproduce what is done by unit-test.findAndRunTests() + var { TestRunner } = loader.require("../deprecated/unit-test"); + var runner = new TestRunner(); + runner.startMany({ + tests: { + getNext: () => resolve(tests.shift()) + }, + stopOnError: cfxArgs.stopOnError, + onDone: nextIteration + }); + }); +} diff --git a/addon-sdk/source/lib/sdk/test/utils.js b/addon-sdk/source/lib/sdk/test/utils.js new file mode 100644 index 000000000..b01df67d4 --- /dev/null +++ b/addon-sdk/source/lib/sdk/test/utils.js @@ -0,0 +1,199 @@ +/* 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'; + +module.metadata = { + 'stability': 'unstable' +}; + +const { defer } = require('../core/promise'); +const { setInterval, clearInterval } = require('../timers'); +const { getTabs, closeTab } = require("../tabs/utils"); +const { windows: getWindows } = require("../window/utils"); +const { close: closeWindow } = require("../window/helpers"); +const { isGenerator } = require("../lang/type"); +const { env } = require("../system/environment"); +const { Task } = require("resource://gre/modules/Task.jsm"); + +const getTestNames = (exports) => + Object.keys(exports).filter(name => /^test/.test(name)); + +const isTestAsync = ({length}) => length > 1; +const isHelperAsync = ({length}) => length > 2; + +/* + * Takes an `exports` object of a test file and a function `beforeFn` + * to be run before each test. `beforeFn` is called with a `name` string + * as the first argument of the test name, and may specify a second + * argument function `done` to indicate that this function should + * resolve asynchronously + */ +function before (exports, beforeFn) { + getTestNames(exports).map(name => { + let testFn = exports[name]; + + // GENERATOR TESTS + if (isGenerator(testFn) && isGenerator(beforeFn)) { + exports[name] = function*(assert) { + yield Task.spawn(beforeFn.bind(null, name, assert)); + yield Task.spawn(testFn.bind(null, assert)); + } + } + else if (isGenerator(testFn) && !isHelperAsync(beforeFn)) { + exports[name] = function*(assert) { + beforeFn(name, assert); + yield Task.spawn(testFn.bind(null, assert)); + } + } + else if (isGenerator(testFn) && isHelperAsync(beforeFn)) { + exports[name] = function*(assert) { + yield new Promise(resolve => beforeFn(name, assert, resolve)); + yield Task.spawn(testFn.bind(null, assert)); + } + } + // SYNC TESTS + else if (!isTestAsync(testFn) && isGenerator(beforeFn)) { + exports[name] = function*(assert) { + yield Task.spawn(beforeFn.bind(null, name, assert)); + testFn(assert); + }; + } + else if (!isTestAsync(testFn) && !isHelperAsync(beforeFn)) { + exports[name] = function (assert) { + beforeFn(name, assert); + testFn(assert); + }; + } + else if (!isTestAsync(testFn) && isHelperAsync(beforeFn)) { + exports[name] = function (assert, done) { + beforeFn(name, assert, () => { + testFn(assert); + done(); + }); + }; + } + // ASYNC TESTS + else if (isTestAsync(testFn) && isGenerator(beforeFn)) { + exports[name] = function*(assert) { + yield Task.spawn(beforeFn.bind(null, name, assert)); + yield new Promise(resolve => testFn(assert, resolve)); + }; + } + else if (isTestAsync(testFn) && !isHelperAsync(beforeFn)) { + exports[name] = function (assert, done) { + beforeFn(name, assert); + testFn(assert, done); + }; + } + else if (isTestAsync(testFn) && isHelperAsync(beforeFn)) { + exports[name] = function (assert, done) { + beforeFn(name, assert, () => { + testFn(assert, done); + }); + }; + } + }); +} +exports.before = before; + +/* + * Takes an `exports` object of a test file and a function `afterFn` + * to be run after each test. `afterFn` is called with a `name` string + * as the first argument of the test name, and may specify a second + * argument function `done` to indicate that this function should + * resolve asynchronously + */ +function after (exports, afterFn) { + getTestNames(exports).map(name => { + let testFn = exports[name]; + + // GENERATOR TESTS + if (isGenerator(testFn) && isGenerator(afterFn)) { + exports[name] = function*(assert) { + yield Task.spawn(testFn.bind(null, assert)); + yield Task.spawn(afterFn.bind(null, name, assert)); + } + } + else if (isGenerator(testFn) && !isHelperAsync(afterFn)) { + exports[name] = function*(assert) { + yield Task.spawn(testFn.bind(null, assert)); + afterFn(name, assert); + } + } + else if (isGenerator(testFn) && isHelperAsync(afterFn)) { + exports[name] = function*(assert) { + yield Task.spawn(testFn.bind(null, assert)); + yield new Promise(resolve => afterFn(name, assert, resolve)); + } + } + // SYNC TESTS + else if (!isTestAsync(testFn) && isGenerator(afterFn)) { + exports[name] = function*(assert) { + testFn(assert); + yield Task.spawn(afterFn.bind(null, name, assert)); + }; + } + else if (!isTestAsync(testFn) && !isHelperAsync(afterFn)) { + exports[name] = function (assert) { + testFn(assert); + afterFn(name, assert); + }; + } + else if (!isTestAsync(testFn) && isHelperAsync(afterFn)) { + exports[name] = function (assert, done) { + testFn(assert); + afterFn(name, assert, done); + }; + } + // ASYNC TESTS + else if (isTestAsync(testFn) && isGenerator(afterFn)) { + exports[name] = function*(assert) { + yield new Promise(resolve => testFn(assert, resolve)); + yield Task.spawn(afterFn.bind(null, name, assert)); + }; + } + else if (isTestAsync(testFn) && !isHelperAsync(afterFn)) { + exports[name] = function*(assert) { + yield new Promise(resolve => testFn(assert, resolve)); + afterFn(name, assert); + }; + } + else if (isTestAsync(testFn) && isHelperAsync(afterFn)) { + exports[name] = function*(assert) { + yield new Promise(resolve => testFn(assert, resolve)); + yield new Promise(resolve => afterFn(name, assert, resolve)); + }; + } + }); +} +exports.after = after; + +function waitUntil (predicate, delay) { + let { promise, resolve } = defer(); + let interval = setInterval(() => { + if (!predicate()) return; + clearInterval(interval); + resolve(); + }, delay || 10); + return promise; +} +exports.waitUntil = waitUntil; + +var cleanUI = function cleanUI() { + let { promise, resolve } = defer(); + + let windows = getWindows(null, { includePrivate: true }); + if (windows.length > 1) { + return closeWindow(windows[1]).then(cleanUI); + } + + getTabs(windows[0]).slice(1).forEach(closeTab); + + resolve(); + + return promise; +} +exports.cleanUI = cleanUI; + +exports.isTravisCI = ("TRAVIS" in env && "CI" in env); diff --git a/addon-sdk/source/lib/sdk/timers.js b/addon-sdk/source/lib/sdk/timers.js new file mode 100644 index 000000000..e97db01f2 --- /dev/null +++ b/addon-sdk/source/lib/sdk/timers.js @@ -0,0 +1,105 @@ +/* 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'; + +module.metadata = { + "stability": "stable" +}; + +const { CC, Cc, Ci } = require("chrome"); +const { when: unload } = require("./system/unload"); + +const { TYPE_ONE_SHOT, TYPE_REPEATING_SLACK } = Ci.nsITimer; +const Timer = CC("@mozilla.org/timer;1", "nsITimer"); +const timers = Object.create(null); +const threadManager = Cc["@mozilla.org/thread-manager;1"]. + getService(Ci.nsIThreadManager); +const prefBranch = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefService). + QueryInterface(Ci.nsIPrefBranch); + +var MIN_DELAY = 4; +// Try to get min timeout delay used by browser. +try { MIN_DELAY = prefBranch.getIntPref("dom.min_timeout_value"); } finally {} + + +// Last timer id. +var lastID = 0; + +// Sets typer either by timeout or by interval +// depending on a given type. +function setTimer(type, callback, delay, ...args) { + let id = ++ lastID; + let timer = timers[id] = Timer(); + timer.initWithCallback({ + notify: function notify() { + try { + if (type === TYPE_ONE_SHOT) + delete timers[id]; + callback.apply(null, args); + } + catch(error) { + console.exception(error); + } + } + }, Math.max(delay || MIN_DELAY), type); + return id; +} + +function unsetTimer(id) { + let timer = timers[id]; + delete timers[id]; + if (timer) timer.cancel(); +} + +var immediates = new Map(); + +var dispatcher = _ => { + // Allow scheduling of a new dispatch loop. + dispatcher.scheduled = false; + // Take a snapshot of timer `id`'s that have being present before + // starting a dispatch loop, in order to ignore timers registered + // in side effect to dispatch while also skipping immediates that + // were removed in side effect. + let ids = [...immediates.keys()]; + for (let id of ids) { + let immediate = immediates.get(id); + if (immediate) { + immediates.delete(id); + try { immediate(); } + catch (error) { console.exception(error); } + } + } +} + +function setImmediate(callback, ...params) { + let id = ++ lastID; + // register new immediate timer with curried params. + immediates.set(id, _ => callback.apply(callback, params)); + // if dispatch loop is not scheduled schedule one. Own scheduler + if (!dispatcher.scheduled) { + dispatcher.scheduled = true; + threadManager.currentThread.dispatch(dispatcher, + Ci.nsIThread.DISPATCH_NORMAL); + } + return id; +} + +function clearImmediate(id) { + immediates.delete(id); +} + +// Bind timers so that toString-ing them looks same as on native timers. +exports.setImmediate = setImmediate.bind(null); +exports.clearImmediate = clearImmediate.bind(null); +exports.setTimeout = setTimer.bind(null, TYPE_ONE_SHOT); +exports.setInterval = setTimer.bind(null, TYPE_REPEATING_SLACK); +exports.clearTimeout = unsetTimer.bind(null); +exports.clearInterval = unsetTimer.bind(null); + +// all timers are cleared out on unload. +unload(function() { + immediates.clear(); + Object.keys(timers).forEach(unsetTimer) +}); diff --git a/addon-sdk/source/lib/sdk/ui.js b/addon-sdk/source/lib/sdk/ui.js new file mode 100644 index 000000000..7f9110b26 --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui.js @@ -0,0 +1,17 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '> 28' + } +}; + +exports.ActionButton = require('./ui/button/action').ActionButton; +exports.ToggleButton = require('./ui/button/toggle').ToggleButton; +exports.Sidebar = require('./ui/sidebar').Sidebar; +exports.Frame = require('./ui/frame').Frame; +exports.Toolbar = require('./ui/toolbar').Toolbar; diff --git a/addon-sdk/source/lib/sdk/ui/button/action.js b/addon-sdk/source/lib/sdk/ui/button/action.js new file mode 100644 index 000000000..dfb092d0c --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/button/action.js @@ -0,0 +1,114 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '> 28' + } +}; + +const { Class } = require('../../core/heritage'); +const { merge } = require('../../util/object'); +const { Disposable } = require('../../core/disposable'); +const { on, off, emit, setListeners } = require('../../event/core'); +const { EventTarget } = require('../../event/target'); +const { getNodeView } = require('../../view/core'); + +const view = require('./view'); +const { buttonContract, stateContract } = require('./contract'); +const { properties, render, state, register, unregister, + getDerivedStateFor } = require('../state'); +const { events: stateEvents } = require('../state/events'); +const { events: viewEvents } = require('./view/events'); +const events = require('../../event/utils'); + +const { getActiveTab } = require('../../tabs/utils'); + +const { id: addonID } = require('../../self'); +const { identify } = require('../id'); + +const buttons = new Map(); + +const toWidgetId = id => + ('action-button--' + addonID.toLowerCase()+ '-' + id). + replace(/[^a-z0-9_-]/g, ''); + +const ActionButton = Class({ + extends: EventTarget, + implements: [ + properties(stateContract), + state(stateContract), + Disposable + ], + setup: function setup(options) { + let state = merge({ + disabled: false + }, buttonContract(options)); + + let id = toWidgetId(options.id); + + register(this, state); + + // Setup listeners. + setListeners(this, options); + + buttons.set(id, this); + + view.create(merge({}, state, { id: id })); + }, + + dispose: function dispose() { + let id = toWidgetId(this.id); + buttons.delete(id); + + off(this); + + view.dispose(id); + + unregister(this); + }, + + get id() { + return this.state().id; + }, + + click: function click() { view.click(toWidgetId(this.id)) } +}); +exports.ActionButton = ActionButton; + +identify.define(ActionButton, ({id}) => toWidgetId(id)); + +getNodeView.define(ActionButton, button => + view.nodeFor(toWidgetId(button.id)) +); + +var actionButtonStateEvents = events.filter(stateEvents, + e => e.target instanceof ActionButton); + +var actionButtonViewEvents = events.filter(viewEvents, + e => buttons.has(e.target)); + +var clickEvents = events.filter(actionButtonViewEvents, e => e.type === 'click'); +var updateEvents = events.filter(actionButtonViewEvents, e => e.type === 'update'); + +on(clickEvents, 'data', ({target: id, window}) => { + let button = buttons.get(id); + let state = getDerivedStateFor(button, getActiveTab(window)); + + emit(button, 'click', state); +}); + +on(updateEvents, 'data', ({target: id, window}) => { + render(buttons.get(id), window); +}); + +on(actionButtonStateEvents, 'data', ({target, window, state}) => { + let id = toWidgetId(target.id); + view.setIcon(id, window, state.icon); + view.setLabel(id, window, state.label); + view.setDisabled(id, window, state.disabled); + view.setBadge(id, window, state.badge, state.badgeColor); +}); diff --git a/addon-sdk/source/lib/sdk/ui/button/contract.js b/addon-sdk/source/lib/sdk/ui/button/contract.js new file mode 100644 index 000000000..ce6e33d95 --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/button/contract.js @@ -0,0 +1,73 @@ +/* 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 { contract } = require('../../util/contract'); +const { isLocalURL } = require('../../url'); +const { isNil, isObject, isString } = require('../../lang/type'); +const { required, either, string, boolean, object, number } = require('../../deprecated/api-utils'); +const { merge } = require('../../util/object'); +const { freeze } = Object; + +const isIconSet = (icons) => + Object.keys(icons). + every(size => String(size >>> 0) === size && isLocalURL(icons[size])); + +var iconSet = { + is: either(object, string), + map: v => isObject(v) ? freeze(merge({}, v)) : v, + ok: v => (isString(v) && isLocalURL(v)) || (isObject(v) && isIconSet(v)), + msg: 'The option "icon" must be a local URL or an object with ' + + 'numeric keys / local URL values pair.' +} + +var id = { + is: string, + ok: v => /^[a-z-_][a-z0-9-_]*$/i.test(v), + msg: 'The option "id" must be a valid alphanumeric id (hyphens and ' + + 'underscores are allowed).' +}; + +var label = { + is: string, + ok: v => isNil(v) || v.trim().length > 0, + msg: 'The option "label" must be a non empty string' +} + +var badge = { + is: either(string, number), + msg: 'The option "badge" must be a string or a number' +} + +var badgeColor = { + is: string, + msg: 'The option "badgeColor" must be a string' +} + +var stateContract = contract({ + label: label, + icon: iconSet, + disabled: boolean, + badge: badge, + badgeColor: badgeColor +}); + +exports.stateContract = stateContract; + +var buttonContract = contract(merge({}, stateContract.rules, { + id: required(id), + label: required(label), + icon: required(iconSet) +})); + +exports.buttonContract = buttonContract; + +exports.toggleStateContract = contract(merge({ + checked: boolean +}, stateContract.rules)); + +exports.toggleButtonContract = contract(merge({ + checked: boolean +}, buttonContract.rules)); + diff --git a/addon-sdk/source/lib/sdk/ui/button/toggle.js b/addon-sdk/source/lib/sdk/ui/button/toggle.js new file mode 100644 index 000000000..a226b3212 --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/button/toggle.js @@ -0,0 +1,127 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '> 28' + } +}; + +const { Class } = require('../../core/heritage'); +const { merge } = require('../../util/object'); +const { Disposable } = require('../../core/disposable'); +const { on, off, emit, setListeners } = require('../../event/core'); +const { EventTarget } = require('../../event/target'); +const { getNodeView } = require('../../view/core'); + +const view = require('./view'); +const { toggleButtonContract, toggleStateContract } = require('./contract'); +const { properties, render, state, register, unregister, + setStateFor, getStateFor, getDerivedStateFor } = require('../state'); +const { events: stateEvents } = require('../state/events'); +const { events: viewEvents } = require('./view/events'); +const events = require('../../event/utils'); + +const { getActiveTab } = require('../../tabs/utils'); + +const { id: addonID } = require('../../self'); +const { identify } = require('../id'); + +const buttons = new Map(); + +const toWidgetId = id => + ('toggle-button--' + addonID.toLowerCase()+ '-' + id). + replace(/[^a-z0-9_-]/g, ''); + +const ToggleButton = Class({ + extends: EventTarget, + implements: [ + properties(toggleStateContract), + state(toggleStateContract), + Disposable + ], + setup: function setup(options) { + let state = merge({ + disabled: false, + checked: false + }, toggleButtonContract(options)); + + let id = toWidgetId(options.id); + + register(this, state); + + // Setup listeners. + setListeners(this, options); + + buttons.set(id, this); + + view.create(merge({ type: 'checkbox' }, state, { id: id })); + }, + + dispose: function dispose() { + let id = toWidgetId(this.id); + buttons.delete(id); + + off(this); + + view.dispose(id); + + unregister(this); + }, + + get id() { + return this.state().id; + }, + + click: function click() { + return view.click(toWidgetId(this.id)); + } +}); +exports.ToggleButton = ToggleButton; + +identify.define(ToggleButton, ({id}) => toWidgetId(id)); + +getNodeView.define(ToggleButton, button => + view.nodeFor(toWidgetId(button.id)) +); + +var toggleButtonStateEvents = events.filter(stateEvents, + e => e.target instanceof ToggleButton); + +var toggleButtonViewEvents = events.filter(viewEvents, + e => buttons.has(e.target)); + +var clickEvents = events.filter(toggleButtonViewEvents, e => e.type === 'click'); +var updateEvents = events.filter(toggleButtonViewEvents, e => e.type === 'update'); + +on(toggleButtonStateEvents, 'data', ({target, window, state}) => { + let id = toWidgetId(target.id); + + view.setIcon(id, window, state.icon); + view.setLabel(id, window, state.label); + view.setDisabled(id, window, state.disabled); + view.setChecked(id, window, state.checked); + view.setBadge(id, window, state.badge, state.badgeColor); +}); + +on(clickEvents, 'data', ({target: id, window, checked }) => { + let button = buttons.get(id); + let windowState = getStateFor(button, window); + + let newWindowState = merge({}, windowState, { checked: checked }); + + setStateFor(button, window, newWindowState); + + let state = getDerivedStateFor(button, getActiveTab(window)); + + emit(button, 'click', state); + + emit(button, 'change', state); +}); + +on(updateEvents, 'data', ({target: id, window}) => { + render(buttons.get(id), window); +}); diff --git a/addon-sdk/source/lib/sdk/ui/button/view.js b/addon-sdk/source/lib/sdk/ui/button/view.js new file mode 100644 index 000000000..63b7aea31 --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/button/view.js @@ -0,0 +1,243 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '> 28' + } +}; + +const { Cu } = require('chrome'); +const { on, off, emit } = require('../../event/core'); + +const { data } = require('sdk/self'); + +const { isObject, isNil } = require('../../lang/type'); + +const { getMostRecentBrowserWindow } = require('../../window/utils'); +const { ignoreWindow } = require('../../private-browsing/utils'); +const { CustomizableUI } = Cu.import('resource:///modules/CustomizableUI.jsm', {}); +const { AREA_PANEL, AREA_NAVBAR } = CustomizableUI; + +const { events: viewEvents } = require('./view/events'); + +const XUL_NS = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; + +const views = new Map(); +const customizedWindows = new WeakMap(); + +const buttonListener = { + onCustomizeStart: window => { + for (let [id, view] of views) { + setIcon(id, window, view.icon); + setLabel(id, window, view.label); + } + + customizedWindows.set(window, true); + }, + onCustomizeEnd: window => { + customizedWindows.delete(window); + + for (let [id, ] of views) { + let placement = CustomizableUI.getPlacementOfWidget(id); + + if (placement) + emit(viewEvents, 'data', { type: 'update', target: id, window: window }); + } + }, + onWidgetAfterDOMChange: (node, nextNode, container) => { + let { id } = node; + let view = views.get(id); + let window = node.ownerDocument.defaultView; + + if (view) { + emit(viewEvents, 'data', { type: 'update', target: id, window: window }); + } + } +}; + +CustomizableUI.addListener(buttonListener); + +require('../../system/unload').when( _ => + CustomizableUI.removeListener(buttonListener) +); + +function getNode(id, window) { + return !views.has(id) || ignoreWindow(window) + ? null + : CustomizableUI.getWidget(id).forWindow(window).node +}; + +function isInToolbar(id) { + let placement = CustomizableUI.getPlacementOfWidget(id); + + return placement && CustomizableUI.getAreaType(placement.area) === 'toolbar'; +} + + +function getImage(icon, isInToolbar, pixelRatio) { + let targetSize = (isInToolbar ? 18 : 32) * pixelRatio; + let bestSize = 0; + let image = icon; + + if (isObject(icon)) { + for (let size of Object.keys(icon)) { + size = +size; + let offset = targetSize - size; + + if (offset === 0) { + bestSize = size; + break; + } + + let delta = Math.abs(offset) - Math.abs(targetSize - bestSize); + + if (delta < 0) + bestSize = size; + } + + image = icon[bestSize]; + } + + if (image.indexOf('./') === 0) + return data.url(image.substr(2)); + + return image; +} + +function nodeFor(id, window=getMostRecentBrowserWindow()) { + return customizedWindows.has(window) ? null : getNode(id, window); +}; +exports.nodeFor = nodeFor; + +function create(options) { + let { id, label, icon, type, badge } = options; + + if (views.has(id)) + throw new Error('The ID "' + id + '" seems already used.'); + + CustomizableUI.createWidget({ + id: id, + type: 'custom', + removable: true, + defaultArea: AREA_NAVBAR, + allowedAreas: [ AREA_PANEL, AREA_NAVBAR ], + + onBuild: function(document) { + let window = document.defaultView; + + let node = document.createElementNS(XUL_NS, 'toolbarbutton'); + + let image = getImage(icon, true, window.devicePixelRatio); + + if (ignoreWindow(window)) + node.style.display = 'none'; + + node.setAttribute('id', this.id); + node.setAttribute('class', 'toolbarbutton-1 chromeclass-toolbar-additional badged-button'); + node.setAttribute('type', type); + node.setAttribute('label', label); + node.setAttribute('tooltiptext', label); + node.setAttribute('image', image); + node.setAttribute('constrain-size', 'true'); + + views.set(id, { + area: this.currentArea, + icon: icon, + label: label + }); + + node.addEventListener('command', function(event) { + if (views.has(id)) { + emit(viewEvents, 'data', { + type: 'click', + target: id, + window: event.view, + checked: node.checked + }); + } + }); + + return node; + } + }); +}; +exports.create = create; + +function dispose(id) { + if (!views.has(id)) return; + + views.delete(id); + CustomizableUI.destroyWidget(id); +} +exports.dispose = dispose; + +function setIcon(id, window, icon) { + let node = getNode(id, window); + + if (node) { + icon = customizedWindows.has(window) ? views.get(id).icon : icon; + let image = getImage(icon, isInToolbar(id), window.devicePixelRatio); + + node.setAttribute('image', image); + } +} +exports.setIcon = setIcon; + +function setLabel(id, window, label) { + let node = nodeFor(id, window); + + if (node) { + node.setAttribute('label', label); + node.setAttribute('tooltiptext', label); + } +} +exports.setLabel = setLabel; + +function setDisabled(id, window, disabled) { + let node = nodeFor(id, window); + + if (node) + node.disabled = disabled; +} +exports.setDisabled = setDisabled; + +function setChecked(id, window, checked) { + let node = nodeFor(id, window); + + if (node) + node.checked = checked; +} +exports.setChecked = setChecked; + +function setBadge(id, window, badge, color) { + let node = nodeFor(id, window); + + if (node) { + // `Array.from` is needed to handle unicode symbol properly: + // '𝐀𝐁'.length is 4 where Array.from('𝐀𝐁').length is 2 + let text = isNil(badge) + ? '' + : Array.from(String(badge)).slice(0, 4).join(''); + + node.setAttribute('badge', text); + + let badgeNode = node.ownerDocument.getAnonymousElementByAttribute(node, + 'class', 'toolbarbutton-badge'); + + if (badgeNode) + badgeNode.style.backgroundColor = isNil(color) ? '' : color; + } +} +exports.setBadge = setBadge; + +function click(id) { + let node = nodeFor(id); + + if (node) + node.click(); +} +exports.click = click; diff --git a/addon-sdk/source/lib/sdk/ui/button/view/events.js b/addon-sdk/source/lib/sdk/ui/button/view/events.js new file mode 100644 index 000000000..98909656a --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/button/view/events.js @@ -0,0 +1,18 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '*', + 'SeaMonkey': '*', + 'Thunderbird': '*' + } +}; + +var channel = {}; + +exports.events = channel; diff --git a/addon-sdk/source/lib/sdk/ui/component.js b/addon-sdk/source/lib/sdk/ui/component.js new file mode 100644 index 000000000..d1f12c95e --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/component.js @@ -0,0 +1,182 @@ +/* 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"; + +// Internal properties not exposed to the public. +const cache = Symbol("component/cache"); +const writer = Symbol("component/writer"); +const isFirstWrite = Symbol("component/writer/first-write?"); +const currentState = Symbol("component/state/current"); +const pendingState = Symbol("component/state/pending"); +const isWriting = Symbol("component/writing?"); + +const isntNull = x => x !== null; + +const Component = function(options, children) { + this[currentState] = null; + this[pendingState] = null; + this[writer] = null; + this[cache] = null; + this[isFirstWrite] = true; + + this[Component.construct](options, children); +} +Component.Component = Component; +// Constructs component. +Component.construct = Symbol("component/construct"); +// Called with `options` and `children` and must return +// initial state back. +Component.initial = Symbol("component/initial"); + +// Function patches current `state` with a given update. +Component.patch = Symbol("component/patch"); +// Function that replaces current `state` with a passed state. +Component.reset = Symbol("component/reset"); + +// Function that must return render tree from passed state. +Component.render = Symbol("component/render"); + +// Path of the component with in the mount point. +Component.path = Symbol("component/path"); + +Component.isMounted = component => !!component[writer]; +Component.isWriting = component => !!component[isWriting]; + +// Internal method that mounts component to a writer. +// Mounts component to a writer. +Component.mount = (component, write) => { + if (Component.isMounted(component)) { + throw Error("Can not mount already mounted component"); + } + + component[writer] = write; + Component.write(component); + + if (component[Component.mounted]) { + component[Component.mounted](); + } +} + +// Unmounts component from a writer. +Component.unmount = (component) => { + if (Component.isMounted(component)) { + component[writer] = null; + if (component[Component.unmounted]) { + component[Component.unmounted](); + } + } else { + console.warn("Unmounting component that is not mounted is redundant"); + } +}; + // Method invoked once after inital write occurs. +Component.mounted = Symbol("component/mounted"); +// Internal method that unmounts component from the writer. +Component.unmounted = Symbol("component/unmounted"); +// Function that must return true if component is changed +Component.isUpdated = Symbol("component/updated?"); +Component.update = Symbol("component/update"); +Component.updated = Symbol("component/updated"); + +const writeChild = base => (child, index) => Component.write(child, base, index) +Component.write = (component, base, index) => { + if (component === null) { + return component; + } + + if (!(component instanceof Component)) { + const path = base ? `${base}${component.key || index}/` : `/`; + return Object.assign({}, component, { + [Component.path]: path, + children: component.children && component.children. + map(writeChild(path)). + filter(isntNull) + }); + } + + component[isWriting] = true; + + try { + + const current = component[currentState]; + const pending = component[pendingState] || current; + const isUpdated = component[Component.isUpdated]; + const isInitial = component[isFirstWrite]; + + if (isUpdated(current, pending) || isInitial) { + if (!isInitial && component[Component.update]) { + component[Component.update](pending, current) + } + + // Note: [Component.update] could have caused more updates so can't use + // `pending` as `component[pendingState]` may have changed. + component[currentState] = component[pendingState] || current; + component[pendingState] = null; + + const tree = component[Component.render](component[currentState]); + component[cache] = Component.write(tree, base, index); + if (component[writer]) { + component[writer].call(null, component[cache]); + } + + if (!isInitial && component[Component.updated]) { + component[Component.updated](current, pending); + } + } + + component[isFirstWrite] = false; + + return component[cache]; + } finally { + component[isWriting] = false; + } +}; + +Component.prototype = Object.freeze({ + constructor: Component, + + [Component.mounted]: null, + [Component.unmounted]: null, + [Component.update]: null, + [Component.updated]: null, + + get state() { + return this[pendingState] || this[currentState]; + }, + + + [Component.construct](settings, items) { + const initial = this[Component.initial]; + const base = initial(settings, items); + const options = Object.assign(Object.create(null), base.options, settings); + const children = base.children || items || null; + const state = Object.assign(Object.create(null), base, {options, children}); + this[currentState] = state; + + if (this.setup) { + this.setup(state); + } + }, + [Component.initial](options, children) { + return Object.create(null); + }, + [Component.patch](update) { + this[Component.reset](Object.assign({}, this.state, update)); + }, + [Component.reset](state) { + this[pendingState] = state; + if (Component.isMounted(this) && !Component.isWriting(this)) { + Component.write(this); + } + }, + + [Component.isUpdated](before, after) { + return before != after + }, + + [Component.render](state) { + throw Error("Component must implement [Component.render] member"); + } +}); + +module.exports = Component; diff --git a/addon-sdk/source/lib/sdk/ui/frame.js b/addon-sdk/source/lib/sdk/ui/frame.js new file mode 100644 index 000000000..566353cdf --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/frame.js @@ -0,0 +1,16 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +require("./frame/view"); +const { Frame } = require("./frame/model"); + +exports.Frame = Frame; diff --git a/addon-sdk/source/lib/sdk/ui/frame/model.js b/addon-sdk/source/lib/sdk/ui/frame/model.js new file mode 100644 index 000000000..627310874 --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/frame/model.js @@ -0,0 +1,154 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +const { Class } = require("../../core/heritage"); +const { EventTarget } = require("../../event/target"); +const { emit, off, setListeners } = require("../../event/core"); +const { Reactor, foldp, send, merges } = require("../../event/utils"); +const { Disposable } = require("../../core/disposable"); +const { OutputPort } = require("../../output/system"); +const { InputPort } = require("../../input/system"); +const { identify } = require("../id"); +const { pairs, object, map, each } = require("../../util/sequence"); +const { patch, diff } = require("diffpatcher/index"); +const { isLocalURL } = require("../../url"); +const { compose } = require("../../lang/functional"); +const { contract } = require("../../util/contract"); +const { id: addonID, data: { url: resolve }} = require("../../self"); +const { Frames } = require("../../input/frame"); + + +const output = new OutputPort({ id: "frame-change" }); +const mailbox = new OutputPort({ id: "frame-mailbox" }); +const input = Frames; + + +const makeID = url => + ("frame-" + addonID + "-" + url). + split("/").join("-"). + split(".").join("-"). + replace(/[^A-Za-z0-9_\-]/g, ""); + +const validate = contract({ + name: { + is: ["string", "undefined"], + ok: x => /^[a-z][a-z0-9-_]+$/i.test(x), + msg: "The `option.name` must be a valid alphanumeric string (hyphens and " + + "underscores are allowed) starting with letter." + }, + url: { + map: x => x.toString(), + is: ["string"], + ok: x => isLocalURL(x), + msg: "The `options.url` must be a valid local URI." + } +}); + +const Source = function({id, ownerID}) { + this.id = id; + this.ownerID = ownerID; +}; +Source.postMessage = ({id, ownerID}, data, origin) => { + send(mailbox, object([id, { + inbox: { + target: {id: id, ownerID: ownerID}, + timeStamp: Date.now(), + data: data, + origin: origin + } + }])); +}; +Source.prototype.postMessage = function(data, origin) { + Source.postMessage(this, data, origin); +}; + +const Message = function({type, data, source, origin, timeStamp}) { + this.type = type; + this.data = data; + this.origin = origin; + this.timeStamp = timeStamp; + this.source = new Source(source); +}; + + +const frames = new Map(); +const sources = new Map(); + +const Frame = Class({ + extends: EventTarget, + implements: [Disposable, Source], + initialize: function(params={}) { + const options = validate(params); + const id = makeID(options.name || options.url); + + if (frames.has(id)) + throw Error("Frame with this id already exists: " + id); + + const initial = { id: id, url: resolve(options.url) }; + this.id = id; + + setListeners(this, params); + + frames.set(this.id, this); + + send(output, object([id, initial])); + }, + get url() { + const state = reactor.value[this.id]; + return state && state.url; + }, + destroy: function() { + send(output, object([this.id, null])); + frames.delete(this.id); + off(this); + }, + // `JSON.stringify` serializes objects based of the return + // value of this method. For convinienc we provide this method + // to serialize actual state data. + toJSON: function() { + return { id: this.id, url: this.url }; + } +}); +identify.define(Frame, frame => frame.id); + +exports.Frame = Frame; + +const reactor = new Reactor({ + onStep: (present, past) => { + const delta = diff(past, present); + + each(([id, update]) => { + const frame = frames.get(id); + if (update) { + if (!past[id]) + emit(frame, "register"); + + if (update.outbox) + emit(frame, "message", new Message(present[id].outbox)); + + each(([ownerID, state]) => { + const readyState = state ? state.readyState : "detach"; + const type = readyState === "loading" ? "attach" : + readyState === "interactive" ? "ready" : + readyState === "complete" ? "load" : + readyState; + + // TODO: Cache `Source` instances somewhere to preserve + // identity. + emit(frame, type, {type: type, + source: new Source({id: id, ownerID: ownerID})}); + }, pairs(update.owners)); + } + }, pairs(delta)); + } +}); +reactor.run(input); diff --git a/addon-sdk/source/lib/sdk/ui/frame/view.html b/addon-sdk/source/lib/sdk/ui/frame/view.html new file mode 100644 index 000000000..2a405b583 --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/frame/view.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + <head> + <script> + // HACK: This is not an ideal way to deliver chrome messages + // to an inner frame content but seems only way that would + // make `event.source` this (outer frame) window. + window.onmessage = function(event) { + var frame = document.querySelector("iframe"); + var content = frame.contentWindow; + // If message is posted from chrome it has no `event.source`. + if (event.source === null) + content.postMessage(event.data, "*"); + }; + </script> + </head> + <body style="overflow: hidden"></body> +</html> diff --git a/addon-sdk/source/lib/sdk/ui/frame/view.js b/addon-sdk/source/lib/sdk/ui/frame/view.js new file mode 100644 index 000000000..2eb4df2b7 --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/frame/view.js @@ -0,0 +1,150 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +const { Cu, Ci } = require("chrome"); +const { CustomizableUI } = Cu.import('resource:///modules/CustomizableUI.jsm', {}); +const { subscribe, send, Reactor, foldp, lift, merges, keepIf } = require("../../event/utils"); +const { InputPort } = require("../../input/system"); +const { OutputPort } = require("../../output/system"); +const { LastClosed } = require("../../input/browser"); +const { pairs, keys, object, each } = require("../../util/sequence"); +const { curry, compose } = require("../../lang/functional"); +const { getFrameElement, getOuterId, + getByOuterId, getOwnerBrowserWindow } = require("../../window/utils"); +const { patch, diff } = require("diffpatcher/index"); +const { encode } = require("../../base64"); +const { Frames } = require("../../input/frame"); + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const OUTER_FRAME_URI = module.uri.replace(/\.js$/, ".html"); + +const mailbox = new OutputPort({ id: "frame-mailbox" }); + +const frameID = frame => frame.id.replace("outer-", ""); +const windowID = compose(getOuterId, getOwnerBrowserWindow); + +const getOuterFrame = (windowID, frameID) => + getByOuterId(windowID).document.getElementById("outer-" + frameID); + +const listener = ({target, source, data, origin, timeStamp}) => { + // And sent received message to outbox so that frame API model + // will deal with it. + if (source && source !== target) { + const frame = getFrameElement(target); + const id = frameID(frame); + send(mailbox, object([id, { + outbox: {type: "message", + source: {id: id, ownerID: windowID(frame)}, + data: data, + origin: origin, + timeStamp: timeStamp}}])); + } +}; + +// Utility function used to create frame with a given `state` and +// inject it into given `window`. +const registerFrame = ({id, url}) => { + CustomizableUI.createWidget({ + id: id, + type: "custom", + removable: true, + onBuild: document => { + let view = document.createElementNS(XUL_NS, "toolbaritem"); + view.setAttribute("id", id); + view.setAttribute("flex", 2); + + let outerFrame = document.createElementNS(XUL_NS, "iframe"); + outerFrame.setAttribute("src", OUTER_FRAME_URI); + outerFrame.setAttribute("id", "outer-" + id); + outerFrame.setAttribute("data-is-sdk-outer-frame", true); + outerFrame.setAttribute("type", "content"); + outerFrame.setAttribute("transparent", true); + outerFrame.setAttribute("flex", 2); + outerFrame.setAttribute("style", "overflow: hidden;"); + outerFrame.setAttribute("scrolling", "no"); + outerFrame.setAttribute("disablehistory", true); + outerFrame.setAttribute("seamless", "seamless"); + outerFrame.addEventListener("load", function onload() { + outerFrame.removeEventListener("load", onload, true); + + let doc = outerFrame.contentDocument; + + let innerFrame = doc.createElementNS(HTML_NS, "iframe"); + innerFrame.setAttribute("id", id); + innerFrame.setAttribute("src", url); + innerFrame.setAttribute("seamless", "seamless"); + innerFrame.setAttribute("sandbox", "allow-scripts"); + innerFrame.setAttribute("scrolling", "no"); + innerFrame.setAttribute("data-is-sdk-inner-frame", true); + innerFrame.setAttribute("style", [ "border:none", + "position:absolute", "width:100%", "top: 0", + "left: 0", "overflow: hidden"].join(";")); + + doc.body.appendChild(innerFrame); + }, true); + + view.appendChild(outerFrame); + + return view; + } + }); +}; + +const unregisterFrame = CustomizableUI.destroyWidget; + +const deliverMessage = curry((frameID, data, windowID) => { + const frame = getOuterFrame(windowID, frameID); + const content = frame && frame.contentWindow; + + if (content) + content.postMessage(data, content.location.origin); +}); + +const updateFrame = (id, {inbox, owners}, present) => { + if (inbox) { + const { data, target:{ownerID}, source } = present[id].inbox; + if (ownerID) + deliverMessage(id, data, ownerID); + else + each(deliverMessage(id, data), keys(present[id].owners)); + } + + each(setupView(id), pairs(owners)); +}; + +const setupView = curry((frameID, [windowID, state]) => { + if (state && state.readyState === "loading") { + const frame = getOuterFrame(windowID, frameID); + // Setup a message listener on contentWindow. + frame.contentWindow.addEventListener("message", listener); + } +}); + + +const reactor = new Reactor({ + onStep: (present, past) => { + const delta = diff(past, present); + + // Apply frame changes + each(([id, update]) => { + if (update === null) + unregisterFrame(id); + else if (past[id]) + updateFrame(id, update, present); + else + registerFrame(update); + }, pairs(delta)); + }, + onEnd: state => each(unregisterFrame, keys(state)) +}); +reactor.run(Frames); diff --git a/addon-sdk/source/lib/sdk/ui/id.js b/addon-sdk/source/lib/sdk/ui/id.js new file mode 100644 index 000000000..d17eb0a4e --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/id.js @@ -0,0 +1,27 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental' +}; + +const method = require('../../method/core'); +const { uuid } = require('../util/uuid'); + +// NOTE: use lang/functional memoize when it is updated to use WeakMap +function memoize(f) { + const memo = new WeakMap(); + + return function memoizer(o) { + let key = o; + if (!memo.has(key)) + memo.set(key, f.apply(this, arguments)); + return memo.get(key); + }; +} + +var identify = method('identify'); +identify.define(Object, memoize(function() { return uuid(); })); +exports.identify = identify; diff --git a/addon-sdk/source/lib/sdk/ui/sidebar.js b/addon-sdk/source/lib/sdk/ui/sidebar.js new file mode 100644 index 000000000..59e35ea11 --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/sidebar.js @@ -0,0 +1,311 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '*' + } +}; + +const { Class } = require('../core/heritage'); +const { merge } = require('../util/object'); +const { Disposable } = require('../core/disposable'); +const { off, emit, setListeners } = require('../event/core'); +const { EventTarget } = require('../event/target'); +const { URL } = require('../url'); +const { add, remove, has, clear, iterator } = require('../lang/weak-set'); +const { id: addonID, data } = require('../self'); +const { WindowTracker } = require('../deprecated/window-utils'); +const { isShowing } = require('./sidebar/utils'); +const { isBrowser, getMostRecentBrowserWindow, windows, isWindowPrivate } = require('../window/utils'); +const { ns } = require('../core/namespace'); +const { remove: removeFromArray } = require('../util/array'); +const { show, hide, toggle } = require('./sidebar/actions'); +const { Worker } = require('../deprecated/sync-worker'); +const { contract: sidebarContract } = require('./sidebar/contract'); +const { create, dispose, updateTitle, updateURL, isSidebarShowing, showSidebar, hideSidebar } = require('./sidebar/view'); +const { defer } = require('../core/promise'); +const { models, views, viewsFor, modelFor } = require('./sidebar/namespace'); +const { isLocalURL } = require('../url'); +const { ensure } = require('../system/unload'); +const { identify } = require('./id'); +const { uuid } = require('../util/uuid'); +const { viewFor } = require('../view/core'); + +const resolveURL = (url) => url ? data.url(url) : url; + +const sidebarNS = ns(); + +const WEB_PANEL_BROWSER_ID = 'web-panels-browser'; + +var sidebars = {}; + +const Sidebar = Class({ + implements: [ Disposable ], + extends: EventTarget, + setup: function(options) { + // inital validation for the model information + let model = sidebarContract(options); + + // save the model information + models.set(this, model); + + // generate an id if one was not provided + model.id = model.id || addonID + '-' + uuid(); + + // further validation for the title and url + validateTitleAndURLCombo({}, this.title, this.url); + + const self = this; + const internals = sidebarNS(self); + const windowNS = internals.windowNS = ns(); + + // see bug https://bugzilla.mozilla.org/show_bug.cgi?id=886148 + ensure(this, 'destroy'); + + setListeners(this, options); + + let bars = []; + internals.tracker = WindowTracker({ + onTrack: function(window) { + if (!isBrowser(window)) + return; + + let sidebar = window.document.getElementById('sidebar'); + let sidebarBox = window.document.getElementById('sidebar-box'); + + let bar = create(window, { + id: self.id, + title: self.title, + sidebarurl: self.url + }); + bars.push(bar); + windowNS(window).bar = bar; + + bar.addEventListener('command', function() { + if (isSidebarShowing(window, self)) { + hideSidebar(window, self).catch(() => {}); + return; + } + + showSidebar(window, self); + }, false); + + function onSidebarLoad() { + // check if the sidebar is ready + let isReady = sidebar.docShell && sidebar.contentDocument; + if (!isReady) + return; + + // check if it is a web panel + let panelBrowser = sidebar.contentDocument.getElementById(WEB_PANEL_BROWSER_ID); + if (!panelBrowser) { + bar.removeAttribute('checked'); + return; + } + + let sbTitle = window.document.getElementById('sidebar-title'); + function onWebPanelSidebarCreated() { + if (panelBrowser.contentWindow.location != resolveURL(model.url) || + sbTitle.value != model.title) { + return; + } + + let worker = windowNS(window).worker = Worker({ + window: panelBrowser.contentWindow, + injectInDocument: true + }); + + function onWebPanelSidebarUnload() { + windowNS(window).onWebPanelSidebarUnload = null; + + // uncheck the associated menuitem + bar.setAttribute('checked', 'false'); + + emit(self, 'hide', {}); + emit(self, 'detach', worker); + windowNS(window).worker = null; + } + windowNS(window).onWebPanelSidebarUnload = onWebPanelSidebarUnload; + panelBrowser.contentWindow.addEventListener('unload', onWebPanelSidebarUnload, true); + + // check the associated menuitem + bar.setAttribute('checked', 'true'); + + function onWebPanelSidebarReady() { + panelBrowser.contentWindow.removeEventListener('DOMContentLoaded', onWebPanelSidebarReady, false); + windowNS(window).onWebPanelSidebarReady = null; + + emit(self, 'ready', worker); + } + windowNS(window).onWebPanelSidebarReady = onWebPanelSidebarReady; + panelBrowser.contentWindow.addEventListener('DOMContentLoaded', onWebPanelSidebarReady, false); + + function onWebPanelSidebarLoad() { + panelBrowser.contentWindow.removeEventListener('load', onWebPanelSidebarLoad, true); + windowNS(window).onWebPanelSidebarLoad = null; + + // TODO: decide if returning worker is acceptable.. + //emit(self, 'show', { worker: worker }); + emit(self, 'show', {}); + } + windowNS(window).onWebPanelSidebarLoad = onWebPanelSidebarLoad; + panelBrowser.contentWindow.addEventListener('load', onWebPanelSidebarLoad, true); + + emit(self, 'attach', worker); + } + windowNS(window).onWebPanelSidebarCreated = onWebPanelSidebarCreated; + panelBrowser.addEventListener('DOMWindowCreated', onWebPanelSidebarCreated, true); + } + windowNS(window).onSidebarLoad = onSidebarLoad; + sidebar.addEventListener('load', onSidebarLoad, true); // removed properly + }, + onUntrack: function(window) { + if (!isBrowser(window)) + return; + + // hide the sidebar if it is showing + hideSidebar(window, self).catch(() => {}); + + // kill the menu item + let { bar } = windowNS(window); + if (bar) { + removeFromArray(viewsFor(self), bar); + dispose(bar); + } + + // kill listeners + let sidebar = window.document.getElementById('sidebar'); + + if (windowNS(window).onSidebarLoad) { + sidebar && sidebar.removeEventListener('load', windowNS(window).onSidebarLoad, true) + windowNS(window).onSidebarLoad = null; + } + + let panelBrowser = sidebar && sidebar.contentDocument.getElementById(WEB_PANEL_BROWSER_ID); + if (windowNS(window).onWebPanelSidebarCreated) { + panelBrowser && panelBrowser.removeEventListener('DOMWindowCreated', windowNS(window).onWebPanelSidebarCreated, true); + windowNS(window).onWebPanelSidebarCreated = null; + } + + if (windowNS(window).onWebPanelSidebarReady) { + panelBrowser && panelBrowser.contentWindow.removeEventListener('DOMContentLoaded', windowNS(window).onWebPanelSidebarReady, false); + windowNS(window).onWebPanelSidebarReady = null; + } + + if (windowNS(window).onWebPanelSidebarLoad) { + panelBrowser && panelBrowser.contentWindow.removeEventListener('load', windowNS(window).onWebPanelSidebarLoad, true); + windowNS(window).onWebPanelSidebarLoad = null; + } + + if (windowNS(window).onWebPanelSidebarUnload) { + panelBrowser && panelBrowser.contentWindow.removeEventListener('unload', windowNS(window).onWebPanelSidebarUnload, true); + windowNS(window).onWebPanelSidebarUnload(); + } + } + }); + + views.set(this, bars); + + add(sidebars, this); + }, + get id() { + return (modelFor(this) || {}).id; + }, + get title() { + return (modelFor(this) || {}).title; + }, + set title(v) { + // destroyed? + if (!modelFor(this)) + return; + // validation + if (typeof v != 'string') + throw Error('title must be a string'); + validateTitleAndURLCombo(this, v, this.url); + // do update + updateTitle(this, v); + return modelFor(this).title = v; + }, + get url() { + return (modelFor(this) || {}).url; + }, + set url(v) { + // destroyed? + if (!modelFor(this)) + return; + + // validation + if (!isLocalURL(v)) + throw Error('the url must be a valid local url'); + + validateTitleAndURLCombo(this, this.title, v); + + // do update + updateURL(this, v); + modelFor(this).url = v; + }, + show: function(window) { + return showSidebar(viewFor(window), this); + }, + hide: function(window) { + return hideSidebar(viewFor(window), this); + }, + dispose: function() { + const internals = sidebarNS(this); + + off(this); + + remove(sidebars, this); + + // stop tracking windows + if (internals.tracker) { + internals.tracker.unload(); + } + + internals.tracker = null; + internals.windowNS = null; + + views.delete(this); + models.delete(this); + } +}); +exports.Sidebar = Sidebar; + +function validateTitleAndURLCombo(sidebar, title, url) { + url = resolveURL(url); + + if (sidebar.title == title && sidebar.url == url) { + return false; + } + + for (let window of windows(null, { includePrivate: true })) { + let sidebar = window.document.querySelector('menuitem[sidebarurl="' + url + '"][label="' + title + '"]'); + if (sidebar) { + throw Error('The provided title and url combination is invalid (already used).'); + } + } + + return false; +} + +isShowing.define(Sidebar, isSidebarShowing.bind(null, null)); +show.define(Sidebar, showSidebar.bind(null, null)); +hide.define(Sidebar, hideSidebar.bind(null, null)); + +identify.define(Sidebar, function(sidebar) { + return sidebar.id; +}); + +function toggleSidebar(window, sidebar) { + // TODO: make sure this is not private + window = window || getMostRecentBrowserWindow(); + if (isSidebarShowing(window, sidebar)) { + return hideSidebar(window, sidebar); + } + return showSidebar(window, sidebar); +} +toggle.define(Sidebar, toggleSidebar.bind(null, null)); diff --git a/addon-sdk/source/lib/sdk/ui/sidebar/actions.js b/addon-sdk/source/lib/sdk/ui/sidebar/actions.js new file mode 100644 index 000000000..4a52984c9 --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/sidebar/actions.js @@ -0,0 +1,10 @@ +/* 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 method = require('../../../method/core'); + +exports.show = method('show'); +exports.hide = method('hide'); +exports.toggle = method('toggle'); diff --git a/addon-sdk/source/lib/sdk/ui/sidebar/contract.js b/addon-sdk/source/lib/sdk/ui/sidebar/contract.js new file mode 100644 index 000000000..b59c37c0b --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/sidebar/contract.js @@ -0,0 +1,27 @@ +/* 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 { contract } = require('../../util/contract'); +const { isValidURI, URL, isLocalURL } = require('../../url'); +const { isNil, isObject, isString } = require('../../lang/type'); + +exports.contract = contract({ + id: { + is: [ 'string', 'undefined' ], + ok: v => /^[a-z0-9-_]+$/i.test(v), + msg: 'The option "id" must be a valid alphanumeric id (hyphens and ' + + 'underscores are allowed).' + }, + title: { + is: [ 'string' ], + ok: v => v.length + }, + url: { + is: [ 'string' ], + ok: v => isLocalURL(v), + map: v => v.toString(), + msg: 'The option "url" must be a valid local URI.' + } +}); diff --git a/addon-sdk/source/lib/sdk/ui/sidebar/namespace.js b/addon-sdk/source/lib/sdk/ui/sidebar/namespace.js new file mode 100644 index 000000000..d79725d1a --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/sidebar/namespace.js @@ -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/. */ +'use strict'; + +const models = exports.models = new WeakMap(); +const views = exports.views = new WeakMap(); +exports.buttons = new WeakMap(); + +exports.viewsFor = function viewsFor(sidebar) { + return views.get(sidebar); +}; +exports.modelFor = function modelFor(sidebar) { + return models.get(sidebar); +}; diff --git a/addon-sdk/source/lib/sdk/ui/sidebar/utils.js b/addon-sdk/source/lib/sdk/ui/sidebar/utils.js new file mode 100644 index 000000000..d6145c32e --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/sidebar/utils.js @@ -0,0 +1,8 @@ +/* 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 method = require('../../../method/core'); + +exports.isShowing = method('isShowing'); diff --git a/addon-sdk/source/lib/sdk/ui/sidebar/view.js b/addon-sdk/source/lib/sdk/ui/sidebar/view.js new file mode 100644 index 000000000..c91e69d3d --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/sidebar/view.js @@ -0,0 +1,214 @@ +/* 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'; + +module.metadata = { + 'stability': 'unstable', + 'engines': { + 'Firefox': '*' + } +}; + +const { models, buttons, views, viewsFor, modelFor } = require('./namespace'); +const { isBrowser, getMostRecentBrowserWindow, windows, isWindowPrivate } = require('../../window/utils'); +const { setStateFor } = require('../state'); +const { defer } = require('../../core/promise'); +const { isPrivateBrowsingSupported, data } = require('../../self'); + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const WEB_PANEL_BROWSER_ID = 'web-panels-browser'; + +const resolveURL = (url) => url ? data.url(url) : url; + +function create(window, details) { + let id = makeID(details.id); + let { document } = window; + + if (document.getElementById(id)) + throw new Error('The ID "' + details.id + '" seems already used.'); + + let menuitem = document.createElementNS(XUL_NS, 'menuitem'); + menuitem.setAttribute('id', id); + menuitem.setAttribute('label', details.title); + menuitem.setAttribute('sidebarurl', resolveURL(details.sidebarurl)); + menuitem.setAttribute('checked', 'false'); + menuitem.setAttribute('type', 'checkbox'); + menuitem.setAttribute('group', 'sidebar'); + menuitem.setAttribute('autoCheck', 'false'); + + document.getElementById('viewSidebarMenu').appendChild(menuitem); + + return menuitem; +} +exports.create = create; + +function dispose(menuitem) { + menuitem.parentNode.removeChild(menuitem); +} +exports.dispose = dispose; + +function updateTitle(sidebar, title) { + let button = buttons.get(sidebar); + + for (let window of windows(null, { includePrivate: true })) { + let { document } = window; + + // update the button + if (button) { + setStateFor(button, window, { label: title }); + } + + // update the menuitem + let mi = document.getElementById(makeID(sidebar.id)); + if (mi) { + mi.setAttribute('label', title) + } + + // update sidebar, if showing + if (isSidebarShowing(window, sidebar)) { + document.getElementById('sidebar-title').setAttribute('value', title); + } + } +} +exports.updateTitle = updateTitle; + +function updateURL(sidebar, url) { + let eleID = makeID(sidebar.id); + + url = resolveURL(url); + + for (let window of windows(null, { includePrivate: true })) { + // update the menuitem + let mi = window.document.getElementById(eleID); + if (mi) { + mi.setAttribute('sidebarurl', url) + } + + // update sidebar, if showing + if (isSidebarShowing(window, sidebar)) { + showSidebar(window, sidebar, url); + } + } +} +exports.updateURL = updateURL; + +function isSidebarShowing(window, sidebar) { + let win = window || getMostRecentBrowserWindow(); + + // make sure there is a window + if (!win) { + return false; + } + + // make sure there is a sidebar for the window + let sb = win.document.getElementById('sidebar'); + let sidebarTitle = win.document.getElementById('sidebar-title'); + if (!(sb && sidebarTitle)) { + return false; + } + + // checks if the sidebar box is hidden + let sbb = win.document.getElementById('sidebar-box'); + if (!sbb || sbb.hidden) { + return false; + } + + if (sidebarTitle.value == modelFor(sidebar).title) { + let url = resolveURL(modelFor(sidebar).url); + + // checks if the sidebar is loading + if (win.gWebPanelURI == url) { + return true; + } + + // checks if the sidebar loaded already + let ele = sb.contentDocument && sb.contentDocument.getElementById(WEB_PANEL_BROWSER_ID); + if (!ele) { + return false; + } + + if (ele.getAttribute('cachedurl') == url) { + return true; + } + + if (ele && ele.contentWindow && ele.contentWindow.location == url) { + return true; + } + } + + // default + return false; +} +exports.isSidebarShowing = isSidebarShowing; + +function showSidebar(window, sidebar, newURL) { + window = window || getMostRecentBrowserWindow(); + + let { promise, resolve, reject } = defer(); + let model = modelFor(sidebar); + + if (!newURL && isSidebarShowing(window, sidebar)) { + resolve({}); + } + else if (!isPrivateBrowsingSupported && isWindowPrivate(window)) { + reject(Error('You cannot show a sidebar on private windows')); + } + else { + sidebar.once('show', resolve); + + let menuitem = window.document.getElementById(makeID(model.id)); + menuitem.setAttribute('checked', true); + + window.openWebPanel(model.title, resolveURL(newURL || model.url)); + } + + return promise; +} +exports.showSidebar = showSidebar; + + +function hideSidebar(window, sidebar) { + window = window || getMostRecentBrowserWindow(); + + let { promise, resolve, reject } = defer(); + + if (!isSidebarShowing(window, sidebar)) { + reject(Error('The sidebar is already hidden')); + } + else { + sidebar.once('hide', resolve); + + // Below was taken from http://mxr.mozilla.org/mozilla-central/source/browser/base/content/browser.js#4775 + // the code for window.todggleSideBar().. + let { document } = window; + let sidebarEle = document.getElementById('sidebar'); + let sidebarTitle = document.getElementById('sidebar-title'); + let sidebarBox = document.getElementById('sidebar-box'); + let sidebarSplitter = document.getElementById('sidebar-splitter'); + let commandID = sidebarBox.getAttribute('sidebarcommand'); + let sidebarBroadcaster = document.getElementById(commandID); + + sidebarBox.hidden = true; + sidebarSplitter.hidden = true; + + sidebarEle.setAttribute('src', 'about:blank'); + //sidebarEle.docShell.createAboutBlankContentViewer(null); + + sidebarBroadcaster.removeAttribute('checked'); + sidebarBox.setAttribute('sidebarcommand', ''); + sidebarTitle.value = ''; + sidebarBox.hidden = true; + sidebarSplitter.hidden = true; + + // TODO: perhaps this isn't necessary if the window is not most recent? + window.gBrowser.selectedBrowser.focus(); + } + + return promise; +} +exports.hideSidebar = hideSidebar; + +function makeID(id) { + return 'jetpack-sidebar-' + id; +} diff --git a/addon-sdk/source/lib/sdk/ui/state.js b/addon-sdk/source/lib/sdk/ui/state.js new file mode 100644 index 000000000..152ce696d --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/state.js @@ -0,0 +1,239 @@ +/* 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'; + +// The Button module currently supports only Firefox. +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=jetpack-panel-apps +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '*', + 'SeaMonkey': '*', + 'Thunderbird': '*' + } +}; + +const { Ci } = require('chrome'); + +const events = require('../event/utils'); +const { events: browserEvents } = require('../browser/events'); +const { events: tabEvents } = require('../tab/events'); +const { events: stateEvents } = require('./state/events'); + +const { windows, isInteractive, getFocusedBrowser } = require('../window/utils'); +const { getActiveTab, getOwnerWindow } = require('../tabs/utils'); + +const { ignoreWindow } = require('../private-browsing/utils'); + +const { freeze } = Object; +const { merge } = require('../util/object'); +const { on, off, emit } = require('../event/core'); + +const { add, remove, has, clear, iterator } = require('../lang/weak-set'); +const { isNil } = require('../lang/type'); + +const { viewFor } = require('../view/core'); + +const components = new WeakMap(); + +const ERR_UNREGISTERED = 'The state cannot be set or get. ' + + 'The object may be not be registered, or may already have been unloaded.'; + +const ERR_INVALID_TARGET = 'The state cannot be set or get for this target.' + + 'Only window, tab and registered component are valid targets.'; + +const isWindow = thing => thing instanceof Ci.nsIDOMWindow; +const isTab = thing => thing.tagName && thing.tagName.toLowerCase() === 'tab'; +const isActiveTab = thing => isTab(thing) && thing === getActiveTab(getOwnerWindow(thing)); +const isEnumerable = window => !ignoreWindow(window); +const browsers = _ => + windows('navigator:browser', { includePrivate: true }).filter(isInteractive); +const getMostRecentTab = _ => getActiveTab(getFocusedBrowser()); + +function getStateFor(component, target) { + if (!isRegistered(component)) + throw new Error(ERR_UNREGISTERED); + + if (!components.has(component)) + return null; + + let states = components.get(component); + + if (target) { + if (isTab(target) || isWindow(target) || target === component) + return states.get(target) || null; + else + throw new Error(ERR_INVALID_TARGET); + } + + return null; +} +exports.getStateFor = getStateFor; + +function getDerivedStateFor(component, target) { + if (!isRegistered(component)) + throw new Error(ERR_UNREGISTERED); + + if (!components.has(component)) + return null; + + let states = components.get(component); + + let componentState = states.get(component); + let windowState = null; + let tabState = null; + + if (target) { + // has a target + if (isTab(target)) { + windowState = states.get(getOwnerWindow(target), null); + + if (states.has(target)) { + // we have a tab state + tabState = states.get(target); + } + } + else if (isWindow(target) && states.has(target)) { + // we have a window state + windowState = states.get(target); + } + } + + return freeze(merge({}, componentState, windowState, tabState)); +} +exports.getDerivedStateFor = getDerivedStateFor; + +function setStateFor(component, target, state) { + if (!isRegistered(component)) + throw new Error(ERR_UNREGISTERED); + + let isComponentState = target === component; + let targetWindows = isWindow(target) ? [target] : + isActiveTab(target) ? [getOwnerWindow(target)] : + isComponentState ? browsers() : + isTab(target) ? [] : + null; + + if (!targetWindows) + throw new Error(ERR_INVALID_TARGET); + + // initialize the state's map + if (!components.has(component)) + components.set(component, new WeakMap()); + + let states = components.get(component); + + if (state === null && !isComponentState) // component state can't be deleted + states.delete(target); + else { + let base = isComponentState ? states.get(target) : null; + states.set(target, freeze(merge({}, base, state))); + } + + render(component, targetWindows); +} +exports.setStateFor = setStateFor; + +function render(component, targetWindows) { + targetWindows = targetWindows ? [].concat(targetWindows) : browsers(); + + for (let window of targetWindows.filter(isEnumerable)) { + let tabState = getDerivedStateFor(component, getActiveTab(window)); + + emit(stateEvents, 'data', { + type: 'render', + target: component, + window: window, + state: tabState + }); + + } +} +exports.render = render; + +function properties(contract) { + let { rules } = contract; + let descriptor = Object.keys(rules).reduce(function(descriptor, name) { + descriptor[name] = { + get: function() { return getDerivedStateFor(this)[name] }, + set: function(value) { + let changed = {}; + changed[name] = value; + + setStateFor(this, this, contract(changed)); + } + } + return descriptor; + }, {}); + + return Object.create(Object.prototype, descriptor); +} +exports.properties = properties; + +function state(contract) { + return { + state: function state(target, state) { + let nativeTarget = target === 'window' ? getFocusedBrowser() + : target === 'tab' ? getMostRecentTab() + : target === this ? null + : viewFor(target); + + if (!nativeTarget && target !== this && !isNil(target)) + throw new Error(ERR_INVALID_TARGET); + + target = nativeTarget || target; + + // jquery style + return arguments.length < 2 + ? getDerivedStateFor(this, target) + : setStateFor(this, target, contract(state)) + } + } +} +exports.state = state; + +const register = (component, state) => { + add(components, component); + setStateFor(component, component, state); +} +exports.register = register; + +const unregister = component => { + remove(components, component); +} +exports.unregister = unregister; + +const isRegistered = component => has(components, component); +exports.isRegistered = isRegistered; + +var tabSelect = events.filter(tabEvents, e => e.type === 'TabSelect'); +var tabClose = events.filter(tabEvents, e => e.type === 'TabClose'); +var windowOpen = events.filter(browserEvents, e => e.type === 'load'); +var windowClose = events.filter(browserEvents, e => e.type === 'close'); + +var close = events.merge([tabClose, windowClose]); +var activate = events.merge([windowOpen, tabSelect]); + +on(activate, 'data', ({target}) => { + let [window, tab] = isWindow(target) + ? [target, getActiveTab(target)] + : [getOwnerWindow(target), target]; + + if (ignoreWindow(window)) return; + + for (let component of iterator(components)) { + emit(stateEvents, 'data', { + type: 'render', + target: component, + window: window, + state: getDerivedStateFor(component, tab) + }); + } +}); + +on(close, 'data', function({target}) { + for (let component of iterator(components)) { + components.get(component).delete(target); + } +}); diff --git a/addon-sdk/source/lib/sdk/ui/state/events.js b/addon-sdk/source/lib/sdk/ui/state/events.js new file mode 100644 index 000000000..98909656a --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/state/events.js @@ -0,0 +1,18 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '*', + 'SeaMonkey': '*', + 'Thunderbird': '*' + } +}; + +var channel = {}; + +exports.events = channel; diff --git a/addon-sdk/source/lib/sdk/ui/toolbar.js b/addon-sdk/source/lib/sdk/ui/toolbar.js new file mode 100644 index 000000000..c1becab2d --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/toolbar.js @@ -0,0 +1,16 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +const { Toolbar } = require("./toolbar/model"); +require("./toolbar/view"); + +exports.Toolbar = Toolbar; diff --git a/addon-sdk/source/lib/sdk/ui/toolbar/model.js b/addon-sdk/source/lib/sdk/ui/toolbar/model.js new file mode 100644 index 000000000..5c5428606 --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/toolbar/model.js @@ -0,0 +1,151 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +const { Class } = require("../../core/heritage"); +const { EventTarget } = require("../../event/target"); +const { off, setListeners, emit } = require("../../event/core"); +const { Reactor, foldp, merges, send } = require("../../event/utils"); +const { Disposable } = require("../../core/disposable"); +const { InputPort } = require("../../input/system"); +const { OutputPort } = require("../../output/system"); +const { identify } = require("../id"); +const { pairs, object, map, each } = require("../../util/sequence"); +const { patch, diff } = require("diffpatcher/index"); +const { contract } = require("../../util/contract"); +const { id: addonID } = require("../../self"); + +// Input state is accumulated from the input received form the toolbar +// view code & local output. Merging local output reflects local state +// changes without complete roundloop. +const input = foldp(patch, {}, new InputPort({ id: "toolbar-changed" })); +const output = new OutputPort({ id: "toolbar-change" }); + +// Takes toolbar title and normalizes is to an +// identifier, also prefixes with add-on id. +const titleToId = title => + ("toolbar-" + addonID + "-" + title). + toLowerCase(). + replace(/\s/g, "-"). + replace(/[^A-Za-z0-9_\-]/g, ""); + +const validate = contract({ + title: { + is: ["string"], + ok: x => x.length > 0, + msg: "The `option.title` string must be provided" + }, + items: { + is:["undefined", "object", "array"], + msg: "The `options.items` must be iterable sequence of items" + }, + hidden: { + is: ["boolean", "undefined"], + msg: "The `options.hidden` must be boolean" + } +}); + +// Toolbars is a mapping between `toolbar.id` & `toolbar` instances, +// which is used to find intstance for dispatching events. +var toolbars = new Map(); + +const Toolbar = Class({ + extends: EventTarget, + implements: [Disposable], + initialize: function(params={}) { + const options = validate(params); + const id = titleToId(options.title); + + if (toolbars.has(id)) + throw Error("Toolbar with this id already exists: " + id); + + // Set of the items in the toolbar isn't mutable, as a matter of fact + // it just defines desired set of items, actual set is under users + // control. Conver test to an array and freeze to make sure users won't + // try mess with it. + const items = Object.freeze(options.items ? [...options.items] : []); + + const initial = { + id: id, + title: options.title, + // By default toolbars are visible when add-on is installed, unless + // add-on authors decides it should be hidden. From that point on + // user is in control. + collapsed: !!options.hidden, + // In terms of state only identifiers of items matter. + items: items.map(identify) + }; + + this.id = id; + this.items = items; + + toolbars.set(id, this); + setListeners(this, params); + + // Send initial state to the host so it can reflect it + // into a user interface. + send(output, object([id, initial])); + }, + + get title() { + const state = reactor.value[this.id]; + return state && state.title; + }, + get hidden() { + const state = reactor.value[this.id]; + return state && state.collapsed; + }, + + destroy: function() { + send(output, object([this.id, null])); + }, + // `JSON.stringify` serializes objects based of the return + // value of this method. For convinienc we provide this method + // to serialize actual state data. Note: items will also be + // serialized so they should probably implement `toJSON`. + toJSON: function() { + return { + id: this.id, + title: this.title, + hidden: this.hidden, + items: this.items + }; + } +}); +exports.Toolbar = Toolbar; +identify.define(Toolbar, toolbar => toolbar.id); + +const dispose = toolbar => { + toolbars.delete(toolbar.id); + emit(toolbar, "detach"); + off(toolbar); +}; + +const reactor = new Reactor({ + onStep: (present, past) => { + const delta = diff(past, present); + + each(([id, update]) => { + const toolbar = toolbars.get(id); + + // Remove + if (!update) + dispose(toolbar); + // Add + else if (!past[id]) + emit(toolbar, "attach"); + // Update + else + emit(toolbar, update.collapsed ? "hide" : "show", toolbar); + }, pairs(delta)); + } +}); +reactor.run(input); diff --git a/addon-sdk/source/lib/sdk/ui/toolbar/view.js b/addon-sdk/source/lib/sdk/ui/toolbar/view.js new file mode 100644 index 000000000..4ef0c3d46 --- /dev/null +++ b/addon-sdk/source/lib/sdk/ui/toolbar/view.js @@ -0,0 +1,248 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +const { Cu } = require("chrome"); +const { CustomizableUI } = Cu.import('resource:///modules/CustomizableUI.jsm', {}); +const { subscribe, send, Reactor, foldp, lift, merges } = require("../../event/utils"); +const { InputPort } = require("../../input/system"); +const { OutputPort } = require("../../output/system"); +const { Interactive } = require("../../input/browser"); +const { CustomizationInput } = require("../../input/customizable-ui"); +const { pairs, map, isEmpty, object, + each, keys, values } = require("../../util/sequence"); +const { curry, flip } = require("../../lang/functional"); +const { patch, diff } = require("diffpatcher/index"); +const prefs = require("../../preferences/service"); +const { getByOuterId } = require("../../window/utils"); +const { ignoreWindow } = require('../../private-browsing/utils'); + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const PREF_ROOT = "extensions.sdk-toolbar-collapsed."; + + +// There are two output ports one for publishing changes that occured +// and the other for change requests. Later is synchronous and is only +// consumed here. Note: it needs to be synchronous to avoid race conditions +// when `collapsed` attribute changes are caused by user interaction and +// toolbar is destroyed between the ticks. +const output = new OutputPort({ id: "toolbar-changed" }); +const syncoutput = new OutputPort({ id: "toolbar-change", sync: true }); + +// Merge disptached changes and recevied changes from models to keep state up to +// date. +const Toolbars = foldp(patch, {}, merges([new InputPort({ id: "toolbar-changed" }), + new InputPort({ id: "toolbar-change" })])); +const State = lift((toolbars, windows, customizable) => + ({windows: windows, toolbars: toolbars, customizable: customizable}), + Toolbars, Interactive, new CustomizationInput()); + +// Shared event handler that makes `event.target.parent` collapsed. +// Used as toolbar's close buttons click handler. +const collapseToolbar = event => { + const toolbar = event.target.parentNode; + toolbar.collapsed = true; +}; + +const parseAttribute = x => + x === "true" ? true : + x === "false" ? false : + x === "" ? null : + x; + +// Shared mutation observer that is used to observe `toolbar` node's +// attribute mutations. Mutations are aggregated in the `delta` hash +// and send to `ToolbarStateChanged` channel to let model know state +// has changed. +const attributesChanged = mutations => { + const delta = mutations.reduce((changes, {attributeName, target}) => { + const id = target.id; + const field = attributeName === "toolbarname" ? "title" : attributeName; + let change = changes[id] || (changes[id] = {}); + change[field] = parseAttribute(target.getAttribute(attributeName)); + return changes; + }, {}); + + // Calculate what are the updates from the current state and if there are + // any send them. + const updates = diff(reactor.value, patch(reactor.value, delta)); + + if (!isEmpty(pairs(updates))) { + // TODO: Consider sending sync to make sure that there won't be a new + // update doing a delete in the meantime. + send(syncoutput, updates); + } +}; + + +// Utility function creates `toolbar` with a "close" button and returns +// it back. In addition it set's up a listener and observer to communicate +// state changes. +const addView = curry((options, {document, window}) => { + if (ignoreWindow(window)) + return; + + let view = document.createElementNS(XUL_NS, "toolbar"); + view.setAttribute("id", options.id); + view.setAttribute("collapsed", options.collapsed); + view.setAttribute("toolbarname", options.title); + view.setAttribute("pack", "end"); + view.setAttribute("customizable", "false"); + view.setAttribute("style", "padding: 2px 0; max-height: 40px;"); + view.setAttribute("mode", "icons"); + view.setAttribute("iconsize", "small"); + view.setAttribute("context", "toolbar-context-menu"); + view.setAttribute("class", "chromeclass-toolbar"); + + let label = document.createElementNS(XUL_NS, "label"); + label.setAttribute("value", options.title); + label.setAttribute("collapsed", "true"); + view.appendChild(label); + + let closeButton = document.createElementNS(XUL_NS, "toolbarbutton"); + closeButton.setAttribute("id", "close-" + options.id); + closeButton.setAttribute("class", "close-icon"); + closeButton.setAttribute("customizable", false); + closeButton.addEventListener("command", collapseToolbar); + + view.appendChild(closeButton); + + // In order to have a close button not costumizable, aligned on the right, + // leaving the customizable capabilities of Australis, we need to create + // a toolbar inside a toolbar. + // This is should be a temporary hack, we should have a proper XBL for toolbar + // instead. See: + // https://bugzilla.mozilla.org/show_bug.cgi?id=982005 + let toolbar = document.createElementNS(XUL_NS, "toolbar"); + toolbar.setAttribute("id", "inner-" + options.id); + toolbar.setAttribute("defaultset", options.items.join(",")); + toolbar.setAttribute("customizable", "true"); + toolbar.setAttribute("style", "-moz-appearance: none; overflow: hidden"); + toolbar.setAttribute("mode", "icons"); + toolbar.setAttribute("iconsize", "small"); + toolbar.setAttribute("context", "toolbar-context-menu"); + toolbar.setAttribute("flex", "1"); + + view.insertBefore(toolbar, closeButton); + + const observer = new document.defaultView.MutationObserver(attributesChanged); + observer.observe(view, { attributes: true, + attributeFilter: ["collapsed", "toolbarname"] }); + + const toolbox = document.getElementById("navigator-toolbox"); + toolbox.appendChild(view); +}); +const viewAdd = curry(flip(addView)); + +const removeView = curry((id, {document}) => { + const view = document.getElementById(id); + if (view) view.remove(); +}); + +const updateView = curry((id, {title, collapsed, isCustomizing}, {document}) => { + const view = document.getElementById(id); + + if (!view) + return; + + if (title) + view.setAttribute("toolbarname", title); + + if (collapsed !== void(0)) + view.setAttribute("collapsed", Boolean(collapsed)); + + if (isCustomizing !== void(0)) { + view.querySelector("label").collapsed = !isCustomizing; + view.querySelector("toolbar").style.visibility = isCustomizing + ? "hidden" : "visible"; + } +}); + +const viewUpdate = curry(flip(updateView)); + +// Utility function used to register toolbar into CustomizableUI. +const registerToolbar = state => { + // If it's first additon register toolbar as customizableUI component. + CustomizableUI.registerArea("inner-" + state.id, { + type: CustomizableUI.TYPE_TOOLBAR, + legacy: true, + defaultPlacements: [...state.items] + }); +}; +// Utility function used to unregister toolbar from the CustomizableUI. +const unregisterToolbar = CustomizableUI.unregisterArea; + +const reactor = new Reactor({ + onStep: (present, past) => { + const delta = diff(past, present); + + each(([id, update]) => { + // If update is `null` toolbar is removed, in such case + // we unregister toolbar and remove it from each window + // it was added to. + if (update === null) { + unregisterToolbar("inner-" + id); + each(removeView(id), values(past.windows)); + + send(output, object([id, null])); + } + else if (past.toolbars[id]) { + // If `collapsed` state for toolbar was updated, persist + // it for a future sessions. + if (update.collapsed !== void(0)) + prefs.set(PREF_ROOT + id, update.collapsed); + + // Reflect update in each window it was added to. + each(updateView(id, update), values(past.windows)); + + send(output, object([id, update])); + } + // Hack: Mutation observers are invoked async, which means that if + // client does `hide(toolbar)` & then `toolbar.destroy()` by the + // time we'll get update for `collapsed` toolbar will be removed. + // For now we check if `update.id` is present which will be undefined + // in such cases. + else if (update.id) { + // If it is a new toolbar we create initial state by overriding + // `collapsed` filed with value persisted in previous sessions. + const state = patch(update, { + collapsed: prefs.get(PREF_ROOT + id, update.collapsed), + }); + + // Register toolbar and add it each window known in the past + // (note that new windows if any will be handled in loop below). + registerToolbar(state); + each(addView(state), values(past.windows)); + + send(output, object([state.id, state])); + } + }, pairs(delta.toolbars)); + + // Add views to every window that was added. + each(window => { + if (window) + each(viewAdd(window), values(past.toolbars)); + }, values(delta.windows)); + + each(([id, isCustomizing]) => { + each(viewUpdate(getByOuterId(id), {isCustomizing: !!isCustomizing}), + keys(present.toolbars)); + + }, pairs(delta.customizable)) + }, + onEnd: state => { + each(id => { + unregisterToolbar("inner-" + id); + each(removeView(id), values(state.windows)); + }, keys(state.toolbars)); + } +}); +reactor.run(State); diff --git a/addon-sdk/source/lib/sdk/uri/resource.js b/addon-sdk/source/lib/sdk/uri/resource.js new file mode 100644 index 000000000..8a1dcbf2c --- /dev/null +++ b/addon-sdk/source/lib/sdk/uri/resource.js @@ -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"; + +module.metadata = { + "stability": "unstable" +}; + +const {Cc, Ci} = require("chrome"); +const ioService = Cc["@mozilla.org/network/io-service;1"]. + getService(Ci.nsIIOService); +const resourceHandler = ioService.getProtocolHandler("resource"). + QueryInterface(Ci.nsIResProtocolHandler); + +const URI = (uri, base=null) => + ioService.newURI(uri, null, base && URI(base)) + +const mount = (domain, uri) => + resourceHandler.setSubstitution(domain, ioService.newURI(uri, null, null)); +exports.mount = mount; + +const unmount = (domain, uri) => + resourceHandler.setSubstitution(domain, null); +exports.unmount = unmount; + +const domain = 1; +const path = 2; +const resolve = (uri) => { + const match = /resource\:\/\/([^\/]+)\/{0,1}([\s\S]*)/.exec(uri); + const domain = match && match[1]; + const path = match && match[2]; + return !match ? null : + !resourceHandler.hasSubstitution(domain) ? null : + resourceHandler.resolveURI(URI(`/${path}`, `resource://${domain}/`)); +} +exports.resolve = resolve; diff --git a/addon-sdk/source/lib/sdk/url.js b/addon-sdk/source/lib/sdk/url.js new file mode 100644 index 000000000..ae16ac4a8 --- /dev/null +++ b/addon-sdk/source/lib/sdk/url.js @@ -0,0 +1,349 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Cc, Ci, Cr, Cu } = require("chrome"); + +const { Class } = require("./core/heritage"); +const base64 = require("./base64"); +var tlds = Cc["@mozilla.org/network/effective-tld-service;1"] + .getService(Ci.nsIEffectiveTLDService); + +var ios = Cc['@mozilla.org/network/io-service;1'] + .getService(Ci.nsIIOService); + +var resProt = ios.getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + +var URLParser = Cc["@mozilla.org/network/url-parser;1?auth=no"] + .getService(Ci.nsIURLParser); + +const { Services } = Cu.import("resource://gre/modules/Services.jsm"); + +function newURI(uriStr, base) { + try { + let baseURI = base ? ios.newURI(base, null, null) : null; + return ios.newURI(uriStr, null, baseURI); + } + catch (e) { + if (e.result == Cr.NS_ERROR_MALFORMED_URI) { + throw new Error("malformed URI: " + uriStr); + } + if (e.result == Cr.NS_ERROR_FAILURE || + e.result == Cr.NS_ERROR_ILLEGAL_VALUE) { + throw new Error("invalid URI: " + uriStr); + } + } +} + +function resolveResourceURI(uri) { + var resolved; + try { + resolved = resProt.resolveURI(uri); + } + catch (e) { + if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) { + throw new Error("resource does not exist: " + uri.spec); + } + } + return resolved; +} + +var fromFilename = exports.fromFilename = function fromFilename(path) { + var file = Cc['@mozilla.org/file/local;1'] + .createInstance(Ci.nsILocalFile); + file.initWithPath(path); + return ios.newFileURI(file).spec; +}; + +var toFilename = exports.toFilename = function toFilename(url) { + var uri = newURI(url); + if (uri.scheme == "resource") + uri = newURI(resolveResourceURI(uri)); + if (uri.scheme == "chrome") { + var channel = ios.newChannelFromURI2(uri, + null, // aLoadingNode + Services.scriptSecurityManager.getSystemPrincipal(), + null, // aTriggeringPrincipal + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER); + try { + channel = channel.QueryInterface(Ci.nsIFileChannel); + return channel.file.path; + } + catch (e) { + if (e.result == Cr.NS_NOINTERFACE) { + throw new Error("chrome url isn't on filesystem: " + url); + } + } + } + if (uri.scheme == "file") { + var file = uri.QueryInterface(Ci.nsIFileURL).file; + return file.path; + } + throw new Error("cannot map to filename: " + url); +}; + +function URL(url, base) { + if (!(this instanceof URL)) { + return new URL(url, base); + } + + var uri = newURI(url, base); + + var userPass = null; + try { + userPass = uri.userPass ? uri.userPass : null; + } + catch (e) { + if (e.result != Cr.NS_ERROR_FAILURE) { + throw e; + } + } + + var host = null; + try { + host = uri.host; + } + catch (e) { + if (e.result != Cr.NS_ERROR_FAILURE) { + throw e; + } + } + + var port = null; + try { + port = uri.port == -1 ? null : uri.port; + } + catch (e) { + if (e.result != Cr.NS_ERROR_FAILURE) { + throw e; + } + } + + let fileName = "/"; + try { + fileName = uri.QueryInterface(Ci.nsIURL).fileName; + } catch (e) { + if (e.result != Cr.NS_NOINTERFACE) { + throw e; + } + } + + let uriData = [uri.path, uri.path.length, {}, {}, {}, {}, {}, {}]; + URLParser.parsePath.apply(URLParser, uriData); + let [{ value: filepathPos }, { value: filepathLen }, + { value: queryPos }, { value: queryLen }, + { value: refPos }, { value: refLen }] = uriData.slice(2); + + let hash = uri.ref ? "#" + uri.ref : ""; + let pathname = uri.path.substr(filepathPos, filepathLen); + let search = uri.path.substr(queryPos, queryLen); + search = search ? "?" + search : ""; + + this.__defineGetter__("fileName", () => fileName); + this.__defineGetter__("scheme", () => uri.scheme); + this.__defineGetter__("userPass", () => userPass); + this.__defineGetter__("host", () => host); + this.__defineGetter__("hostname", () => host); + this.__defineGetter__("port", () => port); + this.__defineGetter__("path", () => uri.path); + this.__defineGetter__("pathname", () => pathname); + this.__defineGetter__("hash", () => hash); + this.__defineGetter__("href", () => uri.spec); + this.__defineGetter__("origin", () => uri.prePath); + this.__defineGetter__("protocol", () => uri.scheme + ":"); + this.__defineGetter__("search", () => search); + + Object.defineProperties(this, { + toString: { + value() { + return new String(uri.spec).toString(); + }, + enumerable: false + }, + valueOf: { + value() { + return new String(uri.spec).valueOf(); + }, + enumerable: false + }, + toSource: { + value() { + return new String(uri.spec).toSource(); + }, + enumerable: false + }, + // makes more sense to flatten to string, easier to travel across JSON + toJSON: { + value() { + return new String(uri.spec).toString(); + }, + enumerable: false + } + }); + + return this; +}; + +URL.prototype = Object.create(String.prototype); +exports.URL = URL; + +/** + * Parse and serialize a Data URL. + * + * See: http://tools.ietf.org/html/rfc2397 + * + * Note: Could be extended in the future to decode / encode automatically binary + * data. + */ +const DataURL = Class({ + + get base64 () { + return "base64" in this.parameters; + }, + + set base64 (value) { + if (value) + this.parameters["base64"] = ""; + else + delete this.parameters["base64"]; + }, + /** + * Initialize the Data URL object. If a uri is given, it will be parsed. + * + * @param {String} [uri] The uri to parse + * + * @throws {URIError} if the Data URL is malformed + */ + initialize: function(uri) { + // Due to bug 751834 it is not possible document and define these + // properties in the prototype. + + /** + * An hashmap that contains the parameters of the Data URL. By default is + * empty, that accordingly to RFC is equivalent to {"charset" : "US-ASCII"} + */ + this.parameters = {}; + + /** + * The MIME type of the data. By default is empty, that accordingly to RFC + * is equivalent to "text/plain" + */ + this.mimeType = ""; + + /** + * The string that represent the data in the Data URL + */ + this.data = ""; + + if (typeof uri === "undefined") + return; + + uri = String(uri); + + let matches = uri.match(/^data:([^,]*),(.*)$/i); + + if (!matches) + throw new URIError("Malformed Data URL: " + uri); + + let mediaType = matches[1].trim(); + + this.data = decodeURIComponent(matches[2].trim()); + + if (!mediaType) + return; + + let parametersList = mediaType.split(";"); + + this.mimeType = parametersList.shift().trim(); + + for (let parameter, i = 0; parameter = parametersList[i++];) { + let pairs = parameter.split("="); + let name = pairs[0].trim(); + let value = pairs.length > 1 ? decodeURIComponent(pairs[1].trim()) : ""; + + this.parameters[name] = value; + } + + if (this.base64) + this.data = base64.decode(this.data); + + }, + + /** + * Returns the object as a valid Data URL string + * + * @returns {String} The Data URL + */ + toString : function() { + let parametersList = []; + + for (let name in this.parameters) { + let encodedParameter = encodeURIComponent(name); + let value = this.parameters[name]; + + if (value) + encodedParameter += "=" + encodeURIComponent(value); + + parametersList.push(encodedParameter); + } + + // If there is at least a parameter, add an empty string in order + // to start with a `;` on join call. + if (parametersList.length > 0) + parametersList.unshift(""); + + let data = this.base64 ? base64.encode(this.data) : this.data; + + return "data:" + + this.mimeType + + parametersList.join(";") + "," + + encodeURIComponent(data); + } +}); + +exports.DataURL = DataURL; + +var getTLD = exports.getTLD = function getTLD (url) { + let uri = newURI(url.toString()); + let tld = null; + try { + tld = tlds.getPublicSuffix(uri); + } + catch (e) { + if (e.result != Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS && + e.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS) { + throw e; + } + } + return tld; +}; + +var isValidURI = exports.isValidURI = function (uri) { + try { + newURI(uri); + } + catch(e) { + return false; + } + return true; +} + +function isLocalURL(url) { + if (String.indexOf(url, './') === 0) + return true; + + try { + return ['resource', 'data', 'chrome'].indexOf(URL(url).scheme) > -1; + } + catch(e) {} + + return false; +} +exports.isLocalURL = isLocalURL; diff --git a/addon-sdk/source/lib/sdk/url/utils.js b/addon-sdk/source/lib/sdk/url/utils.js new file mode 100644 index 000000000..aa5759204 --- /dev/null +++ b/addon-sdk/source/lib/sdk/url/utils.js @@ -0,0 +1,29 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { Cc, Ci, Cr } = require("chrome"); +const IOService = Cc["@mozilla.org/network/io-service;1"]. + getService(Ci.nsIIOService); +const { isValidURI } = require("../url"); +const { method } = require("../../method/core"); + +function newURI (uri) { + if (!isValidURI(uri)) + throw new Error("malformed URI: " + uri); + return IOService.newURI(uri, null, null); +} +exports.newURI = newURI; + +var getURL = method('sdk/url:getURL'); +getURL.define(String, url => url); +getURL.define(function (object) { + return null; +}); +exports.getURL = getURL; diff --git a/addon-sdk/source/lib/sdk/util/array.js b/addon-sdk/source/lib/sdk/util/array.js new file mode 100644 index 000000000..1d61a973e --- /dev/null +++ b/addon-sdk/source/lib/sdk/util/array.js @@ -0,0 +1,123 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +/** + * Returns `true` if given `array` contain given `element` or `false` + * otherwise. + * @param {Array} array + * Target array. + * @param {Object|String|Number|Boolean} element + * Element being looked up. + * @returns {Boolean} + */ +var has = exports.has = function has(array, element) { + // shorter and faster equivalent of `array.indexOf(element) >= 0` + return !!~array.indexOf(element); +}; +var hasAny = exports.hasAny = function hasAny(array, elements) { + if (arguments.length < 2) + return false; + if (!Array.isArray(elements)) + elements = [ elements ]; + return array.some(function (element) { + return has(elements, element); + }); +}; + +/** + * Adds given `element` to the given `array` if it does not contain it yet. + * `true` is returned if element was added otherwise `false` is returned. + * @param {Array} array + * Target array. + * @param {Object|String|Number|Boolean} element + * Element to be added. + * @returns {Boolean} + */ +var add = exports.add = function add(array, element) { + var result; + if ((result = !has(array, element))) + array.push(element); + + return result; +}; + +/** + * Removes first occurrence of the given `element` from the given `array`. If + * `array` does not contain given `element` `false` is returned otherwise + * `true` is returned. + * @param {Array} array + * Target array. + * @param {Object|String|Number|Boolean} element + * Element to be removed. + * @returns {Boolean} + */ +exports.remove = function remove(array, element) { + var result; + if ((result = has(array, element))) + array.splice(array.indexOf(element), 1); + + return result; +}; + +/** + * Produces a duplicate-free version of the given `array`. + * @param {Array} array + * Source array. + * @returns {Array} + */ +function unique(array) { + return array.reduce(function(result, item) { + add(result, item); + return result; + }, []); +}; +exports.unique = unique; + +/** + * Produce an array that contains the union: each distinct element from all + * of the passed-in arrays. + */ +function union() { + return unique(Array.concat.apply(null, arguments)); +}; +exports.union = union; + +exports.flatten = function flatten(array){ + var flat = []; + for (var i = 0, l = array.length; i < l; i++) { + flat = flat.concat(Array.isArray(array[i]) ? flatten(array[i]) : array[i]); + } + return flat; +}; + +function fromIterator(iterator) { + let array = []; + if (iterator.__iterator__) { + for (let item of iterator) + array.push(item); + } + else { + for (let item of iterator) + array.push(item); + } + return array; +} +exports.fromIterator = fromIterator; + +function find(array, predicate, fallback) { + var index = 0; + var count = array.length; + while (index < count) { + var value = array[index]; + if (predicate(value)) return value; + else index = index + 1; + } + return fallback; +} +exports.find = find; diff --git a/addon-sdk/source/lib/sdk/util/collection.js b/addon-sdk/source/lib/sdk/util/collection.js new file mode 100644 index 000000000..194a29470 --- /dev/null +++ b/addon-sdk/source/lib/sdk/util/collection.js @@ -0,0 +1,115 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +exports.Collection = Collection; + +/** + * Adds a collection property to the given object. Setting the property to a + * scalar value empties the collection and adds the value. Setting it to an + * array empties the collection and adds all the items in the array. + * + * @param obj + * The property will be defined on this object. + * @param propName + * The name of the property. + * @param array + * If given, this will be used as the collection's backing array. + */ +exports.addCollectionProperty = function addCollProperty(obj, propName, array) { + array = array || []; + let publicIface = new Collection(array); + + Object.defineProperty(obj, propName, { + configurable: true, + enumerable: true, + + set: function set(itemOrItems) { + array.splice(0, array.length); + publicIface.add(itemOrItems); + }, + + get: function get() { + return publicIface; + } + }); +}; + +/** + * A collection is ordered, like an array, but its items are unique, like a set. + * + * @param array + * The collection is backed by an array. If this is given, it will be + * used as the backing array. This way the caller can fully control the + * collection. Otherwise a new empty array will be used, and no one but + * the collection will have access to it. + */ +function Collection(array) { + array = array || []; + + /** + * Provides iteration over the collection. Items are yielded in the order + * they were added. + */ + this.__iterator__ = function Collection___iterator__() { + let items = array.slice(); + for (let i = 0; i < items.length; i++) + yield items[i]; + }; + + /** + * The number of items in the collection. + */ + this.__defineGetter__("length", function Collection_get_length() { + return array.length; + }); + + /** + * Adds a single item or an array of items to the collection. Any items + * already contained in the collection are ignored. + * + * @param itemOrItems + * An item or array of items. + * @return The collection. + */ + this.add = function Collection_add(itemOrItems) { + let items = toArray(itemOrItems); + for (let i = 0; i < items.length; i++) { + let item = items[i]; + if (array.indexOf(item) < 0) + array.push(item); + } + return this; + }; + + /** + * Removes a single item or an array of items from the collection. Any items + * not contained in the collection are ignored. + * + * @param itemOrItems + * An item or array of items. + * @return The collection. + */ + this.remove = function Collection_remove(itemOrItems) { + let items = toArray(itemOrItems); + for (let i = 0; i < items.length; i++) { + let idx = array.indexOf(items[i]); + if (idx >= 0) + array.splice(idx, 1); + } + return this; + }; +}; + +function toArray(itemOrItems) { + let isArr = itemOrItems && + itemOrItems.constructor && + itemOrItems.constructor.name === "Array"; + return isArr ? itemOrItems : [itemOrItems]; +} diff --git a/addon-sdk/source/lib/sdk/util/contract.js b/addon-sdk/source/lib/sdk/util/contract.js new file mode 100644 index 000000000..c689ea601 --- /dev/null +++ b/addon-sdk/source/lib/sdk/util/contract.js @@ -0,0 +1,55 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { validateOptions: valid } = require("../deprecated/api-utils"); +const method = require("method/core"); + +// Function takes property validation rules and returns function that given +// an `options` object will return validated / normalized options back. If +// option(s) are invalid validator will throw exception described by rules. +// Returned will also have contain `rules` property with a given validation +// rules and `properties` function that can be used to generate validated +// property getter and setters can be mixed into prototype. For more details +// see `properties` function below. +function contract(rules) { + const validator = (instance, options) => { + return valid(options || instance || {}, rules); + }; + validator.rules = rules + validator.properties = function(modelFor) { + return properties(modelFor, rules); + } + return validator; +} +exports.contract = contract + +// Function takes `modelFor` instance state model accessor functions and +// a property validation rules and generates object with getters and setters +// that can be mixed into prototype. Property accessors update model for the +// given instance. If you wish to react to property updates you can always +// override setters to put specific logic. +function properties(modelFor, rules) { + let descriptor = Object.keys(rules).reduce(function(descriptor, name) { + descriptor[name] = { + get: function() { return modelFor(this)[name] }, + set: function(value) { + let change = {}; + change[name] = value; + modelFor(this)[name] = valid(change, rules)[name]; + } + } + return descriptor + }, {}); + return Object.create(Object.prototype, descriptor); +} +exports.properties = properties; + +const validate = method("contract/validate"); +exports.validate = validate; diff --git a/addon-sdk/source/lib/sdk/util/deprecate.js b/addon-sdk/source/lib/sdk/util/deprecate.js new file mode 100644 index 000000000..40f236de5 --- /dev/null +++ b/addon-sdk/source/lib/sdk/util/deprecate.js @@ -0,0 +1,40 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +const { get, format } = require("../console/traceback"); +const { get: getPref } = require("../preferences/service"); +const PREFERENCE = "devtools.errorconsole.deprecation_warnings"; + +function deprecateUsage(msg) { + // Print caller stacktrace in order to help figuring out which code + // does use deprecated thing + let stack = get().slice(2); + + if (getPref(PREFERENCE)) + console.error("DEPRECATED: " + msg + "\n" + format(stack)); +} +exports.deprecateUsage = deprecateUsage; + +function deprecateFunction(fun, msg) { + return function deprecated() { + deprecateUsage(msg); + return fun.apply(this, arguments); + }; +} +exports.deprecateFunction = deprecateFunction; + +function deprecateEvent(fun, msg, evtTypes) { + return function deprecateEvent(evtType) { + if (evtTypes.indexOf(evtType) >= 0) + deprecateUsage(msg); + return fun.apply(this, arguments); + }; +} +exports.deprecateEvent = deprecateEvent; diff --git a/addon-sdk/source/lib/sdk/util/dispatcher.js b/addon-sdk/source/lib/sdk/util/dispatcher.js new file mode 100644 index 000000000..67d29dfed --- /dev/null +++ b/addon-sdk/source/lib/sdk/util/dispatcher.js @@ -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"; + +module.metadata = { + "stability": "experimental" +}; + +const method = require("method/core"); + +// Utility function that is just an enhancement over `method` to +// allow predicate based dispatch in addition to polymorphic +// dispatch. Unfortunately polymorphic dispatch does not quite +// cuts it in the world of XPCOM where no types / classes exist +// and all the XUL nodes share same type / prototype. +// Probably this is more generic and belongs some place else, but +// we can move it later once this will be relevant. +var dispatcher = hint => { + const base = method(hint); + // Make a map for storing predicate, implementation mappings. + let implementations = new Map(); + + // Dispatcher function goes through `predicate, implementation` + // pairs to find predicate that matches first argument and + // returns application of arguments on the associated + // `implementation`. If no matching predicate is found delegates + // to a `base` polymorphic function. + let dispatch = (value, ...rest) => { + for (let [predicate, implementation] of implementations) { + if (predicate(value)) + return implementation(value, ...rest); + } + + return base(value, ...rest); + }; + + // Expose base API. + dispatch.define = base.define; + dispatch.implement = base.implement; + dispatch.toString = base.toString; + + // Add a `when` function to allow extending function via + // predicates. + dispatch.when = (predicate, implementation) => { + if (implementations.has(predicate)) + throw TypeError("Already implemented for the given predicate"); + implementations.set(predicate, implementation); + }; + + return dispatch; +}; + +exports.dispatcher = dispatcher; diff --git a/addon-sdk/source/lib/sdk/util/list.js b/addon-sdk/source/lib/sdk/util/list.js new file mode 100644 index 000000000..6d7d2dea9 --- /dev/null +++ b/addon-sdk/source/lib/sdk/util/list.js @@ -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'; + +module.metadata = { + "stability": "experimental" +}; + +const { Class } = require('../core/heritage'); +const listNS = require('../core/namespace').ns(); + +const listOptions = { + /** + * List constructor can take any number of element to populate itself. + * @params {Object|String|Number} element + * @example + * List(1,2,3).length == 3 // true + */ + initialize: function List() { + listNS(this).keyValueMap = []; + + for (let i = 0, ii = arguments.length; i < ii; i++) + addListItem(this, arguments[i]); + }, + /** + * Number of elements in this list. + * @type {Number} + */ + get length() { + return listNS(this).keyValueMap.length; + }, + /** + * Returns a string representing this list. + * @returns {String} + */ + toString: function toString() { + return 'List(' + listNS(this).keyValueMap + ')'; + }, + /** + * Custom iterator providing `List`s enumeration behavior. + * We cant reuse `_iterator` that is defined by `Iterable` since it provides + * iteration in an arbitrary order. + * @see https://developer.mozilla.org/en/JavaScript/Reference/Statements/for...in + * @param {Boolean} onKeys + */ + __iterator__: function __iterator__(onKeys, onKeyValue) { + let array = listNS(this).keyValueMap.slice(0), + i = -1; + for (let element of array) + yield onKeyValue ? [++i, element] : onKeys ? ++i : element; + }, +}; +listOptions[Symbol.iterator] = function iterator() { + return listNS(this).keyValueMap.slice(0)[Symbol.iterator](); +}; +const List = Class(listOptions); +exports.List = List; + +function addListItem(that, value) { + let list = listNS(that).keyValueMap, + index = list.indexOf(value); + + if (-1 === index) { + try { + that[that.length] = value; + } + catch (e) {} + list.push(value); + } +} +exports.addListItem = addListItem; + +function removeListItem(that, element) { + let list = listNS(that).keyValueMap, + index = list.indexOf(element); + + if (0 <= index) { + list.splice(index, 1); + try { + for (let length = list.length; index < length; index++) + that[index] = list[index]; + that[list.length] = undefined; + } + catch(e){} + } +} +exports.removeListItem = removeListItem; + +exports.listNS = listNS; diff --git a/addon-sdk/source/lib/sdk/util/match-pattern.js b/addon-sdk/source/lib/sdk/util/match-pattern.js new file mode 100644 index 000000000..a0eb88b49 --- /dev/null +++ b/addon-sdk/source/lib/sdk/util/match-pattern.js @@ -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"; + +module.metadata = { + "stability": "unstable" +}; + +const { URL } = require('../url'); +const cache = {}; + +function MatchPattern(pattern) { + if (cache[pattern]) return cache[pattern]; + + if (typeof pattern.test == "function") { + // For compatibility with -moz-document rules, we require the RegExp's + // global, ignoreCase, and multiline flags to be set to false. + if (pattern.global) { + throw new Error("A RegExp match pattern cannot be set to `global` " + + "(i.e. //g)."); + } + if (pattern.multiline) { + throw new Error("A RegExp match pattern cannot be set to `multiline` " + + "(i.e. //m)."); + } + + this.regexp = pattern; + } + else { + let firstWildcardPosition = pattern.indexOf("*"); + let lastWildcardPosition = pattern.lastIndexOf("*"); + if (firstWildcardPosition != lastWildcardPosition) + throw new Error("There can be at most one '*' character in a wildcard."); + + if (firstWildcardPosition == 0) { + if (pattern.length == 1) + this.anyWebPage = true; + else if (pattern[1] != ".") + throw new Error("Expected a *.<domain name> string, got: " + pattern); + else + this.domain = pattern.substr(2); + } + else { + if (pattern.indexOf(":") == -1) { + throw new Error("When not using *.example.org wildcard, the string " + + "supplied is expected to be either an exact URL to " + + "match or a URL prefix. The provided string ('" + + pattern + "') is unlikely to match any pages."); + } + + if (firstWildcardPosition == -1) + this.exactURL = pattern; + else if (firstWildcardPosition == pattern.length - 1) + this.urlPrefix = pattern.substr(0, pattern.length - 1); + else { + throw new Error("The provided wildcard ('" + pattern + "') has a '*' " + + "in an unexpected position. It is expected to be the " + + "first or the last character in the wildcard."); + } + } + } + + cache[pattern] = this; +} + +MatchPattern.prototype = { + test: function MatchPattern_test(urlStr) { + try { + var url = URL(urlStr); + } + catch (err) { + return false; + } + + // Test the URL against a RegExp pattern. For compatibility with + // -moz-document rules, we require the RegExp to match the entire URL, + // so we not only test for a match, we also make sure the matched string + // is the entire URL string. + // + // Assuming most URLs don't match most match patterns, we call `test` for + // speed when determining whether or not the URL matches, then call `exec` + // for the small subset that match to make sure the entire URL matches. + if (this.regexp && this.regexp.test(urlStr) && + this.regexp.exec(urlStr)[0] == urlStr) + return true; + + if (this.anyWebPage && /^(https?|ftp)$/.test(url.scheme)) + return true; + + if (this.exactURL && this.exactURL == urlStr) + return true; + + // Tests the urlStr against domain and check if + // wildcard submitted (*.domain.com), it only allows + // subdomains (sub.domain.com) or from the root (http://domain.com) + // and reject non-matching domains (otherdomain.com) + // bug 856913 + if (this.domain && url.host && + (url.host === this.domain || + url.host.slice(-this.domain.length - 1) === "." + this.domain)) + return true; + + if (this.urlPrefix && 0 == urlStr.indexOf(this.urlPrefix)) + return true; + + return false; + }, + + toString: () => '[object MatchPattern]' +}; + +exports.MatchPattern = MatchPattern; diff --git a/addon-sdk/source/lib/sdk/util/object.js b/addon-sdk/source/lib/sdk/util/object.js new file mode 100644 index 000000000..9d202bb51 --- /dev/null +++ b/addon-sdk/source/lib/sdk/util/object.js @@ -0,0 +1,104 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { flatten } = require('./array'); + +/** + * Merges all the properties of all arguments into first argument. If two or + * more argument objects have own properties with the same name, the property + * is overridden, with precedence from right to left, implying, that properties + * of the object on the left are overridden by a same named property of the + * object on the right. + * + * Any argument given with "falsy" value - commonly `null` and `undefined` in + * case of objects - are skipped. + * + * @examples + * var a = { bar: 0, a: 'a' } + * var b = merge(a, { foo: 'foo', bar: 1 }, { foo: 'bar', name: 'b' }); + * b === a // true + * b.a // 'a' + * b.foo // 'bar' + * b.bar // 1 + * b.name // 'b' + */ +function merge(source) { + let descriptor = {}; + + // `Boolean` converts the first parameter to a boolean value. Any object is + // converted to `true` where `null` and `undefined` becames `false`. Therefore + // the `filter` method will keep only objects that are defined and not null. + Array.slice(arguments, 1).filter(Boolean).forEach(function onEach(properties) { + getOwnPropertyIdentifiers(properties).forEach(function(name) { + descriptor[name] = Object.getOwnPropertyDescriptor(properties, name); + }); + }); + return Object.defineProperties(source, descriptor); +} +exports.merge = merge; + +/** + * Returns an object that inherits from the first argument and contains all the + * properties from all following arguments. + * `extend(source1, source2, source3)` is equivalent of + * `merge(Object.create(source1), source2, source3)`. + */ +function extend(source) { + let rest = Array.slice(arguments, 1); + rest.unshift(Object.create(source)); + return merge.apply(null, rest); +} +exports.extend = extend; + +function has(obj, key) { + return obj.hasOwnProperty(key); +} +exports.has = has; + +function each(obj, fn) { + for (let key in obj) has(obj, key) && fn(obj[key], key, obj); +} +exports.each = each; + +/** + * Like `merge`, except no property descriptors are manipulated, for use + * with platform objects. Identical to underscore's `extend`. Useful for + * merging XPCOM objects + */ +function safeMerge(source) { + Array.slice(arguments, 1).forEach(function onEach (obj) { + for (let prop in obj) source[prop] = obj[prop]; + }); + return source; +} +exports.safeMerge = safeMerge; + +/* + * Returns a copy of the object without omitted properties + */ +function omit(source, ...values) { + let copy = {}; + let keys = flatten(values); + for (let prop in source) + if (!~keys.indexOf(prop)) + copy[prop] = source[prop]; + return copy; +} +exports.omit = omit; + +// get object's own property Symbols and/or Names, including nonEnumerables by default +function getOwnPropertyIdentifiers(object, options = { names: true, symbols: true, nonEnumerables: true }) { + const symbols = !options.symbols ? [] : + Object.getOwnPropertySymbols(object); + const names = !options.names ? [] : + options.nonEnumerables ? Object.getOwnPropertyNames(object) : + Object.keys(object); + return [...names, ...symbols]; +} +exports.getOwnPropertyIdentifiers = getOwnPropertyIdentifiers; diff --git a/addon-sdk/source/lib/sdk/util/rules.js b/addon-sdk/source/lib/sdk/util/rules.js new file mode 100644 index 000000000..98e3109b0 --- /dev/null +++ b/addon-sdk/source/lib/sdk/util/rules.js @@ -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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Class } = require('../core/heritage'); +const { MatchPattern } = require('./match-pattern'); +const { emit } = require('../event/core'); +const { EventTarget } = require('../event/target'); +const { List, addListItem, removeListItem } = require('./list'); + +// Should deprecate usage of EventEmitter/compose +const Rules = Class({ + implements: [ + EventTarget, + List + ], + add: function(...rules) { + return [].concat(rules).forEach(function onAdd(rule) { + addListItem(this, rule); + emit(this, 'add', rule); + }, this); + }, + remove: function(...rules) { + return [].concat(rules).forEach(function onRemove(rule) { + removeListItem(this, rule); + emit(this, 'remove', rule); + }, this); + }, + get: function(rule) { + let found = false; + for (let i in this) if (this[i] === rule) found = true; + return found; + }, + // Returns true if uri matches atleast one stored rule + matchesAny: function(uri) { + return !!filterMatches(this, uri).length; + }, + toString: () => '[object Rules]' +}); +exports.Rules = Rules; + +function filterMatches(instance, uri) { + let matches = []; + for (let i in instance) { + if (new MatchPattern(instance[i]).test(uri)) matches.push(instance[i]); + } + return matches; +} diff --git a/addon-sdk/source/lib/sdk/util/sequence.js b/addon-sdk/source/lib/sdk/util/sequence.js new file mode 100644 index 000000000..28e3de255 --- /dev/null +++ b/addon-sdk/source/lib/sdk/util/sequence.js @@ -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/. */ +"use strict"; + +module.metadata = { + "stability": "experimental" +}; + +// Disclamer: +// In this module we'll have some common argument / variable names +// to hint their type or behavior. +// +// - `f` stands for "function" that is intended to be side effect +// free. +// - `p` stands for "predicate" that is function which returns logical +// true or false and is intended to be side effect free. +// - `x` / `y` single item of the sequence. +// - `xs` / `ys` sequence of `x` / `y` items where `x` / `y` signifies +// type of the items in sequence, so sequence is not of the same item. +// - `_` used for argument(s) or variable(s) who's values are ignored. + +const { complement, flip, identity } = require("../lang/functional"); +const { isArray, isArguments, isMap, isSet, isGenerator, + isString, isBoolean, isNumber } = require("../lang/type"); + +const Sequence = function Sequence(iterator) { + if (!isGenerator(iterator)) { + throw TypeError("Expected generator argument"); + } + + this[Symbol.iterator] = iterator; +}; +exports.Sequence = Sequence; + +const polymorphic = dispatch => x => + x === null ? dispatch.null(null) : + x === void(0) ? dispatch.void(void(0)) : + isArray(x) ? (dispatch.array || dispatch.indexed)(x) : + isString(x) ? (dispatch.string || dispatch.indexed)(x) : + isArguments(x) ? (dispatch.arguments || dispatch.indexed)(x) : + isMap(x) ? dispatch.map(x) : + isSet(x) ? dispatch.set(x) : + isNumber(x) ? dispatch.number(x) : + isBoolean(x) ? dispatch.boolean(x) : + dispatch.default(x); + +const nogen = function*() {}; +const empty = () => new Sequence(nogen); +exports.empty = empty; + +const seq = polymorphic({ + null: empty, + void: empty, + array: identity, + string: identity, + arguments: identity, + map: identity, + set: identity, + default: x => x instanceof Sequence ? x : new Sequence(x) +}); +exports.seq = seq; + +// Function to cast seq to string. +const string = (...etc) => "".concat(...etc); +exports.string = string; + +// Function for casting seq to plain object. +const object = (...pairs) => { + let result = {}; + for (let [key, value] of pairs) + result[key] = value; + + return result; +}; +exports.object = object; + +// Takes `getEnumerator` function that returns `nsISimpleEnumerator` +// and creates lazy sequence of it's items. Note that function does +// not take `nsISimpleEnumerator` itslef because that would allow +// single iteration, which would not be consistent with rest of the +// lazy sequences. +const fromEnumerator = getEnumerator => seq(function* () { + const enumerator = getEnumerator(); + while (enumerator.hasMoreElements()) + yield enumerator.getNext(); +}); +exports.fromEnumerator = fromEnumerator; + +// Takes `object` and returns lazy sequence of own `[key, value]` +// pairs (does not include inherited and non enumerable keys). +const pairs = polymorphic({ + null: empty, + void: empty, + map: identity, + indexed: indexed => seq(function* () { + const count = indexed.length; + let index = 0; + while (index < count) { + yield [index, indexed[index]]; + index = index + 1; + } + }), + default: object => seq(function* () { + for (let key of Object.keys(object)) + yield [key, object[key]]; + }) +}); +exports.pairs = pairs; + +const names = polymorphic({ + null: empty, + void: empty, + default: object => seq(function*() { + for (let name of Object.getOwnPropertyNames(object)) { + yield name; + } + }) +}); +exports.names = names; + +const symbols = polymorphic({ + null: empty, + void: empty, + default: object => seq(function* () { + for (let symbol of Object.getOwnPropertySymbols(object)) { + yield symbol; + } + }) +}); +exports.symbols = symbols; + +const keys = polymorphic({ + null: empty, + void: empty, + indexed: indexed => seq(function* () { + const count = indexed.length; + let index = 0; + while (index < count) { + yield index; + index = index + 1; + } + }), + map: map => seq(function* () { + for (let [key, _] of map) + yield key; + }), + default: object => seq(function* () { + for (let key of Object.keys(object)) + yield key; + }) +}); +exports.keys = keys; + + +const values = polymorphic({ + null: empty, + void: empty, + set: identity, + indexed: indexed => seq(function* () { + const count = indexed.length; + let index = 0; + while (index < count) { + yield indexed[index]; + index = index + 1; + } + }), + map: map => seq(function* () { + for (let [_, value] of map) yield value; + }), + default: object => seq(function* () { + for (let key of Object.keys(object)) yield object[key]; + }) +}); +exports.values = values; + + + +// Returns a lazy sequence of `x`, `f(x)`, `f(f(x))` etc. +// `f` must be free of side-effects. Note that returned +// sequence is infinite so it must be consumed partially. +// +// Implements clojure iterate: +// http://clojuredocs.org/clojure_core/clojure.core/iterate +const iterate = (f, x) => seq(function* () { + let state = x; + while (true) { + yield state; + state = f(state); + } +}); +exports.iterate = iterate; + +// Returns a lazy sequence of the items in sequence for which `p(item)` +// returns `true`. `p` must be free of side-effects. +// +// Implements clojure filter: +// http://clojuredocs.org/clojure_core/clojure.core/filter +const filter = (p, sequence) => seq(function* () { + if (sequence !== null && sequence !== void(0)) { + for (let item of sequence) { + if (p(item)) + yield item; + } + } +}); +exports.filter = filter; + +// Returns a lazy sequence consisting of the result of applying `f` to the +// set of first items of each sequence, followed by applying f to the set +// of second items in each sequence, until any one of the sequences is +// exhausted. Any remaining items in other sequences are ignored. Function +// `f` should accept number-of-sequences arguments. +// +// Implements clojure map: +// http://clojuredocs.org/clojure_core/clojure.core/map +const map = (f, ...sequences) => seq(function* () { + const count = sequences.length; + // Optimize a single sequence case + if (count === 1) { + let [sequence] = sequences; + if (sequence !== null && sequence !== void(0)) { + for (let item of sequence) + yield f(item); + } + } + else { + // define args array that will be recycled on each + // step to aggregate arguments to be passed to `f`. + let args = []; + // define inputs to contain started generators. + let inputs = []; + + let index = 0; + while (index < count) { + inputs[index] = sequences[index][Symbol.iterator](); + index = index + 1; + } + + // Run loop yielding of applying `f` to the set of + // items at each step until one of the `inputs` is + // exhausted. + let done = false; + while (!done) { + let index = 0; + let value = void(0); + while (index < count && !done) { + ({ done, value } = inputs[index].next()); + + // If input is not exhausted yet store value in args. + if (!done) { + args[index] = value; + index = index + 1; + } + } + + // If none of the inputs is exhasted yet, `args` contain items + // from each input so we yield application of `f` over them. + if (!done) + yield f(...args); + } + } +}); +exports.map = map; + +// Returns a lazy sequence of the intermediate values of the reduction (as +// per reduce) of sequence by `f`, starting with `initial` value if provided. +// +// Implements clojure reductions: +// http://clojuredocs.org/clojure_core/clojure.core/reductions +const reductions = (...params) => { + const count = params.length; + let hasInitial = false; + let f, initial, source; + if (count === 2) { + [f, source] = params; + } + else if (count === 3) { + [f, initial, source] = params; + hasInitial = true; + } + else { + throw Error("Invoked with wrong number of arguments: " + count); + } + + const sequence = seq(source); + + return seq(function* () { + let started = hasInitial; + let result = void(0); + + // If initial is present yield it. + if (hasInitial) + yield (result = initial); + + // For each item of the sequence accumulate new result. + for (let item of sequence) { + // If nothing has being yield yet set result to first + // item and yield it. + if (!started) { + started = true; + yield (result = item); + } + // Otherwise accumulate new result and yield it. + else { + yield (result = f(result, item)); + } + } + + // If nothing has being yield yet it's empty sequence and no + // `initial` was provided in which case we need to yield `f()`. + if (!started) + yield f(); + }); +}; +exports.reductions = reductions; + +// `f` should be a function of 2 arguments. If `initial` is not supplied, +// returns the result of applying `f` to the first 2 items in sequence, then +// applying `f` to that result and the 3rd item, etc. If sequence contains no +// items, `f` must accept no arguments as well, and reduce returns the +// result of calling f with no arguments. If sequence has only 1 item, it +// is returned and `f` is not called. If `initial` is supplied, returns the +// result of applying `f` to `initial` and the first item in sequence, then +// applying `f` to that result and the 2nd item, etc. If sequence contains no +// items, returns `initial` and `f` is not called. +// +// Implements clojure reduce: +// http://clojuredocs.org/clojure_core/clojure.core/reduce +const reduce = (...args) => { + const xs = reductions(...args); + let x; + for (x of xs) void(0); + return x; +}; +exports.reduce = reduce; + +const each = (f, sequence) => { + for (let x of seq(sequence)) void(f(x)); +}; +exports.each = each; + + +const inc = x => x + 1; +// Returns the number of items in the sequence. `count(null)` && `count()` +// returns `0`. Also works on strings, arrays, Maps & Sets. + +// Implements clojure count: +// http://clojuredocs.org/clojure_core/clojure.core/count +const count = polymorphic({ + null: _ => 0, + void: _ => 0, + indexed: indexed => indexed.length, + map: map => map.size, + set: set => set.size, + default: xs => reduce(inc, 0, xs) +}); +exports.count = count; + +// Returns `true` if sequence has no items. + +// Implements clojure empty?: +// http://clojuredocs.org/clojure_core/clojure.core/empty_q +const isEmpty = sequence => { + // Treat `null` and `undefined` as empty sequences. + if (sequence === null || sequence === void(0)) + return true; + + // If contains any item non empty so return `false`. + for (let _ of sequence) + return false; + + // If has not returned yet, there was nothing to iterate + // so it's empty. + return true; +}; +exports.isEmpty = isEmpty; + +const and = (a, b) => a && b; + +// Returns true if `p(x)` is logical `true` for every `x` in sequence, else +// `false`. +// +// Implements clojure every?: +// http://clojuredocs.org/clojure_core/clojure.core/every_q +const isEvery = (p, sequence) => { + if (sequence !== null && sequence !== void(0)) { + for (let item of sequence) { + if (!p(item)) + return false; + } + } + return true; +}; +exports.isEvery = isEvery; + +// Returns the first logical true value of (p x) for any x in sequence, +// else `null`. +// +// Implements clojure some: +// http://clojuredocs.org/clojure_core/clojure.core/some +const some = (p, sequence) => { + if (sequence !== null && sequence !== void(0)) { + for (let item of sequence) { + if (p(item)) + return true; + } + } + return null; +}; +exports.some = some; + +// Returns a lazy sequence of the first `n` items in sequence, or all items if +// there are fewer than `n`. +// +// Implements clojure take: +// http://clojuredocs.org/clojure_core/clojure.core/take +const take = (n, sequence) => n <= 0 ? empty() : seq(function* () { + let count = n; + for (let item of sequence) { + yield item; + count = count - 1; + if (count === 0) break; + } +}); +exports.take = take; + +// Returns a lazy sequence of successive items from sequence while +// `p(item)` returns `true`. `p` must be free of side-effects. +// +// Implements clojure take-while: +// http://clojuredocs.org/clojure_core/clojure.core/take-while +const takeWhile = (p, sequence) => seq(function* () { + for (let item of sequence) { + if (!p(item)) + break; + + yield item; + } +}); +exports.takeWhile = takeWhile; + +// Returns a lazy sequence of all but the first `n` items in +// sequence. +// +// Implements clojure drop: +// http://clojuredocs.org/clojure_core/clojure.core/drop +const drop = (n, sequence) => seq(function* () { + if (sequence !== null && sequence !== void(0)) { + let count = n; + for (let item of sequence) { + if (count > 0) + count = count - 1; + else + yield item; + } + } +}); +exports.drop = drop; + +// Returns a lazy sequence of the items in sequence starting from the +// first item for which `p(item)` returns falsy value. +// +// Implements clojure drop-while: +// http://clojuredocs.org/clojure_core/clojure.core/drop-while +const dropWhile = (p, sequence) => seq(function* () { + let keep = false; + for (let item of sequence) { + keep = keep || !p(item); + if (keep) yield item; + } +}); +exports.dropWhile = dropWhile; + +// Returns a lazy sequence representing the concatenation of the +// suplied sequences. +// +// Implements clojure conact: +// http://clojuredocs.org/clojure_core/clojure.core/concat +const concat = (...sequences) => seq(function* () { + for (let sequence of sequences) + for (let item of sequence) + yield item; +}); +exports.concat = concat; + +// Returns the first item in the sequence. +// +// Implements clojure first: +// http://clojuredocs.org/clojure_core/clojure.core/first +const first = sequence => { + if (sequence !== null && sequence !== void(0)) { + for (let item of sequence) + return item; + } + return null; +}; +exports.first = first; + +// Returns a possibly empty sequence of the items after the first. +// +// Implements clojure rest: +// http://clojuredocs.org/clojure_core/clojure.core/rest +const rest = sequence => drop(1, sequence); +exports.rest = rest; + +// Returns the value at the index. Returns `notFound` or `undefined` +// if index is out of bounds. +const nth = (xs, n, notFound) => { + if (n >= 0) { + if (isArray(xs) || isArguments(xs) || isString(xs)) { + return n < xs.length ? xs[n] : notFound; + } + else if (xs !== null && xs !== void(0)) { + let count = n; + for (let x of xs) { + if (count <= 0) + return x; + + count = count - 1; + } + } + } + return notFound; +}; +exports.nth = nth; + +// Return the last item in sequence, in linear time. +// If `sequence` is an array or string or arguments +// returns in constant time. +// Implements clojure last: +// http://clojuredocs.org/clojure_core/clojure.core/last +const last = polymorphic({ + null: _ => null, + void: _ => null, + indexed: indexed => indexed[indexed.length - 1], + map: xs => reduce((_, x) => x, xs), + set: xs => reduce((_, x) => x, xs), + default: xs => reduce((_, x) => x, xs) +}); +exports.last = last; + +// Return a lazy sequence of all but the last `n` (default 1) items +// from the give `xs`. +// +// Implements clojure drop-last: +// http://clojuredocs.org/clojure_core/clojure.core/drop-last +const dropLast = flip((xs, n=1) => seq(function* () { + let ys = []; + for (let x of xs) { + ys.push(x); + if (ys.length > n) + yield ys.shift(); + } +})); +exports.dropLast = dropLast; + +// Returns a lazy sequence of the elements of `xs` with duplicates +// removed +// +// Implements clojure distinct +// http://clojuredocs.org/clojure_core/clojure.core/distinct +const distinct = sequence => seq(function* () { + let items = new Set(); + for (let item of sequence) { + if (!items.has(item)) { + items.add(item); + yield item; + } + } +}); +exports.distinct = distinct; + +// Returns a lazy sequence of the items in `xs` for which +// `p(x)` returns false. `p` must be free of side-effects. +// +// Implements clojure remove +// http://clojuredocs.org/clojure_core/clojure.core/remove +const remove = (p, xs) => filter(complement(p), xs); +exports.remove = remove; + +// Returns the result of applying concat to the result of +// `map(f, xs)`. Thus function `f` should return a sequence. +// +// Implements clojure mapcat +// http://clojuredocs.org/clojure_core/clojure.core/mapcat +const mapcat = (f, sequence) => seq(function* () { + const sequences = map(f, sequence); + for (let sequence of sequences) + for (let item of sequence) + yield item; +}); +exports.mapcat = mapcat; diff --git a/addon-sdk/source/lib/sdk/util/uuid.js b/addon-sdk/source/lib/sdk/util/uuid.js new file mode 100644 index 000000000..6d0f2de53 --- /dev/null +++ b/addon-sdk/source/lib/sdk/util/uuid.js @@ -0,0 +1,19 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci, components: { ID: parseUUID } } = require('chrome'); +const { generateUUID } = Cc['@mozilla.org/uuid-generator;1']. + getService(Ci.nsIUUIDGenerator); + +// Returns `uuid`. If `id` is passed then it's parsed to `uuid` and returned +// if not then new one is generated. +exports.uuid = function uuid(id) { + return id ? parseUUID(id) : generateUUID(); +}; diff --git a/addon-sdk/source/lib/sdk/view/core.js b/addon-sdk/source/lib/sdk/view/core.js new file mode 100644 index 000000000..5e82e9b5d --- /dev/null +++ b/addon-sdk/source/lib/sdk/view/core.js @@ -0,0 +1,26 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +var { Ci } = require("chrome"); +var method = require("../../method/core"); + +// Returns DOM node associated with a view for +// the given `value`. If `value` has no view associated +// it returns `null`. You can implement this method for +// this type to define what the result should be for it. +var getNodeView = method("getNodeView"); +getNodeView.define(x => + x instanceof Ci.nsIDOMNode ? x : + x instanceof Ci.nsIDOMWindow ? x : + null); +exports.getNodeView = getNodeView; +exports.viewFor = getNodeView; + +var getActiveView = method("getActiveView"); +exports.getActiveView = getActiveView; diff --git a/addon-sdk/source/lib/sdk/webextension.js b/addon-sdk/source/lib/sdk/webextension.js new file mode 100644 index 000000000..d1c4385e2 --- /dev/null +++ b/addon-sdk/source/lib/sdk/webextension.js @@ -0,0 +1,43 @@ +/* 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"; + +module.metadata = { + "stability": "experimental" +}; + +let webExtension; +let waitForWebExtensionAPI; + +module.exports = { + initFromBootstrapAddonParam(data) { + if (webExtension) { + throw new Error("'sdk/webextension' module has been already initialized"); + } + + webExtension = data.webExtension; + }, + + startup() { + if (!webExtension) { + return Promise.reject(new Error( + "'sdk/webextension' module is currently disabled. " + + "('hasEmbeddedWebExtension' option is missing or set to false)" + )); + } + + // NOTE: calling `startup` more than once raises an "Embedded Extension already started" + // error, but given that SDK addons are going to have access to the startup method through + // an SDK module that can be required in any part of the addon, it will be nicer if any + // additional startup calls return the startup promise instead of raising an exception, + // so that the SDK addon can access the API object in the other addon modules without the + // need to manually pass this promise around. + if (!waitForWebExtensionAPI) { + waitForWebExtensionAPI = webExtension.startup(); + } + + return waitForWebExtensionAPI; + } +}; diff --git a/addon-sdk/source/lib/sdk/window/browser.js b/addon-sdk/source/lib/sdk/window/browser.js new file mode 100644 index 000000000..380b5a486 --- /dev/null +++ b/addon-sdk/source/lib/sdk/window/browser.js @@ -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'; + +const { Class } = require('../core/heritage'); +const { windowNS } = require('./namespace'); +const { on, off, once } = require('../event/core'); +const { method } = require('../lang/functional'); +const { getWindowTitle } = require('./utils'); +const unload = require('../system/unload'); +const { EventTarget } = require('../event/target'); +const { isPrivate } = require('../private-browsing/utils'); +const { isWindowPrivate, isFocused } = require('../window/utils'); +const { viewFor } = require('../view/core'); + +const ERR_FENNEC_MSG = 'This method is not yet supported by Fennec, consider using require("sdk/tabs") instead'; + +const BrowserWindow = Class({ + initialize: function initialize(options) { + EventTarget.prototype.initialize.call(this, options); + windowNS(this).window = options.window; + }, + activate: function activate() { + // TODO + return null; + }, + close: function() { + throw new Error(ERR_FENNEC_MSG); + return null; + }, + get title() { + return getWindowTitle(windowNS(this).window); + }, + // NOTE: Fennec only has one window, which is assumed below + // TODO: remove assumption below + // NOTE: tabs requires windows + get tabs() { + return require('../tabs'); + }, + get activeTab() { + return require('../tabs').activeTab; + }, + on: method(on), + removeListener: method(off), + once: method(once) +}); +exports.BrowserWindow = BrowserWindow; + +const getWindowView = window => windowNS(window).window; + +viewFor.define(BrowserWindow, getWindowView); +isPrivate.define(BrowserWindow, (window) => isWindowPrivate(viewFor(window).window)); +isFocused.define(BrowserWindow, (window) => isFocused(viewFor(window).window)); diff --git a/addon-sdk/source/lib/sdk/window/events.js b/addon-sdk/source/lib/sdk/window/events.js new file mode 100644 index 000000000..b1d3a1f3e --- /dev/null +++ b/addon-sdk/source/lib/sdk/window/events.js @@ -0,0 +1,68 @@ +/* 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"; + +module.metadata = { + "stability": "unstable" +}; + +const { Ci, Cu } = require("chrome"); +const { observe } = require("../event/chrome"); +const { open } = require("../event/dom"); +const { windows } = require("../window/utils"); +const { filter, merge, map, expand } = require("../event/utils"); + +function documentMatches(weakWindow, event) { + let window = weakWindow.get(); + return window && event.target === window.document; +} + +function makeStrictDocumentFilter(window) { + // Note: Do not define a closure within this function. Otherwise + // you may leak the window argument. + let weak = Cu.getWeakReference(window); + return documentMatches.bind(null, weak); +} + +function toEventWithDefaultViewTarget({type, target}) { + return { type: type, target: target.defaultView } +} + +// Function registers single shot event listeners for relevant window events +// that forward events to exported event stream. +function eventsFor(window) { + // NOTE: Do no use pass a closure from this function into a stream + // transform function. You will capture the window in the + // closure and leak the window until the event stream is + // completely closed. + let interactive = open(window, "DOMContentLoaded", { capture: true }); + let complete = open(window, "load", { capture: true }); + let states = merge([interactive, complete]); + let changes = filter(states, makeStrictDocumentFilter(window)); + return map(changes, toEventWithDefaultViewTarget); +} + +// Create our event channels. We do this in a separate function to +// minimize the chance of leaking intermediate objects on the global. +function makeEvents() { + // In addition to observing windows that are open we also observe windows + // that are already already opened in case they're in process of loading. + var opened = windows(null, { includePrivate: true }); + var currentEvents = merge(opened.map(eventsFor)); + + // Register system event listeners for top level window open / close. + function rename({type, target, data}) { + return { type: rename[type], target: target, data: data } + } + rename.domwindowopened = "open"; + rename.domwindowclosed = "close"; + + var openEvents = map(observe("domwindowopened"), rename); + var closeEvents = map(observe("domwindowclosed"), rename); + var futureEvents = expand(openEvents, ({target}) => eventsFor(target)); + + return merge([currentEvents, futureEvents, openEvents, closeEvents]); +} + +exports.events = makeEvents(); diff --git a/addon-sdk/source/lib/sdk/window/helpers.js b/addon-sdk/source/lib/sdk/window/helpers.js new file mode 100644 index 000000000..56cfcaba7 --- /dev/null +++ b/addon-sdk/source/lib/sdk/window/helpers.js @@ -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'; + +const { defer, all } = require('../core/promise'); +const events = require('../system/events'); +const { open: openWindow, onFocus, getToplevelWindow, + isInteractive, isStartupFinished, getOuterId } = require('./utils'); +const { Ci } = require("chrome"); + +function open(uri, options) { + return promise(openWindow.apply(null, arguments), 'load').then(focus); +} +exports.open = open; + +function close(window) { + let deferred = defer(); + let toplevelWindow = getToplevelWindow(window); + let outerId = getOuterId(toplevelWindow); + events.on("outer-window-destroyed", function onclose({subject}) { + let id = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + if (id == outerId) { + events.off("outer-window-destroyed", onclose); + deferred.resolve(); + } + }, true); + window.close(); + return deferred.promise; +} +exports.close = close; + +function focus(window) { + let p = onFocus(window); + window.focus(); + return p; +} +exports.focus = focus; + +function ready(window) { + let { promise: result, resolve } = defer(); + + if (isInteractive(window)) + resolve(window); + else + resolve(promise(window, 'DOMContentLoaded')); + + return result; +} +exports.ready = ready; + +function startup(window) { + let { promise: result, resolve } = defer(); + + if (isStartupFinished(window)) { + resolve(window); + } else { + events.on("browser-delayed-startup-finished", function listener({subject}) { + if (subject === window) { + events.off("browser-delayed-startup-finished", listener); + resolve(window); + } + }); + } + + return result; +} +exports.startup = startup; + +function promise(target, evt, capture) { + let deferred = defer(); + capture = !!capture; + + target.addEventListener(evt, function eventHandler() { + target.removeEventListener(evt, eventHandler, capture); + deferred.resolve(target); + }, capture); + + return deferred.promise; +} +exports.promise = promise; diff --git a/addon-sdk/source/lib/sdk/window/namespace.js b/addon-sdk/source/lib/sdk/window/namespace.js new file mode 100644 index 000000000..b486f888d --- /dev/null +++ b/addon-sdk/source/lib/sdk/window/namespace.js @@ -0,0 +1,6 @@ +/* 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"; + +exports.windowNS = require('../core/namespace').ns(); diff --git a/addon-sdk/source/lib/sdk/window/utils.js b/addon-sdk/source/lib/sdk/window/utils.js new file mode 100644 index 000000000..db91a0fed --- /dev/null +++ b/addon-sdk/source/lib/sdk/window/utils.js @@ -0,0 +1,460 @@ +/* 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'; + +module.metadata = { + 'stability': 'unstable' +}; + +const { Cc, Ci } = require('chrome'); +const array = require('../util/array'); +const { defer } = require('sdk/core/promise'); +const { dispatcher } = require("../util/dispatcher"); + +const windowWatcher = Cc['@mozilla.org/embedcomp/window-watcher;1']. + getService(Ci.nsIWindowWatcher); +const appShellService = Cc['@mozilla.org/appshell/appShellService;1']. + getService(Ci.nsIAppShellService); +const WM = Cc['@mozilla.org/appshell/window-mediator;1']. + getService(Ci.nsIWindowMediator); +const io = Cc['@mozilla.org/network/io-service;1']. + getService(Ci.nsIIOService); +const FM = Cc["@mozilla.org/focus-manager;1"]. + getService(Ci.nsIFocusManager); + +const XUL_NS = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; + +const prefs = require("../preferences/service"); +const BROWSER = 'navigator:browser', + URI_BROWSER = prefs.get('browser.chromeURL', null), + NAME = '_blank', + FEATURES = 'chrome,all,dialog=no,non-private'; + +function isWindowPrivate(win) { + if (!win) + return false; + + // if the pbService is undefined, the PrivateBrowsingUtils.jsm is available, + // and the app is Firefox, then assume per-window private browsing is + // enabled. + try { + return win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsILoadContext) + .usePrivateBrowsing; + } + catch(e) {} + + // Sometimes the input is not a nsIDOMWindow.. but it is still a winodw. + try { + return !!win.docShell.QueryInterface(Ci.nsILoadContext).usePrivateBrowsing; + } + catch (e) {} + + return false; +} +exports.isWindowPrivate = isWindowPrivate; + +function getMostRecentBrowserWindow() { + return getMostRecentWindow(BROWSER); +} +exports.getMostRecentBrowserWindow = getMostRecentBrowserWindow; + +function getHiddenWindow() { + return appShellService.hiddenDOMWindow; +} +exports.getHiddenWindow = getHiddenWindow; + +function getMostRecentWindow(type) { + return WM.getMostRecentWindow(type); +} +exports.getMostRecentWindow = getMostRecentWindow; + +/** + * Returns the ID of the window's current inner window. + */ +function getInnerId(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID; +}; +exports.getInnerId = getInnerId; + +/** + * Returns the ID of the window's outer window. + */ +function getOuterId(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindowUtils).outerWindowID; +}; +exports.getOuterId = getOuterId; + +/** + * Returns window by the outer window id. + */ +const getByOuterId = WM.getOuterWindowWithId; +exports.getByOuterId = getByOuterId; + +const getByInnerId = WM.getCurrentInnerWindowWithId; +exports.getByInnerId = getByInnerId; + +/** + * Returns `nsIXULWindow` for the given `nsIDOMWindow`. + */ +function getXULWindow(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIWebNavigation). + QueryInterface(Ci.nsIDocShellTreeItem). + treeOwner.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIXULWindow); +}; +exports.getXULWindow = getXULWindow; + +function getDOMWindow(xulWindow) { + return xulWindow.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindow); +} +exports.getDOMWindow = getDOMWindow; + +/** + * Returns `nsIBaseWindow` for the given `nsIDOMWindow`. + */ +function getBaseWindow(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIWebNavigation). + QueryInterface(Ci.nsIDocShell). + QueryInterface(Ci.nsIDocShellTreeItem). + treeOwner. + QueryInterface(Ci.nsIBaseWindow); +} +exports.getBaseWindow = getBaseWindow; + +/** + * Returns the `nsIDOMWindow` toplevel window for any child/inner window + */ +function getToplevelWindow(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem) + .rootTreeItem + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); +} +exports.getToplevelWindow = getToplevelWindow; + +function getWindowDocShell(window) { + return window.gBrowser.docShell; +} +exports.getWindowDocShell = getWindowDocShell; + +function getWindowLoadingContext(window) { + return getWindowDocShell(window). + QueryInterface(Ci.nsILoadContext); +} +exports.getWindowLoadingContext = getWindowLoadingContext; + +const isTopLevel = window => window && getToplevelWindow(window) === window; +exports.isTopLevel = isTopLevel; + +/** + * Takes hash of options and serializes it to a features string that + * can be used passed to `window.open`. For more details on features string see: + * https://developer.mozilla.org/en/DOM/window.open#Position_and_size_features + */ +function serializeFeatures(options) { + return Object.keys(options).reduce(function(result, name) { + let value = options[name]; + + // the chrome and private features are special + if ((name == 'private' || name == 'chrome' || name == 'all')) + return result + ((value === true) ? ',' + name : ''); + + return result + ',' + name + '=' + + (value === true ? 'yes' : value === false ? 'no' : value); + }, '').substr(1); +} + +/** + * Opens a top level window and returns it's `nsIDOMWindow` representation. + * @params {String} uri + * URI of the document to be loaded into window. + * @params {nsIDOMWindow} options.parent + * Used as parent for the created window. + * @params {String} options.name + * Optional name that is assigned to the window. + * @params {Object} options.features + * Map of key, values like: `{ width: 10, height: 15, chrome: true, private: true }`. + */ +function open(uri, options) { + uri = uri || URI_BROWSER; + options = options || {}; + + if (!uri) + throw new Error('browser.chromeURL is undefined, please provide an explicit uri'); + + if (['chrome', 'resource', 'data'].indexOf(io.newURI(uri, null, null).scheme) < 0) + throw new Error('only chrome, resource and data uris are allowed'); + + let newWindow = windowWatcher. + openWindow(options.parent || null, + uri, + options.name || null, + options.features ? serializeFeatures(options.features) : null, + options.args || null); + + return newWindow; +} +exports.open = open; + +function onFocus(window) { + let { resolve, promise } = defer(); + + if (isFocused(window)) { + resolve(window); + } + else { + window.addEventListener("focus", function focusListener() { + window.removeEventListener("focus", focusListener, true); + resolve(window); + }, true); + } + + return promise; +} +exports.onFocus = onFocus; + +var isFocused = dispatcher("window-isFocused"); +isFocused.when(x => x instanceof Ci.nsIDOMWindow, (window) => { + const FM = Cc["@mozilla.org/focus-manager;1"]. + getService(Ci.nsIFocusManager); + + let childTargetWindow = {}; + FM.getFocusedElementForWindow(window, true, childTargetWindow); + childTargetWindow = childTargetWindow.value; + + let focusedChildWindow = {}; + if (FM.activeWindow) { + FM.getFocusedElementForWindow(FM.activeWindow, true, focusedChildWindow); + focusedChildWindow = focusedChildWindow.value; + } + + return (focusedChildWindow === childTargetWindow); +}); +exports.isFocused = isFocused; + +/** + * Opens a top level window and returns it's `nsIDOMWindow` representation. + * Same as `open` but with more features + * @param {Object} options + * + */ +function openDialog(options) { + options = options || {}; + + let features = options.features || FEATURES; + let featureAry = features.toLowerCase().split(','); + + if (!!options.private) { + // add private flag if private window is desired + if (!array.has(featureAry, 'private')) { + featureAry.push('private'); + } + + // remove the non-private flag ig a private window is desired + let nonPrivateIndex = featureAry.indexOf('non-private'); + if (nonPrivateIndex >= 0) { + featureAry.splice(nonPrivateIndex, 1); + } + + features = featureAry.join(','); + } + + let browser = getMostRecentBrowserWindow(); + + // if there is no browser then do nothing + if (!browser) + return undefined; + + let newWindow = browser.openDialog.apply( + browser, + array.flatten([ + options.url || URI_BROWSER, + options.name || NAME, + features, + options.args || null + ]) + ); + + return newWindow; +} +exports.openDialog = openDialog; + +/** + * Returns an array of all currently opened windows. + * Note that these windows may still be loading. + */ +function windows(type, options) { + options = options || {}; + let list = []; + let winEnum = WM.getEnumerator(type); + while (winEnum.hasMoreElements()) { + let window = winEnum.getNext().QueryInterface(Ci.nsIDOMWindow); + // Only add non-private windows when pb permission isn't set, + // unless an option forces the addition of them. + if (!window.closed && (options.includePrivate || !isWindowPrivate(window))) { + list.push(window); + } + } + return list; +} +exports.windows = windows; + +/** + * Check if the given window is interactive. + * i.e. if its "DOMContentLoaded" event has already been fired. + * @params {nsIDOMWindow} window + */ +const isInteractive = window => + window.document.readyState === "interactive" || + isDocumentLoaded(window) || + // XUL documents stays '"uninitialized"' until it's `readyState` becomes + // `"complete"`. + isXULDocumentWindow(window) && window.document.readyState === "interactive"; +exports.isInteractive = isInteractive; + +/** + * Check if the given browser window has finished the startup. + * @params {nsIDOMWindow} window + */ +const isStartupFinished = (window) => + isBrowser(window) && + window.gBrowserInit && + window.gBrowserInit.delayedStartupFinished; + +exports.isStartupFinished = isStartupFinished; + +const isXULDocumentWindow = ({document}) => + document.documentElement && + document.documentElement.namespaceURI === XUL_NS; + +/** + * Check if the given window is completely loaded. + * i.e. if its "load" event has already been fired and all possible DOM content + * is done loading (the whole DOM document, images content, ...) + * @params {nsIDOMWindow} window + */ +function isDocumentLoaded(window) { + return window.document.readyState == "complete"; +} +exports.isDocumentLoaded = isDocumentLoaded; + +function isBrowser(window) { + try { + return window.document.documentElement.getAttribute("windowtype") === BROWSER; + } + catch (e) {} + return false; +}; +exports.isBrowser = isBrowser; + +function getWindowTitle(window) { + return window && window.document ? window.document.title : null; +} +exports.getWindowTitle = getWindowTitle; + +function isXULBrowser(window) { + return !!(isBrowser(window) && window.XULBrowserWindow); +} +exports.isXULBrowser = isXULBrowser; + +/** + * Returns the most recent focused window + */ +function getFocusedWindow() { + let window = WM.getMostRecentWindow(BROWSER); + + return window ? window.document.commandDispatcher.focusedWindow : null; +} +exports.getFocusedWindow = getFocusedWindow; + +/** + * Returns the focused browser window if any, or the most recent one. + * Opening new window, updates most recent window, but focus window + * changes later; so most recent window and focused window are not always + * the same. + */ +function getFocusedBrowser() { + let window = FM.activeWindow; + return isBrowser(window) ? window : getMostRecentBrowserWindow() +} +exports.getFocusedBrowser = getFocusedBrowser; + +/** + * Returns the focused element in the most recent focused window + */ +function getFocusedElement() { + let window = WM.getMostRecentWindow(BROWSER); + + return window ? window.document.commandDispatcher.focusedElement : null; +} +exports.getFocusedElement = getFocusedElement; + +function getFrames(window) { + return Array.slice(window.frames).reduce(function(frames, frame) { + return frames.concat(frame, getFrames(frame)); + }, []); +} +exports.getFrames = getFrames; + +function getScreenPixelsPerCSSPixel(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindowUtils).screenPixelsPerCSSPixel; +} +exports.getScreenPixelsPerCSSPixel = getScreenPixelsPerCSSPixel; + +function getOwnerBrowserWindow(node) { + /** + Takes DOM node and returns browser window that contains it. + **/ + let window = getToplevelWindow(node.ownerDocument.defaultView); + // If anchored window is browser then it's target browser window. + return isBrowser(window) ? window : null; +} +exports.getOwnerBrowserWindow = getOwnerBrowserWindow; + +function getParentWindow(window) { + try { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem).parent + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + } + catch (e) {} + return null; +} +exports.getParentWindow = getParentWindow; + + +function getParentFrame(window) { + try { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem).parent + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + } + catch (e) {} + return null; +} +exports.getParentWindow = getParentWindow; + +// The element in which the window is embedded, or `null` +// if the window is top-level. Similar to `window.frameElement` +// but can cross chrome-content boundries. +const getFrameElement = target => + (target instanceof Ci.nsIDOMDocument ? target.defaultView : target). + QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindowUtils). + containerElement; +exports.getFrameElement = getFrameElement; diff --git a/addon-sdk/source/lib/sdk/windows.js b/addon-sdk/source/lib/sdk/windows.js new file mode 100644 index 000000000..06dbe70b2 --- /dev/null +++ b/addon-sdk/source/lib/sdk/windows.js @@ -0,0 +1,32 @@ +/* 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'; + +module.metadata = { + 'stability': 'stable' +}; + +const { isBrowser } = require('./window/utils'); +const { modelFor } = require('./model/core'); +const { viewFor } = require('./view/core'); + + +if (require('./system/xul-app').is('Fennec')) { + module.exports = require('./windows/fennec'); +} +else { + module.exports = require('./windows/firefox'); +} + + +const browsers = module.exports.browserWindows; + +// +modelFor.when(isBrowser, view => { + for (let model of browsers) { + if (viewFor(model) === view) + return model; + } + return null; +}); diff --git a/addon-sdk/source/lib/sdk/windows/fennec.js b/addon-sdk/source/lib/sdk/windows/fennec.js new file mode 100644 index 000000000..3c3b6c313 --- /dev/null +++ b/addon-sdk/source/lib/sdk/windows/fennec.js @@ -0,0 +1,83 @@ +/* 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 { Class } = require('../core/heritage'); +const { BrowserWindow } = require('../window/browser'); +const { WindowTracker } = require('../deprecated/window-utils'); +const { isBrowser, getMostRecentBrowserWindow } = require('../window/utils'); +const { windowNS } = require('../window/namespace'); +const { on, off, once, emit } = require('../event/core'); +const { method } = require('../lang/functional'); +const { EventTarget } = require('../event/target'); +const { List, addListItem } = require('../util/list'); + +const ERR_FENNEC_MSG = 'This method is not yet supported by Fennec, consider using require("sdk/tabs") instead'; + +// NOTE: On Fennec there is only one window. + +var BrowserWindows = Class({ + implements: [ List ], + extends: EventTarget, + initialize: function() { + List.prototype.initialize.apply(this); + }, + get activeWindow() { + let window = getMostRecentBrowserWindow(); + return window ? getBrowserWindow({window: window}) : null; + }, + open: function open(options) { + throw new Error(ERR_FENNEC_MSG); + return null; + } +}); +const browserWindows = exports.browserWindows = BrowserWindows(); + + +/** + * Gets a `BrowserWindow` for the given `chromeWindow` if previously + * registered, `null` otherwise. + */ +function getRegisteredWindow(chromeWindow) { + for (let window of browserWindows) { + if (chromeWindow === windowNS(window).window) + return window; + } + + return null; +} + +/** + * Gets a `BrowserWindow` for the provided window options obj + * @params {Object} options + * Options that are passed to the the `BrowserWindow` + * @returns {BrowserWindow} + */ +function getBrowserWindow(options) { + let window = null; + + // if we have a BrowserWindow already then use it + if ('window' in options) + window = getRegisteredWindow(options.window); + if (window) + return window; + + // we don't have a BrowserWindow yet, so create one + window = BrowserWindow(options); + addListItem(browserWindows, window); + return window; +} + +WindowTracker({ + onTrack: function onTrack(chromeWindow) { + if (!isBrowser(chromeWindow)) return; + let window = getBrowserWindow({ window: chromeWindow }); + emit(browserWindows, 'open', window); + }, + onUntrack: function onUntrack(chromeWindow) { + if (!isBrowser(chromeWindow)) return; + let window = getBrowserWindow({ window: chromeWindow }); + emit(browserWindows, 'close', window); + } +}); diff --git a/addon-sdk/source/lib/sdk/windows/firefox.js b/addon-sdk/source/lib/sdk/windows/firefox.js new file mode 100644 index 000000000..1eb1d8488 --- /dev/null +++ b/addon-sdk/source/lib/sdk/windows/firefox.js @@ -0,0 +1,224 @@ +/* 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 { Class } = require('../core/heritage'); +const { observer } = require('./observer'); +const { isBrowser, getMostRecentBrowserWindow, windows, open, getInnerId, + getWindowTitle, getToplevelWindow, isFocused, isWindowPrivate } = require('../window/utils'); +const { List, addListItem, removeListItem } = require('../util/list'); +const { viewFor } = require('../view/core'); +const { modelFor } = require('../model/core'); +const { emit, emitOnObject, setListeners } = require('../event/core'); +const { once } = require('../dom/events'); +const { EventTarget } = require('../event/target'); +const { getSelectedTab } = require('../tabs/utils'); +const { Cc, Ci } = require('chrome'); +const { Options } = require('../tabs/common'); +const system = require('../system/events'); +const { ignoreWindow, isPrivate, isWindowPBSupported } = require('../private-browsing/utils'); +const { data, isPrivateBrowsingSupported } = require('../self'); +const { setImmediate } = require('../timers'); + +const supportPrivateWindows = isPrivateBrowsingSupported && isWindowPBSupported; + +const modelsFor = new WeakMap(); +const viewsFor = new WeakMap(); + +const Window = Class({ + implements: [EventTarget], + initialize: function(domWindow) { + modelsFor.set(domWindow, this); + viewsFor.set(this, domWindow); + }, + + get title() { + return getWindowTitle(viewsFor.get(this)); + }, + + activate: function() { + viewsFor.get(this).focus(); + }, + + close: function(callback) { + let domWindow = viewsFor.get(this); + + if (callback) { + // We want to catch the close event immediately after the close events are + // emitted everywhere but without letting the event loop spin. Registering + // for the same events as windowEventListener but afterwards does this + let listener = (event, closedWin) => { + if (event != "close" || closedWin != domWindow) + return; + + observer.off("*", listener); + callback(); + } + + observer.on("*", listener); + } + + domWindow.close(); + } +}); + +const windowTabs = new WeakMap(); + +const BrowserWindow = Class({ + extends: Window, + + get tabs() { + let tabs = windowTabs.get(this); + if (tabs) + return tabs; + + return new WindowTabs(this); + } +}); + +const WindowTabs = Class({ + implements: [EventTarget], + extends: List, + initialize: function(window) { + List.prototype.initialize.call(this); + windowTabs.set(window, this); + viewsFor.set(this, viewsFor.get(window)); + + // Make sure the tabs module has loaded and found all existing tabs + const tabs = require('../tabs'); + + for (let tab of tabs) { + if (tab.window == window) + addListItem(this, tab); + } + }, + + get activeTab() { + return modelFor(getSelectedTab(viewsFor.get(this))); + }, + + open: function(options) { + options = Options(options); + + let domWindow = viewsFor.get(this); + let { Tab } = require('../tabs/tab-firefox'); + + // The capturing listener will see the TabOpen event before + // sdk/tabs/observer giving us time to set up the tab and listeners before + // the real open event is fired + let listener = event => { + new Tab(event.target, options); + }; + + once(domWindow, "TabOpen", listener, true); + domWindow.gBrowser.addTab(options.url); + } +}); + +const BrowserWindows = Class({ + implements: [EventTarget], + extends: List, + initialize: function() { + List.prototype.initialize.call(this); + }, + + get activeWindow() { + let domWindow = getMostRecentBrowserWindow(); + if (ignoreWindow(domWindow)) + return null; + return modelsFor.get(domWindow); + }, + + open: function(options) { + if (typeof options == "string") + options = { url: options }; + + let { url, isPrivate } = options; + if (url) + url = data.url(url); + + let args = Cc["@mozilla.org/supports-string;1"]. + createInstance(Ci.nsISupportsString); + args.data = url; + + let features = { + chrome: true, + all: true, + dialog: false + }; + features.private = supportPrivateWindows && isPrivate; + + let domWindow = open(null, { + parent: null, + name: "_blank", + features, + args + }) + + let window = makeNewWindow(domWindow, true); + setListeners(window, options); + return window; + } +}); + +const browserWindows = new BrowserWindows(); +exports.browserWindows = browserWindows; + +function windowEmit(window, event, ...args) { + if (window instanceof BrowserWindow && (event == "open" || event == "close")) + emitOnObject(window, event, browserWindows, window, ...args); + else + emit(window, event, window, ...args); + + if (window instanceof BrowserWindow) + emit(browserWindows, event, window, ...args); +} + +function makeNewWindow(domWindow, browserHint = false) { + if (browserHint || isBrowser(domWindow)) + return new BrowserWindow(domWindow); + else + return new Window(domWindow); +} + +for (let domWindow of windows(null, {includePrivate: supportPrivateWindows})) { + let window = makeNewWindow(domWindow); + if (window instanceof BrowserWindow) + addListItem(browserWindows, window); +} + +var windowEventListener = (event, domWindow, ...args) => { + let toplevelWindow = getToplevelWindow(domWindow); + + if (ignoreWindow(toplevelWindow)) + return; + + let window = modelsFor.get(toplevelWindow); + if (!window) + window = makeNewWindow(toplevelWindow); + + if (isBrowser(toplevelWindow)) { + if (event == "open") + addListItem(browserWindows, window); + else if (event == "close") + removeListItem(browserWindows, window); + } + + windowEmit(window, event, ...args); + + // The window object shouldn't be reachable after closed + if (event == "close") { + viewsFor.delete(window); + modelsFor.delete(toplevelWindow); + } +}; +observer.on("*", windowEventListener); + +viewFor.define(BrowserWindow, window => { + return viewsFor.get(window); +}) + +const isBrowserWindow = (x) => x instanceof BrowserWindow; +isPrivate.when(isBrowserWindow, (w) => isWindowPrivate(viewsFor.get(w))); +isFocused.when(isBrowserWindow, (w) => isFocused(viewsFor.get(w))); diff --git a/addon-sdk/source/lib/sdk/windows/observer.js b/addon-sdk/source/lib/sdk/windows/observer.js new file mode 100644 index 000000000..5ba2535f1 --- /dev/null +++ b/addon-sdk/source/lib/sdk/windows/observer.js @@ -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"; + +module.metadata = { + "stability": "unstable" +}; + +const { EventTarget } = require("../event/target"); +const { emit } = require("../event/core"); +const { WindowTracker, windowIterator } = require("../deprecated/window-utils"); +const { DOMEventAssembler } = require("../deprecated/events/assembler"); +const { Class } = require("../core/heritage"); +const { Cu } = require("chrome"); + +// Event emitter objects used to register listeners and emit events on them +// when they occur. +const Observer = Class({ + initialize() { + // Using `WindowTracker` to track window events. + WindowTracker({ + onTrack: chromeWindow => { + emit(this, "open", chromeWindow); + this.observe(chromeWindow); + }, + onUntrack: chromeWindow => { + emit(this, "close", chromeWindow); + this.ignore(chromeWindow); + } + }); + }, + implements: [EventTarget, DOMEventAssembler], + /** + * Events that are supported and emitted by the module. + */ + supportedEventsTypes: [ "activate", "deactivate" ], + /** + * Function handles all the supported events on all the windows that are + * observed. Method is used to proxy events to the listeners registered on + * this event emitter. + * @param {Event} event + * Keyboard event being emitted. + */ + handleEvent(event) { + // Ignore events from windows in the child process as they can't be top-level + if (Cu.isCrossProcessWrapper(event.target)) + return; + emit(this, event.type, event.target, event); + } +}); + +exports.observer = new Observer(); diff --git a/addon-sdk/source/lib/sdk/windows/tabs-fennec.js b/addon-sdk/source/lib/sdk/windows/tabs-fennec.js new file mode 100644 index 000000000..0ef5ec9f5 --- /dev/null +++ b/addon-sdk/source/lib/sdk/windows/tabs-fennec.js @@ -0,0 +1,172 @@ +/* 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 { Class } = require('../core/heritage'); +const { Tab } = require('../tabs/tab'); +const { browserWindows } = require('./fennec'); +const { windowNS } = require('../window/namespace'); +const { tabsNS, tabNS } = require('../tabs/namespace'); +const { openTab, getTabs, getSelectedTab, getTabForBrowser: getRawTabForBrowser, + getTabContentWindow } = require('../tabs/utils'); +const { Options } = require('../tabs/common'); +const { getTabForBrowser, getTabForRawTab } = require('../tabs/helpers'); +const { on, once, off, emit } = require('../event/core'); +const { method } = require('../lang/functional'); +const { EVENTS } = require('../tabs/events'); +const { EventTarget } = require('../event/target'); +const { when: unload } = require('../system/unload'); +const { windowIterator } = require('../deprecated/window-utils'); +const { List, addListItem, removeListItem } = require('../util/list'); +const { isPrivateBrowsingSupported, data } = require('../self'); +const { isTabPBSupported, ignoreWindow } = require('../private-browsing/utils'); + +const mainWindow = windowNS(browserWindows.activeWindow).window; + +const ERR_FENNEC_MSG = 'This method is not yet supported by Fennec'; + +const supportPrivateTabs = isPrivateBrowsingSupported && isTabPBSupported; + +const Tabs = Class({ + implements: [ List ], + extends: EventTarget, + initialize: function initialize(options) { + let tabsInternals = tabsNS(this); + let window = tabsNS(this).window = options.window || mainWindow; + + EventTarget.prototype.initialize.call(this, options); + List.prototype.initialize.apply(this, getTabs(window).map(Tab)); + + // TabOpen event + window.BrowserApp.deck.addEventListener(EVENTS.open.dom, onTabOpen, false); + + // TabSelect + window.BrowserApp.deck.addEventListener(EVENTS.activate.dom, onTabSelect, false); + }, + get activeTab() { + return getTabForRawTab(getSelectedTab(tabsNS(this).window)); + }, + open: function(options) { + options = Options(options); + let activeWin = browserWindows.activeWindow; + + if (options.isPinned) { + console.error(ERR_FENNEC_MSG); // TODO + } + + let url = options.url ? data.url(options.url) : options.url; + let rawTab = openTab(windowNS(activeWin).window, url, { + inBackground: options.inBackground, + isPrivate: supportPrivateTabs && options.isPrivate + }); + + // by now the tab has been created + let tab = getTabForRawTab(rawTab); + + if (options.onClose) + tab.on('close', options.onClose); + + if (options.onOpen) { + // NOTE: on Fennec this will be true + if (tabNS(tab).opened) + options.onOpen(tab); + + tab.on('open', options.onOpen); + } + + if (options.onReady) + tab.on('ready', options.onReady); + + if (options.onLoad) + tab.on('load', options.onLoad); + + if (options.onPageShow) + tab.on('pageshow', options.onPageShow); + + if (options.onActivate) + tab.on('activate', options.onActivate); + + return tab; + } +}); +var gTabs = exports.tabs = Tabs(mainWindow); + +function tabsUnloader(event, window) { + window = window || (event && event.target); + if (!(window && window.BrowserApp)) + return; + window.BrowserApp.deck.removeEventListener(EVENTS.open.dom, onTabOpen, false); + window.BrowserApp.deck.removeEventListener(EVENTS.activate.dom, onTabSelect, false); +} + +// unload handler +unload(function() { + for (let window in windowIterator()) { + tabsUnloader(null, window); + } +}); + +function addTab(tab) { + addListItem(gTabs, tab); + return tab; +} + +function removeTab(tab) { + removeListItem(gTabs, tab); + return tab; +} + +// TabOpen +function onTabOpen(event) { + let browser = event.target; + + // Eventually ignore private tabs + if (ignoreWindow(browser.contentWindow)) + return; + + let tab = getTabForBrowser(browser); + if (tab === null) { + let rawTab = getRawTabForBrowser(browser); + + // create a Tab instance for this new tab + tab = addTab(Tab(rawTab)); + } + + tabNS(tab).opened = true; + + tab.on('ready', () => emit(gTabs, 'ready', tab)); + tab.once('close', onTabClose); + + tab.on('pageshow', (_tab, persisted) => + emit(gTabs, 'pageshow', tab, persisted)); + + emit(tab, 'open', tab); + emit(gTabs, 'open', tab); +} + +// TabSelect +function onTabSelect(event) { + let browser = event.target; + + // Eventually ignore private tabs + if (ignoreWindow(browser.contentWindow)) + return; + + // Set value whenever new tab becomes active. + let tab = getTabForBrowser(browser); + emit(tab, 'activate', tab); + emit(gTabs, 'activate', tab); + + for (let t of gTabs) { + if (t === tab) continue; + emit(t, 'deactivate', t); + emit(gTabs, 'deactivate', t); + } +} + +// TabClose +function onTabClose(tab) { + removeTab(tab); + emit(gTabs, EVENTS.close.name, tab); +} diff --git a/addon-sdk/source/lib/sdk/worker/utils.js b/addon-sdk/source/lib/sdk/worker/utils.js new file mode 100644 index 000000000..fca19be63 --- /dev/null +++ b/addon-sdk/source/lib/sdk/worker/utils.js @@ -0,0 +1,19 @@ +/* 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'; + +module.metadata = { + 'stability': 'deprecated' +}; + +const { + requiresAddonGlobal, attach, detach, destroy, WorkerHost +} = require('../content/utils'); + +exports.WorkerHost = WorkerHost; +exports.detach = detach; +exports.attach = attach; +exports.destroy = destroy; +exports.requiresAddonGlobal = requiresAddonGlobal; diff --git a/addon-sdk/source/lib/sdk/zip/utils.js b/addon-sdk/source/lib/sdk/zip/utils.js new file mode 100644 index 000000000..e600380cb --- /dev/null +++ b/addon-sdk/source/lib/sdk/zip/utils.js @@ -0,0 +1,16 @@ +/* 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, Ci } = require("chrome"); + +function getZipReader(aFile) { + return new Promise(resolve => { + let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"]. + createInstance(Ci.nsIZipReader); + zipReader.open(aFile); + resolve(zipReader); + }); +}; +exports.getZipReader = getZipReader; |