/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ /* vim:set ts=2 sw=2 sts=2 et: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; // Widget content types const CONTENT_TYPE_URI = 1; const CONTENT_TYPE_HTML = 2; const CONTENT_TYPE_IMAGE = 3; const ERR_CONTENT = "No content or contentURL property found. Widgets must " + "have one or the other.", ERR_LABEL = "The widget must have a non-empty label property.", ERR_ID = "You have to specify a unique value for the id property of " + "your widget in order for the application to remember its " + "position.", ERR_DESTROYED = "The widget has been destroyed and can no longer be used."; // Supported events, mapping from DOM event names to our event names const EVENTS = { "click": "click", "mouseover": "mouseover", "mouseout": "mouseout", }; if (!require("api-utils/xul-app").is("Firefox")) { throw new Error([ "The widget module currently supports only Firefox. In the future ", "it will support other applications. Please see ", "https://bugzilla.mozilla.org/show_bug.cgi?id=560716 for more information." ].join("")); } const { validateOptions } = require("api-utils/api-utils"); const panels = require("./panel"); const { EventEmitter, EventEmitterTrait } = require("api-utils/events"); const { Trait } = require("api-utils/traits"); const LightTrait = require('api-utils/light-traits').Trait; const { Loader, Symbiont } = require("api-utils/content"); const timer = require("api-utils/timer"); const { Cortex } = require('api-utils/cortex'); const windowsAPI = require("./windows"); const unload = require("api-utils/unload"); // Data types definition const valid = { number: { is: ["null", "undefined", "number"] }, string: { is: ["null", "undefined", "string"] }, id: { is: ["string"], ok: function (v) v.length > 0, msg: ERR_ID, readonly: true }, label: { is: ["string"], ok: function (v) v.length > 0, msg: ERR_LABEL }, panel: { is: ["null", "undefined", "object"], ok: function(v) !v || v instanceof panels.Panel }, width: { is: ["null", "undefined", "number"], map: function (v) { if (null === v || undefined === v) v = 16; return v; }, defaultValue: 16 }, allow: { is: ["null", "undefined", "object"], map: function (v) { if (!v) v = { script: true }; return v; }, get defaultValue() ({ script: true }) }, }; // Widgets attributes definition let widgetAttributes = { label: valid.label, id: valid.id, tooltip: valid.string, width: valid.width, content: valid.string, panel: valid.panel, allow: valid.allow }; // Import data definitions from loader, but don't compose with it as Model // functions allow us to recreate easily all Loader code. let loaderAttributes = require("api-utils/content/loader").validationAttributes; for (let i in loaderAttributes) widgetAttributes[i] = loaderAttributes[i]; widgetAttributes.contentURL.optional = true; // Widgets public events list, that are automatically binded in options object const WIDGET_EVENTS = [ "click", "mouseover", "mouseout", "error", "message", "attach" ]; // `Model` utility functions that help creating these various Widgets objects let model = { // Validate one attribute using api-utils.js:validateOptions function _validate: function _validate(name, suspect, validation) { let $1 = {}; $1[name] = suspect; let $2 = {}; $2[name] = validation; return validateOptions($1, $2)[name]; }, /** * This method has two purposes: * 1/ Validate and define, on a given object, a set of attribute * 2/ Emit a "change" event on this object when an attribute is changed * * @params {Object} object * Object on which we can bind attributes on and watch for their changes. * This object must have an EventEmitter interface, or, at least `_emit` * method * @params {Object} attrs * Dictionary of attributes definition following api-utils:validateOptions * scheme * @params {Object} values * Dictionary of attributes default values */ setAttributes: function setAttributes(object, attrs, values) { let properties = {}; for (let name in attrs) { let value = values[name]; let req = attrs[name]; // Retrieve default value from typedef if the value is not defined if ((typeof value == "undefined" || value == null) && req.defaultValue) value = req.defaultValue; // Check for valid value if value is defined or mandatory if (!req.optional || typeof value != "undefined") value = model._validate(name, value, req); // In any case, define this property on `object` let property = null; if (req.readonly) { property = { value: value, writable: false, enumerable: true, configurable: false }; } else { property = model._createWritableProperty(name, value); } properties[name] = property; } Object.defineProperties(object, properties); }, // Generate ES5 property definition for a given attribute _createWritableProperty: function _createWritableProperty(name, value) { return { get: function () { return value; }, set: function (newValue) { value = newValue; // The main goal of all this Model stuff is here: // We want to forward all changes to some listeners this._emit("change", name, value); }, enumerable: true, configurable: false }; }, /** * Automagically register listeners in options dictionary * by detecting listener attributes with name starting with `on` * * @params {Object} object * Target object that need to follow EventEmitter interface, or, at least, * having `on` method. * @params {Array} events * List of events name to automatically bind. * @params {Object} listeners * Dictionary of event listener functions to register. */ setEvents: function setEvents(object, events, listeners) { for (let i = 0, l = events.length; i < l; i++) { let name = events[i]; let onName = "on" + name[0].toUpperCase() + name.substr(1); if (!listeners[onName]) continue; object.on(name, listeners[onName].bind(object)); } } }; /** * Main Widget class: entry point of the widget API * * Allow to control all widget across all existing windows with a single object. * Widget.getView allow to retrieve a WidgetView instance to control a widget * specific to one window. */ const WidgetTrait = LightTrait.compose(EventEmitterTrait, LightTrait({ _initWidget: function _initWidget(options) { model.setAttributes(this, widgetAttributes, options); browserManager.validate(this); // We must have at least content or contentURL defined if (!(this.content || this.contentURL)) throw new Error(ERR_CONTENT); this._views = []; // Set tooltip to label value if we don't have tooltip defined if (!this.tooltip) this.tooltip = this.label; model.setEvents(this, WIDGET_EVENTS, options); this.on('change', this._onChange.bind(this)); let self = this; this._port = EventEmitterTrait.create({ emit: function () { let args = arguments; self._views.forEach(function(v) v.port.emit.apply(v.port, args)); } }); // expose wrapped port, that exposes only public properties. this._port._public = Cortex(this._port); // Register this widget to browser manager in order to create new widget on // all new windows browserManager.addItem(this); }, _onChange: function _onChange(name, value) { // Set tooltip to label value if we don't have tooltip defined if (name == 'tooltip' && !value) { // we need to change tooltip again in order to change the value of the // attribute itself this.tooltip = this.label; return; } // Forward attributes changes to WidgetViews if (['width', 'tooltip', 'content', 'contentURL'].indexOf(name) != -1) { this._views.forEach(function(v) v[name] = value); } }, _onEvent: function _onEvent(type, eventData) { this._emit(type, eventData); }, _createView: function _createView() { // Create a new WidgetView instance let view = WidgetView(this); // Keep a reference to it this._views.push(view); // Emit an `attach` event with a WidgetView instance without private attrs this._emit("attach", view._public); return view; }, // a WidgetView instance is destroyed _onViewDestroyed: function _onViewDestroyed(view) { let idx = this._views.indexOf(view); this._views.splice(idx, 1); }, /** * Called on browser window closed, to destroy related WidgetViews * @params {ChromeWindow} window * Window that has been closed */ _onWindowClosed: function _onWindowClosed(window) { for each (let view in this._views) { if (view._isInChromeWindow(window)) { view.destroy(); break; } } }, /** * Get the WidgetView instance related to a BrowserWindow instance * @params {BrowserWindow} window * BrowserWindow reference from "windows" module */ getView: function getView(window) { for each (let view in this._views) { if (view._isInWindow(window)) { return view._public; } } return null; }, get port() this._port._public, set port(v) {}, // Work around Cortex failure with getter without setter // See bug 653464 _port: null, postMessage: function postMessage(message) { this._views.forEach(function(v) v.postMessage(message)); }, destroy: function destroy() { if (this.panel) this.panel.destroy(); // Dispatch destroy calls to views // we need to go backward as we remove items from this array in // _onViewDestroyed for (let i = this._views.length - 1; i >= 0; i--) this._views[i].destroy(); // Unregister widget to stop creating it over new windows // and allow creation of new widget with same id browserManager.removeItem(this); } })); // Widget constructor const Widget = function Widget(options) { let w = WidgetTrait.create(Widget.prototype); w._initWidget(options); // Return a Cortex of widget in order to hide private attributes like _onEvent let _public = Cortex(w); unload.ensure(_public, "destroy"); return _public; } exports.Widget = Widget; /** * WidgetView is an instance of a widget for a specific window. * * This is an external API that can be retrieved by calling Widget.getView or * by watching `attach` event on Widget. */ const WidgetViewTrait = LightTrait.compose(EventEmitterTrait, LightTrait({ // Reference to the matching WidgetChrome // set right after constructor call _chrome: null, // Public interface of the WidgetView, passed in `attach` event or in // Widget.getView _public: null, _initWidgetView: function WidgetView__initWidgetView(baseWidget) { this._baseWidget = baseWidget; model.setAttributes(this, widgetAttributes, baseWidget); this.on('change', this._onChange.bind(this)); let self = this; this._port = EventEmitterTrait.create({ emit: function () { if (!self._chrome) throw new Error(ERR_DESTROYED); self._chrome.update(self._baseWidget, "emit", arguments); } }); // expose wrapped port, that exposes only public properties. this._port._public = Cortex(this._port); this._public = Cortex(this); }, _onChange: function WidgetView__onChange(name, value) { if (name == 'tooltip' && !value) { this.tooltip = this.label; return; } // Forward attributes changes to WidgetChrome instance if (['width', 'tooltip', 'content', 'contentURL'].indexOf(name) != -1) { this._chrome.update(this._baseWidget, name, value); } }, _onEvent: function WidgetView__onEvent(type, eventData, domNode) { // Dispatch event in view this._emit(type, eventData); // And forward it to the main Widget object if ("click" == type || type.indexOf("mouse") == 0) this._baseWidget._onEvent(type, this._public); else this._baseWidget._onEvent(type, eventData); // Special case for click events: if the widget doesn't have a click // handler, but it does have a panel, display the panel. if ("click" == type && !this._listeners("click").length && this.panel) this.panel.show(domNode); }, _isInWindow: function WidgetView__isInWindow(window) { return windowsAPI.BrowserWindow({ window: this._chrome.window }) == window; }, _isInChromeWindow: function WidgetView__isInChromeWindow(window) { return this._chrome.window == window; }, _onPortEvent: function WidgetView__onPortEvent(args) { let port = this._port; port._emit.apply(port, args); let basePort = this._baseWidget._port; basePort._emit.apply(basePort, args); }, get port() this._port._public, set port(v) {}, // Work around Cortex failure with getter without setter // See bug 653464 _port: null, postMessage: function WidgetView_postMessage(message) { if (!this._chrome) throw new Error(ERR_DESTROYED); this._chrome.update(this._baseWidget, "postMessage", message); }, destroy: function WidgetView_destroy() { this._chrome.destroy(); delete this._chrome; this._baseWidget._onViewDestroyed(this); this._emit("detach"); } })); const WidgetView = function WidgetView(baseWidget) { let w = WidgetViewTrait.create(WidgetView.prototype); w._initWidgetView(baseWidget); return w; } /** * Keeps track of all browser windows. * Exposes methods for adding/removing widgets * across all open windows (and future ones). * Create a new instance of BrowserWindow per window. */ let browserManager = { items: [], windows: [], // Registers the manager to listen for window openings and closings. Note // that calling this method can cause onTrack to be called immediately if // there are open windows. init: function () { let windowTracker = new (require("api-utils/window-utils").WindowTracker)(this); unload.ensure(windowTracker); }, // Registers a window with the manager. This is a WindowTracker callback. onTrack: function browserManager_onTrack(window) { if (this._isBrowserWindow(window)) { let win = new BrowserWindow(window); win.addItems(this.items); this.windows.push(win); } }, // Unregisters a window from the manager. It's told to undo all // modifications. This is a WindowTracker callback. Note that when // WindowTracker is unloaded, it calls onUntrack for every currently opened // window. The browserManager therefore doesn't need to specially handle // unload itself, since unloading the browserManager means untracking all // currently opened windows. onUntrack: function browserManager_onUntrack(window) { if (this._isBrowserWindow(window)) { this.items.forEach(function(i) i._onWindowClosed(window)); for (let i = 0; i < this.windows.length; i++) { if (this.windows[i].window == window) { this.windows.splice(i, 1)[0]; return; } } } }, // Used to validate widget by browserManager before adding it, // in order to check input very early in widget constructor validate : function (item) { let idx = this.items.indexOf(item); if (idx > -1) throw new Error("The widget " + item + " has already been added."); if (item.id) { let sameId = this.items.filter(function(i) i.id == item.id); if (sameId.length > 0) throw new Error("This widget ID is already used: " + item.id); } else { item.id = this.items.length; } }, // Registers an item with the manager. It's added to all currently registered // windows, and when new windows are registered it will be added to them, too. addItem: function browserManager_addItem(item) { this.items.push(item); this.windows.forEach(function (w) w.addItems([item])); }, // Unregisters an item from the manager. It's removed from all windows that // are currently registered. removeItem: function browserManager_removeItem(item) { let idx = this.items.indexOf(item); if (idx > -1) this.items.splice(idx, 1); }, _isBrowserWindow: function browserManager__isBrowserWindow(win) { let winType = win.document.documentElement.getAttribute("windowtype"); return winType === "navigator:browser"; } }; /** * Keeps track of a single browser window. * * This is where the core of how a widget's content is added to a window lives. */ function BrowserWindow(window) { this.window = window; this.doc = window.document; } BrowserWindow.prototype = { // Adds an array of items to the window. addItems: function BW_addItems(items) { items.forEach(this._addItemToWindow, this); }, _addItemToWindow: function BW__addItemToWindow(baseWidget) { // Create a WidgetView instance let widget = baseWidget._createView(); // Create a WidgetChrome instance let item = new WidgetChrome({ widget: widget, doc: this.doc, window: this.window }); widget._chrome = item; this._insertNodeInToolbar(item.node); // We need to insert Widget DOM Node before finishing widget view creation // (because fill creates an iframe and tries to access its docShell) item.fill(); }, _insertNodeInToolbar: function BW__insertNodeInToolbar(node) { // Add to the customization palette let toolbox = this.doc.getElementById("navigator-toolbox"); let palette = toolbox.palette; palette.appendChild(node); // Search for widget toolbar by reading toolbar's currentset attribute let container = null; let toolbars = this.doc.getElementsByTagName("toolbar"); let id = node.getAttribute("id"); for (let i = 0, l = toolbars.length; i < l; i++) { let toolbar = toolbars[i]; if (toolbar.getAttribute("currentset").indexOf(id) == -1) continue; container = toolbar; } // if widget isn't in any toolbar, add it to the addon-bar // TODO: we may want some "first-launch" module to do this only on very // first execution if (!container) { container = this.doc.getElementById("addon-bar"); // TODO: find a way to make the following code work when we use "cfx run": // http://mxr.mozilla.org/mozilla-central/source/browser/base/content/browser.js#8586 // until then, force display of addon bar directly from sdk code // https://bugzilla.mozilla.org/show_bug.cgi?id=627484 if (container.collapsed) this.window.toggleAddonBar(); } // Now retrieve a reference to the next toolbar item // by reading currentset attribute on the toolbar let nextNode = null; let currentSet = container.getAttribute("currentset"); let ids = (currentSet == "__empty") ? [] : currentSet.split(","); let idx = ids.indexOf(id); if (idx != -1) { for (let i = idx; i < ids.length; i++) { nextNode = this.doc.getElementById(ids[i]); if (nextNode) break; } } // Finally insert our widget in the right toolbar and in the right position container.insertItem(id, nextNode, null, false); // Update DOM in order to save position if we remove/readd the widget container.setAttribute("currentset", container.currentSet); // Save DOM attribute in order to save position on new window opened this.window.document.persist(container.id, "currentset"); } } /** * Final Widget class that handles chrome DOM Node: * - create initial DOM nodes * - receive instruction from WidgetView through update method and update DOM * - watch for DOM events and forward them to WidgetView */ function WidgetChrome(options) { this.window = options.window; this._doc = options.doc; this._widget = options.widget; this._symbiont = null; // set later this.node = null; // set later this._createNode(); } // Update a property of a widget. WidgetChrome.prototype.update = function WC_update(updatedItem, property, value) { switch(property) { case "contentURL": case "content": this.setContent(); break; case "width": this.node.style.minWidth = value + "px"; this.node.querySelector("iframe").style.width = value + "px"; break; case "tooltip": this.node.setAttribute("tooltiptext", value); break; case "postMessage": this._symbiont.postMessage(value); break; case "emit": let port = this._symbiont.port; port.emit.apply(port, value); break; } } // Add a widget to this window. WidgetChrome.prototype._createNode = function WC__createNode() { // XUL element container for widget let node = this._doc.createElement("toolbaritem"); let guid = require("api-utils/xpcom").makeUuid().toString(); // Temporary work around require("self") failing on unit-test execution ... let jetpackID = "testID"; try { jetpackID = require("self").id; } catch(e) {} // Compute an unique and stable widget id with jetpack id and widget.id let id = "widget:" + jetpackID + "-" + this._widget.id; node.setAttribute("id", id); node.setAttribute("label", this._widget.label); node.setAttribute("tooltiptext", this._widget.tooltip); node.setAttribute("align", "center"); // TODO move into a stylesheet, configurable by consumers. // Either widget.style, exposing the style object, or a URL // (eg, can load local stylesheet file). node.setAttribute("style", [ "overflow: hidden; margin: 1px 2px 1px 2px; padding: 0px;", "min-height: 16px;", ].join("")); node.style.minWidth = this._widget.width + "px"; this.node = node; } // Initial population of a widget's content. WidgetChrome.prototype.fill = function WC_fill() { // Create element var iframe = this._doc.createElement("iframe"); iframe.setAttribute("type", "content"); iframe.setAttribute("transparent", "transparent"); iframe.style.overflow = "hidden"; iframe.style.height = "16px"; iframe.style.maxHeight = "16px"; iframe.style.width = this._widget.width + "px"; iframe.setAttribute("flex", "1"); iframe.style.border = "none"; iframe.style.padding = "0px"; // Do this early, because things like contentWindow are null // until the node is attached to a document. this.node.appendChild(iframe); // add event handlers this.addEventHandlers(); // set content this.setContent(); } // Get widget content type. WidgetChrome.prototype.getContentType = function WC_getContentType() { if (this._widget.content) return CONTENT_TYPE_HTML; return (this._widget.contentURL && /\.(jpg|gif|png|ico)$/.test(this._widget.contentURL)) ? CONTENT_TYPE_IMAGE : CONTENT_TYPE_URI; } // Set widget content. WidgetChrome.prototype.setContent = function WC_setContent() { let type = this.getContentType(); let contentURL = null; switch (type) { case CONTENT_TYPE_HTML: contentURL = "data:text/html," + encodeURIComponent(this._widget.content); break; case CONTENT_TYPE_URI: contentURL = this._widget.contentURL; break; case CONTENT_TYPE_IMAGE: let imageURL = this._widget.contentURL; contentURL = "data:text/html,"; break; default: throw new Error("The widget's type cannot be determined."); } let iframe = this.node.firstElementChild; let self = this; // Cleanup previously created symbiont (in case we are update content) if (this._symbiont) this._symbiont.destroy(); this._symbiont = Trait.compose(Symbiont.resolve({ _onContentScriptEvent: "_onContentScriptEvent-not-used" }), { _onContentScriptEvent: function () { // Redirect events to WidgetView self._widget._onPortEvent(arguments); } })({ frame: iframe, contentURL: contentURL, contentScriptFile: this._widget.contentScriptFile, contentScript: this._widget.contentScript, contentScriptWhen: this._widget.contentScriptWhen, allow: this._widget.allow, onMessage: function(message) { timer.setTimeout(function() { self._widget._onEvent("message", message); }, 0); } }); } // Detect if document consists of a single image. WidgetChrome._isImageDoc = function WC__isImageDoc(doc) { return doc.body.childNodes.length == 1 && doc.body.firstElementChild && doc.body.firstElementChild.tagName == "IMG"; } // Set up all supported events for a widget. WidgetChrome.prototype.addEventHandlers = function WC_addEventHandlers() { let contentType = this.getContentType(); let self = this; let listener = function(e) { // Ignore event firings that target the iframe. if (e.target == self.node.firstElementChild) return; // The widget only supports left-click for now, // so ignore right-clicks. if (e.type == "click" && e.button == 2) return; // Proxy event to the widget timer.setTimeout(function() { self._widget._onEvent(EVENTS[e.type], null, self.node); }, 0); }; this.eventListeners = {}; let iframe = this.node.firstElementChild; for (let [type, method] in Iterator(EVENTS)) { iframe.addEventListener(type, listener, true, true); // Store listeners for later removal this.eventListeners[type] = listener; } // On document load, make modifications required for nice default // presentation. let self = this; function loadListener(e) { // Ignore event firings that target the iframe if (e.target == iframe) return; // Ignore about:blank loads if (e.type == "load" && e.target.location == "about:blank") return; // We may have had an unload event before that cleaned up the symbiont if (!self._symbiont) self.setContent(); let doc = e.target; if (contentType == CONTENT_TYPE_IMAGE || WidgetChrome._isImageDoc(doc)) { // Force image content to size. // Add-on authors must size their images correctly. doc.body.firstElementChild.style.width = self._widget.width + "px"; doc.body.firstElementChild.style.height = "16px"; } // Allow all content to fill the box by default. doc.body.style.margin = "0"; } iframe.addEventListener("load", loadListener, true); this.eventListeners["load"] = loadListener; // Register a listener to unload symbiont if the toolbaritem is moved // on user toolbars customization function unloadListener(e) { if (e.target.location == "about:blank") return; self._symbiont.destroy(); self._symbiont = null; // This may fail but not always, it depends on how the node is // moved or removed try { self.setContent(); } catch(e) {} } iframe.addEventListener("unload", unloadListener, true); this.eventListeners["unload"] = unloadListener; } // Remove and unregister the widget from everything WidgetChrome.prototype.destroy = function WC_destroy(removedItems) { // remove event listeners for (let [type, listener] in Iterator(this.eventListeners)) this.node.firstElementChild.removeEventListener(type, listener, true); // remove dom node this.node.parentNode.removeChild(this.node); // cleanup symbiont this._symbiont.destroy(); // cleanup itself this.eventListeners = null; this._widget = null; this._symbiont = null; } // Init the browserManager only after setting prototypes and such above, because // it will cause browserManager.onTrack to be called immediately if there are // open windows. browserManager.init();