From b1a00d7c72053d21693f52f4334007dc77ebeb06 Mon Sep 17 00:00:00 2001 From: Bill McCloskey Date: Wed, 3 Jun 2015 15:34:44 -0700 Subject: [PATCH] Bug 1175770 - New extension API (r=Mossop) --- browser/base/content/tab-content.js | 6 + browser/components/extensions/bootstrap.js | 20 + .../extensions/ext-browserAction.js | 326 ++++++++++ .../components/extensions/ext-contextMenus.js | 8 + browser/components/extensions/ext-tabs.js | 486 +++++++++++++++ browser/components/extensions/ext-utils.js | 324 ++++++++++ browser/components/extensions/ext-windows.js | 151 +++++ browser/components/extensions/extension.svg | 19 + browser/components/extensions/jar.mn | 11 + browser/components/extensions/moz.build | 7 + browser/components/extensions/prepare.py | 83 +++ browser/components/moz.build | 1 + browser/components/nsBrowserGlue.js | 9 + browser/modules/E10SUtils.jsm | 9 + browser/themes/linux/browser.css | 4 + browser/themes/osx/browser.css | 4 + browser/themes/windows/browser.css | 4 + toolkit/components/extensions/Extension.jsm | 578 ++++++++++++++++++ .../extensions/ExtensionContent.jsm | 521 ++++++++++++++++ .../extensions/ExtensionManagement.jsm | 201 ++++++ .../extensions/ExtensionStorage.jsm | 149 +++++ .../components/extensions/ExtensionUtils.jsm | 540 ++++++++++++++++ toolkit/components/extensions/ext-alarms.js | 168 +++++ .../extensions/ext-backgroundPage.js | 92 +++ .../components/extensions/ext-extension.js | 10 + toolkit/components/extensions/ext-i18n.js | 9 + toolkit/components/extensions/ext-idle.js | 9 + .../extensions/ext-notifications.js | 140 +++++ toolkit/components/extensions/ext-runtime.js | 47 ++ toolkit/components/extensions/ext-storage.js | 48 ++ .../extensions/ext-webNavigation.js | 70 +++ .../components/extensions/ext-webRequest.js | 104 ++++ toolkit/components/extensions/jar.mn | 16 + toolkit/components/extensions/moz.build | 15 + toolkit/components/moz.build | 1 + toolkit/components/utils/simpleServices.js | 6 +- toolkit/modules/Locale.jsm | 93 +++ toolkit/modules/addons/MatchPattern.jsm | 13 +- toolkit/modules/moz.build | 1 + .../extensions/internal/XPIProvider.jsm | 84 +-- 40 files changed, 4303 insertions(+), 84 deletions(-) create mode 100644 browser/components/extensions/bootstrap.js create mode 100644 browser/components/extensions/ext-browserAction.js create mode 100644 browser/components/extensions/ext-contextMenus.js create mode 100644 browser/components/extensions/ext-tabs.js create mode 100644 browser/components/extensions/ext-utils.js create mode 100644 browser/components/extensions/ext-windows.js create mode 100644 browser/components/extensions/extension.svg create mode 100644 browser/components/extensions/jar.mn create mode 100644 browser/components/extensions/moz.build create mode 100644 browser/components/extensions/prepare.py create mode 100644 toolkit/components/extensions/Extension.jsm create mode 100644 toolkit/components/extensions/ExtensionContent.jsm create mode 100644 toolkit/components/extensions/ExtensionManagement.jsm create mode 100644 toolkit/components/extensions/ExtensionStorage.jsm create mode 100644 toolkit/components/extensions/ExtensionUtils.jsm create mode 100644 toolkit/components/extensions/ext-alarms.js create mode 100644 toolkit/components/extensions/ext-backgroundPage.js create mode 100644 toolkit/components/extensions/ext-extension.js create mode 100644 toolkit/components/extensions/ext-i18n.js create mode 100644 toolkit/components/extensions/ext-idle.js create mode 100644 toolkit/components/extensions/ext-notifications.js create mode 100644 toolkit/components/extensions/ext-runtime.js create mode 100644 toolkit/components/extensions/ext-storage.js create mode 100644 toolkit/components/extensions/ext-webNavigation.js create mode 100644 toolkit/components/extensions/ext-webRequest.js create mode 100644 toolkit/components/extensions/jar.mn create mode 100644 toolkit/components/extensions/moz.build create mode 100644 toolkit/modules/Locale.jsm diff --git a/browser/base/content/tab-content.js b/browser/base/content/tab-content.js index ea7179586b41..75ede343ec1a 100644 --- a/browser/base/content/tab-content.js +++ b/browser/base/content/tab-content.js @@ -9,6 +9,7 @@ let {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/ExtensionContent.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "E10SUtils", "resource:///modules/E10SUtils.jsm"); @@ -656,3 +657,8 @@ let DOMFullscreenHandler = { } }; DOMFullscreenHandler.init(); + +ExtensionContent.init(this); +addEventListener("unload", () => { + ExtensionContent.uninit(this); +}); diff --git a/browser/components/extensions/bootstrap.js b/browser/components/extensions/bootstrap.js new file mode 100644 index 000000000000..19a648bb7274 --- /dev/null +++ b/browser/components/extensions/bootstrap.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"; + +Components.utils.import("resource://gre/modules/Extension.jsm"); + +let extension; + +function startup(data, reason) +{ + extension = new Extension(data); + extension.startup(); +} + +function shutdown(data, reason) +{ + extension.shutdown(); +} diff --git a/browser/components/extensions/ext-browserAction.js b/browser/components/extensions/ext-browserAction.js new file mode 100644 index 000000000000..f29cd2038a47 --- /dev/null +++ b/browser/components/extensions/ext-browserAction.js @@ -0,0 +1,326 @@ +XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI", + "resource:///modules/CustomizableUI.jsm"); + +Cu.import("resource://gre/modules/devtools/event-emitter.js"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + EventManager, + DefaultWeakMap, + ignoreEvent, + runSafe, +} = ExtensionUtils; + +// WeakMap[Extension -> BrowserAction] +let browserActionMap = new WeakMap(); + +function browserActionOf(extension) +{ + return browserActionMap.get(extension); +} + +function makeWidgetId(id) +{ + id = id.toLowerCase(); + return id.replace(/[^a-z0-9_-]/g, "_"); +} + +let nextActionId = 0; + +// Responsible for the browser_action section of the manifest as well +// as the associated popup. +function BrowserAction(options, extension) +{ + this.extension = extension; + this.id = makeWidgetId(extension.id) + "-browser-action"; + this.widget = null; + + this.title = new DefaultWeakMap(extension.localize(options.default_title)); + this.badgeText = new DefaultWeakMap(); + this.badgeBackgroundColor = new DefaultWeakMap(); + this.icon = new DefaultWeakMap(options.default_icon); + this.popup = new DefaultWeakMap(options.default_popup); + + // Make the default something that won't compare equal to anything. + this.prevPopups = new DefaultWeakMap({}); + + this.context = null; +} + +BrowserAction.prototype = { + build() { + let widget = CustomizableUI.createWidget({ + id: this.id, + type: "custom", + removable: true, + defaultArea: CustomizableUI.AREA_NAVBAR, + onBuild: document => { + let node = document.createElement("toolbarbutton"); + node.id = this.id; + node.setAttribute("class", "toolbarbutton-1 chromeclass-toolbar-additional badged-button"); + node.setAttribute("constrain-size", "true"); + + this.updateTab(null, node); + + let tabbrowser = document.defaultView.gBrowser; + tabbrowser.ownerDocument.addEventListener("TabSelect", () => { + this.updateTab(tabbrowser.selectedTab, node); + }); + + node.addEventListener("command", event => { + if (node.getAttribute("type") != "panel") { + this.emit("click"); + } + }); + + return node; + }, + }); + this.widget = widget; + }, + + // Initialize the toolbar icon and popup given that |tab| is the + // current tab and |node| is the CustomizableUI node. Note: |tab| + // will be null if we don't know the current tab yet (during + // initialization). + updateTab(tab, node) { + let window = node.ownerDocument.defaultView; + + let title = this.getProperty(tab, "title"); + if (title) { + node.setAttribute("tooltiptext", title); + node.setAttribute("label", title); + } else { + node.removeAttribute("tooltiptext"); + node.removeAttribute("label"); + } + + let badgeText = this.badgeText.get(tab); + if (badgeText) { + node.setAttribute("badge", badgeText); + } else { + node.removeAttribute("badge"); + } + + function toHex(n) { + return Math.floor(n / 16).toString(16) + (n % 16).toString(16); + } + + let badgeNode = node.ownerDocument.getAnonymousElementByAttribute(node, + 'class', 'toolbarbutton-badge'); + if (badgeNode) { + let color = this.badgeBackgroundColor.get(tab); + if (Array.isArray(color)) { + color = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; + } + badgeNode.style.backgroundColor = color; + } + + let iconURL = this.getIcon(tab, node); + node.setAttribute("image", iconURL); + + let popup = this.getProperty(tab, "popup"); + + if (popup != this.prevPopups.get(window)) { + this.prevPopups.set(window, popup); + + let panel = node.querySelector("panel"); + if (panel) { + panel.remove(); + } + + if (popup) { + let popupURL = this.extension.baseURI.resolve(popup); + node.setAttribute("type", "panel"); + + let document = node.ownerDocument; + let panel = document.createElement("panel"); + panel.setAttribute("class", "browser-action-panel"); + panel.setAttribute("type", "arrow"); + panel.setAttribute("flip", "slide"); + node.appendChild(panel); + + let browser = document.createElementNS(XUL_NS, "browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("disableglobalhistory", "true"); + browser.setAttribute("width", "500"); + browser.setAttribute("height", "500"); + panel.appendChild(browser); + + let loadListener = () => { + panel.removeEventListener("load", loadListener); + + if (this.context) { + this.context.unload(); + } + + this.context = new ExtensionPage(this.extension, { + type: "popup", + contentWindow: browser.contentWindow, + uri: Services.io.newURI(popupURL, null, null), + docShell: browser.docShell, + }); + GlobalManager.injectInDocShell(browser.docShell, this.extension, this.context); + browser.setAttribute("src", popupURL); + }; + panel.addEventListener("load", loadListener); + } else { + node.removeAttribute("type"); + } + } + }, + + // Note: tab is allowed to be null here. + getIcon(tab, node) { + let icon = this.icon.get(tab); + + let url; + if (typeof(icon) != "object") { + url = icon; + } else { + let window = node.ownerDocument.defaultView; + let utils = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Components.interfaces.nsIDOMWindowUtils); + let res = {value: 1} + utils.getResolution(res); + + let size = res.value == 1 ? 19 : 38; + url = icon[size]; + } + + if (url) { + return this.extension.baseURI.resolve(url); + } else { + return "chrome://browser/content/extension.svg"; + } + }, + + // Update the toolbar button for a given window. + updateWindow(window) { + let tab = window.gBrowser ? window.gBrowser.selectedTab : null; + let node = CustomizableUI.getWidget(this.id).forWindow(window).node; + this.updateTab(tab, node); + }, + + // Update the toolbar button when the extension changes the icon, + // title, badge, etc. If it only changes a parameter for a single + // tab, |tab| will be that tab. Otherwise it will be null. + updateOnChange(tab) { + if (tab) { + if (tab.selected) { + this.updateWindow(tab.ownerDocument.defaultView); + } + } else { + let e = Services.wm.getEnumerator("navigator:browser"); + while (e.hasMoreElements()) { + let window = e.getNext(); + if (window.gBrowser) { + this.updateWindow(window); + } + } + } + }, + + // tab is allowed to be null. + // prop should be one of "icon", "title", "badgeText", "popup", or "badgeBackgroundColor". + setProperty(tab, prop, value) { + this[prop].set(tab, value); + this.updateOnChange(tab); + }, + + // tab is allowed to be null. + // prop should be one of "title", "badgeText", "popup", or "badgeBackgroundColor". + getProperty(tab, prop) { + return this[prop].get(tab); + }, + + shutdown() { + CustomizableUI.destroyWidget(this.id); + }, +}; + +EventEmitter.decorate(BrowserAction.prototype); + +extensions.on("manifest_browser_action", (type, directive, extension, manifest) => { + let browserAction = new BrowserAction(manifest.browser_action, extension); + browserAction.build(); + browserActionMap.set(extension, browserAction); +}); + +extensions.on("shutdown", (type, extension) => { + if (browserActionMap.has(extension)) { + browserActionMap.get(extension).shutdown(); + browserActionMap.delete(extension); + } +}); + +extensions.registerAPI((extension, context) => { + return { + browserAction: { + onClicked: new EventManager(context, "browserAction.onClicked", fire => { + let listener = () => { + let tab = TabManager.activeTab; + fire(TabManager.convert(extension, tab)); + }; + browserActionOf(extension).on("click", listener); + return () => { + browserActionOf(extension).off("click", listener); + }; + }).api(), + + setTitle: function(details) { + let tab = details.tabId ? TabManager.getTab(details.tabId) : null; + browserActionOf(extension).setProperty(tab, "title", details.title); + }, + + getTitle: function(details, callback) { + let tab = details.tabId ? TabManager.getTab(details.tabId) : null; + let title = browserActionOf(extension).getProperty(tab, "title"); + runSafe(context, callback, title); + }, + + setIcon: function(details, callback) { + let tab = details.tabId ? TabManager.getTab(details.tabId) : null; + if (details.imageData) { + // FIXME: Support the imageData attribute. + return; + } + browserActionOf(extension).setProperty(tab, "icon", details.path); + }, + + setBadgeText: function(details) { + let tab = details.tabId ? TabManager.getTab(details.tabId) : null; + browserActionOf(extension).setProperty(tab, "badgeText", details.text); + }, + + getBadgeText: function(details, callback) { + let tab = details.tabId ? TabManager.getTab(details.tabId) : null; + let text = browserActionOf(extension).getProperty(tab, "badgeText"); + runSafe(context, callback, text); + }, + + setPopup: function(details) { + let tab = details.tabId ? TabManager.getTab(details.tabId) : null; + browserActionOf(extension).setProperty(tab, "popup", details.popup); + }, + + getPopup: function(details, callback) { + let tab = details.tabId ? TabManager.getTab(details.tabId) : null; + let popup = browserActionOf(extension).getProperty(tab, "popup"); + runSafe(context, callback, popup); + }, + + setBadgeBackgroundColor: function(details) { + let color = details.color; + let tab = details.tabId ? TabManager.getTab(details.tabId) : null; + browserActionOf(extension).setProperty(tab, "badgeBackgroundColor", details.color); + }, + + getBadgeBackgroundColor: function(details, callback) { + let tab = details.tabId ? TabManager.getTab(details.tabId) : null; + let color = browserActionOf(extension).getProperty(tab, "badgeBackgroundColor"); + runSafe(context, callback, color); + }, + } + }; +}); diff --git a/browser/components/extensions/ext-contextMenus.js b/browser/components/extensions/ext-contextMenus.js new file mode 100644 index 000000000000..671318ab322e --- /dev/null +++ b/browser/components/extensions/ext-contextMenus.js @@ -0,0 +1,8 @@ +extensions.registerPrivilegedAPI("contextMenus", (extension, context) => { + return { + contextMenus: { + create() {}, + removeAll() {}, + }, + }; +}); diff --git a/browser/components/extensions/ext-tabs.js b/browser/components/extensions/ext-tabs.js new file mode 100644 index 000000000000..4050cd67d2ff --- /dev/null +++ b/browser/components/extensions/ext-tabs.js @@ -0,0 +1,486 @@ +XPCOMUtils.defineLazyModuleGetter(this, "NewTabURL", + "resource:///modules/NewTabURL.jsm"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + EventManager, + ignoreEvent, + runSafe, +} = ExtensionUtils; + +// This function is pretty tightly tied to Extension.jsm. +// Its job is to fill in the |tab| property of the sender. +function getSender(context, target, sender) +{ + // The message was sent from a content script to a element. + // We can just get the |tab| from |target|. + if (target instanceof Ci.nsIDOMXULElement) { + // The message came from a content script. + let tabbrowser = target.ownerDocument.defaultView.gBrowser; + if (!tabbrowser) { + return; + } + let tab = tabbrowser.getTabForBrowser(target); + + sender.tab = TabManager.convert(context.extension, tab); + } else { + // The message came from an ExtensionPage. In that case, it should + // include a tabId property (which is filled in by the page-open + // listener below). + if ("tabId" in sender) { + sender.tab = TabManager.convert(context.extension, TabManager.getTab(sender.tabId)); + delete sender.tabId; + } + } +} + +// WeakMap[ExtensionPage -> {tab, parentWindow}] +let pageDataMap = new WeakMap(); + +// This listener fires whenever an extension page opens in a tab +// (either initiated by the extension or the user). Its job is to fill +// in some tab-specific details and keep data around about the +// ExtensionPage. +extensions.on("page-load", (type, page, params, sender, delegate) => { + if (params.type == "tab") { + let browser = params.docShell.chromeEventHandler; + let parentWindow = browser.ownerDocument.defaultView; + let tab = parentWindow.gBrowser.getTabForBrowser(browser); + sender.tabId = TabManager.getId(tab); + + pageDataMap.set(page, {tab, parentWindow}); + } + + delegate.getSender = getSender; +}); + +extensions.on("page-unload", (type, page) => { + pageDataMap.delete(page); +}); + +extensions.on("page-shutdown", (type, page) => { + if (pageDataMap.has(page)) { + let {tab, parentWindow} = pageDataMap.get(page); + pageDataMap.delete(page); + + parentWindow.gBrowser.removeTab(tab); + } +}); + +extensions.on("fill-browser-data", (type, browser, data, result) => { + let tabId = TabManager.getBrowserId(browser); + if (tabId == -1) { + result.cancel = true; + return; + } + + data.tabId = tabId; +}); + +// TODO: activeTab permission + +extensions.registerAPI((extension, context) => { + let self = { + tabs: { + onActivated: new WindowEventManager(context, "tabs.onActivated", "TabSelect", (fire, event) => { + let tab = event.originalTarget; + let tabId = TabManager.getId(tab); + let windowId = WindowManager.getId(tab.ownerDocument.defaultView); + fire({tabId, windowId}); + }).api(), + + onCreated: new EventManager(context, "tabs.onCreated", fire => { + let listener = event => { + let tab = event.originalTarget; + fire({tab: TabManager.convert(extension, tab)}); + }; + + let windowListener = window => { + for (let tab of window.gBrowser.tabs) { + fire({tab: TabManager.convert(extension, tab)}); + } + }; + + WindowListManager.addOpenListener(windowListener, false); + AllWindowEvents.addListener("TabOpen", listener); + return () => { + WindowListManager.removeOpenListener(windowListener); + AllWindowEvents.removeListener("TabOpen", listener); + }; + }).api(), + + onUpdated: new EventManager(context, "tabs.onUpdated", fire => { + function sanitize(extension, changeInfo) { + let result = {}; + let nonempty = false; + for (let prop in changeInfo) { + if ((prop != "favIconUrl" && prop != "url") || extension.hasPermission("tabs")) { + nonempty = true; + result[prop] = changeInfo[prop]; + } + } + return [nonempty, result]; + } + + let listener = event => { + let tab = event.originalTarget; + let window = tab.ownerDocument.defaultView; + let tabId = TabManager.getId(tab); + + let changeInfo = {}; + let needed = false; + if (event.type == "TabAttrModified") { + if (event.detail.changed.indexOf("image") != -1) { + changeInfo.favIconUrl = window.gBrowser.getIcon(tab); + needed = true; + } + } else if (event.type == "TabPinned") { + changeInfo.pinned = true; + needed = true; + } else if (event.type == "TabUnpinned") { + changeInfo.pinned = false; + needed = true; + } + + [needed, changeInfo] = sanitize(extension, changeInfo); + if (needed) { + fire(tabId, changeInfo, TabManager.convert(extension, tab)); + } + }; + let progressListener = { + onStateChange(browser, webProgress, request, stateFlags, statusCode) { + if (!webProgress.isTopLevel) { + return; + } + + let status; + if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) { + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + status = "loading"; + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + status = "complete"; + } + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP && + statusCode == Cr.NS_BINDING_ABORTED) { + status = "complete"; + } + + let gBrowser = browser.ownerDocument.defaultView.gBrowser; + let tab = gBrowser.getTabForBrowser(browser); + let tabId = TabManager.getId(tab); + let [needed, changeInfo] = sanitize(extension, {status}); + fire(tabId, changeInfo, TabManager.convert(extension, tab)); + }, + + onLocationChange(browser, webProgress, request, locationURI, flags) { + let gBrowser = browser.ownerDocument.defaultView.gBrowser; + let tab = gBrowser.getTabForBrowser(browser); + let tabId = TabManager.getId(tab); + let [needed, changeInfo] = sanitize(extension, {url: locationURI.spec}); + if (needed) { + fire(tabId, changeInfo, TabManager.convert(extension, tab)); + } + }, + }; + + AllWindowEvents.addListener("progress", progressListener); + AllWindowEvents.addListener("TabAttrModified", listener); + AllWindowEvents.addListener("TabPinned", listener); + AllWindowEvents.addListener("TabUnpinned", listener); + return () => { + AllWindowEvents.removeListener("progress", progressListener); + AllWindowEvents.addListener("TabAttrModified", listener); + AllWindowEvents.addListener("TabPinned", listener); + AllWindowEvents.addListener("TabUnpinned", listener); + }; + }).api(), + + onReplaced: ignoreEvent(), + + onRemoved: new EventManager(context, "tabs.onRemoved", fire => { + let tabListener = event => { + let tab = event.originalTarget; + let tabId = TabManager.getId(tab); + let windowId = WindowManager.getId(tab.ownerDocument.defaultView); + let removeInfo = {windowId, isWindowClosing: false}; + fire(tabId, removeInfo); + }; + + let windowListener = window => { + for (let tab of window.gBrowser.tabs) { + let tabId = TabManager.getId(tab); + let windowId = WindowManager.getId(window); + let removeInfo = {windowId, isWindowClosing: true}; + fire(tabId, removeInfo); + } + }; + + WindowListManager.addCloseListener(windowListener); + AllWindowEvents.addListener("TabClose", tabListener); + return () => { + WindowListManager.removeCloseListener(windowListener); + AllWindowEvents.removeListener("TabClose", tabListener); + }; + }).api(), + + create: function(createProperties, callback) { + if (!createProperties) { + createProperties = {}; + } + + let url = createProperties.url || NewTabURL.get(); + url = extension.baseURI.resolve(url); + + function createInWindow(window) { + let tab = window.gBrowser.addTab(url); + + let active = true; + if ("active" in createProperties) { + active = createProperties.active; + } else if ("selected" in createProperties) { + active = createProperties.selected; + } + if (active) { + window.gBrowser.selectedTab = tab; + } + + if ("index" in createProperties) { + window.gBrowser.moveTabTo(tab, createProperties.index); + } + + if (createProperties.pinned) { + window.gBrowser.pinTab(tab); + } + + if (callback) { + runSafe(context, callback, TabManager.convert(extension, tab)); + } + } + + let window = createProperties.windowId ? + WindowManager.getWindow(createProperties.windowId) : + WindowManager.topWindow; + if (!window.gBrowser) { + let obs = (finishedWindow, topic, data) => { + if (finishedWindow != window) { + return; + } + Services.obs.removeObserver(obs, "browser-delayed-startup-finished"); + createInWindow(window); + }; + Services.obs.addObserver(obs, "browser-delayed-startup-finished", false); + } else { + createInWindow(window); + } + }, + + remove: function(tabs, callback) { + if (!Array.isArray(tabs)) { + tabs = [tabs]; + } + + for (let tabId of tabs) { + let tab = TabManager.getTab(tabId); + tab.ownerDocument.defaultView.gBrowser.removeTab(tab); + } + + if (callback) { + runSafe(context, callback); + } + }, + + update: function(...args) { + let tabId, updateProperties, callback; + if (args.length == 1) { + updateProperties = args[0]; + } else { + [tabId, updateProperties, callback] = args; + } + + let tab = tabId ? TabManager.getTab(tabId) : TabManager.activeTab; + let tabbrowser = tab.ownerDocument.gBrowser; + if ("url" in updateProperties) { + tab.linkedBrowser.loadURI(updateProperties.url); + } + if ("active" in updateProperties) { + if (updateProperties.active) { + tabbrowser.selectedTab = tab; + } else { + // Not sure what to do here? Which tab should we select? + } + } + if ("pinned" in updateProperties) { + if (updateProperties.pinned) { + tabbrowser.pinTab(tab); + } else { + tabbrowser.unpinTab(tab); + } + } + // FIXME: highlighted/selected, openerTabId + + if (callback) { + runSafe(context, callback, TabManager.convert(extension, tab)); + } + }, + + reload: function(tabId, reloadProperties, callback) { + let tab = tabId ? TabManager.getTab(tabId) : TabManager.activeTab; + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + if (reloadProperties && reloadProperties.bypassCache) { + flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + } + tab.linkedBrowser.reloadWithFlags(flags); + + if (callback) { + runSafe(context, callback); + } + }, + + get: function(tabId, callback) { + let tab = TabManager.getTab(tabId); + runSafe(context, callback, TabManager.convert(extension, tab)); + }, + + getAllInWindow: function(...args) { + let window, callback; + if (args.length == 1) { + callbacks = args[0]; + } else { + window = WindowManager.getWindow(args[0]); + callback = args[1]; + } + + if (!window) { + window = WindowManager.topWindow; + } + + return self.tabs.query({windowId: WindowManager.getId(window)}, callback); + }, + + query: function(queryInfo, callback) { + if (!queryInfo) { + queryInfo = {}; + } + + function matches(window, tab) { + let props = ["active", "pinned", "highlighted", "status", "title", "url", "index"]; + for (let prop of props) { + if (prop in queryInfo && queryInfo[prop] != tab[prop]) { + return false; + } + } + + let lastFocused = window == WindowManager.topWindow; + if ("lastFocusedWindow" in queryInfo && queryInfo.lastFocusedWindow != lastFocused) { + return false; + } + + let windowType = WindowManager.windowType(window); + if ("windowType" in queryInfo && queryInfo.windowType != windowType) { + return false; + } + + if ("windowId" in queryInfo) { + if (queryInfo.windowId == WindowManager.WINDOW_ID_CURRENT) { + if (context.contentWindow != window) { + return false; + } + } else { + if (queryInfo.windowId != tab.windowId) { + return false; + } + } + } + + if ("currentWindow" in queryInfo) { + let eq = window == context.contentWindow; + if (queryInfo.currentWindow != eq) { + return false; + } + } + + return true; + } + + let result = []; + let e = Services.wm.getEnumerator("navigator:browser"); + while (e.hasMoreElements()) { + let window = e.getNext(); + let tabs = TabManager.getTabs(extension, window); + for (let tab of tabs) { + if (matches(window, tab)) { + result.push(tab); + } + } + } + runSafe(context, callback, result); + }, + + _execute: function(tabId, details, kind, callback) { + let tab = tabId ? TabManager.getTab(tabId) : TabManager.activeTab; + let mm = tab.linkedBrowser.messageManager; + + let options = {js: [], css: []}; + if (details.code) { + options[kind + 'Code'] = details.code; + } + if (details.file) { + options[kind].push(extension.baseURI.resolve(details.file)); + } + if (details.allFrames) { + options.all_frames = details.allFrames; + } + if (details.matchAboutBlank) { + options.match_about_blank = details.matchAboutBlank; + } + if (details.runAt) { + options.run_at = details.runAt; + } + mm.sendAsyncMessage("Extension:Execute", + {extensionId: extension.id, options}); + + // TODO: Call the callback with the result (which is what???). + }, + + executeScript: function(...args) { + if (args.length == 1) { + self.tabs._execute(undefined, args[0], 'js', undefined); + } else { + self.tabs._execute(args[0], args[1], 'js', args[2]); + } + }, + + insertCss: function(tabId, details, callback) { + if (args.length == 1) { + self.tabs._execute(undefined, args[0], 'css', undefined); + } else { + self.tabs._execute(args[0], args[1], 'css', args[2]); + } + }, + + connect: function(tabId, connectInfo) { + let tab = TabManager.getTab(tabId); + let mm = tab.linkedBrowser.messageManager; + + let name = connectInfo.name || ""; + let recipient = {extensionId: extension.id}; + if ("frameId" in connectInfo) { + recipient.frameId = connectInfo.frameId; + } + return context.messenger.connect(mm, name, recipient); + }, + + sendMessage: function(tabId, message, options, responseCallback) { + let tab = TabManager.getTab(tabId); + let mm = tab.linkedBrowser.messageManager; + + let recipient = {extensionId: extension.id}; + if (options && "frameId" in options) { + recipient.frameId = options.frameId; + } + return context.messenger.sendMessage(mm, message, recipient, responseCallback); + }, + }, + }; + return self; +}); diff --git a/browser/components/extensions/ext-utils.js b/browser/components/extensions/ext-utils.js new file mode 100644 index 000000000000..83d58123bc4a --- /dev/null +++ b/browser/components/extensions/ext-utils.js @@ -0,0 +1,324 @@ +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + EventManager, +} = ExtensionUtils; + +// This file provides some useful code for the |tabs| and |windows| +// modules. All of the code is installed on |global|, which is a scope +// shared among the different ext-*.js scripts. + +// Manages mapping between XUL tabs and extension tab IDs. +global.TabManager = { + _tabs: new WeakMap(), + _nextId: 1, + + getId(tab) { + if (this._tabs.has(tab)) { + return this._tabs.get(tab); + } + let id = this._nextId++; + this._tabs.set(tab, id); + return id; + }, + + getBrowserId(browser) { + let gBrowser = browser.ownerDocument.defaultView.gBrowser; + // Some non-browser windows have gBrowser but not + // getTabForBrowser! + if (gBrowser && gBrowser.getTabForBrowser) { + let tab = gBrowser.getTabForBrowser(browser); + if (tab) { + return this.getId(tab); + } + } + return -1; + }, + + getTab(tabId) { + // FIXME: Speed this up without leaking memory somehow. + let e = Services.wm.getEnumerator("navigator:browser"); + while (e.hasMoreElements()) { + let window = e.getNext(); + if (!window.gBrowser) { + continue; + } + for (let tab of window.gBrowser.tabs) { + if (this.getId(tab) == tabId) { + return tab; + } + } + } + return null; + }, + + get activeTab() { + let window = WindowManager.topWindow; + if (window && window.gBrowser) { + return window.gBrowser.selectedTab; + } + return null; + }, + + getStatus(tab) { + return tab.getAttribute("busy") == "true" ? "loading" : "complete"; + }, + + convert(extension, tab) { + let window = tab.ownerDocument.defaultView; + let windowActive = window == WindowManager.topWindow; + let result = { + id: this.getId(tab), + index: tab._tPos, + windowId: WindowManager.getId(window), + selected: tab.selected, + highlighted: tab.selected, + active: tab.selected, + pinned: tab.pinned, + status: this.getStatus(tab), + incognito: PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser), + width: tab.linkedBrowser.clientWidth, + height: tab.linkedBrowser.clientHeight, + }; + + if (extension.hasPermission("tabs")) { + result.url = tab.linkedBrowser.currentURI.spec; + if (tab.linkedBrowser.contentTitle) { + result.title = tab.linkedBrowser.contentTitle; + } + let icon = window.gBrowser.getIcon(tab); + if (icon) { + result.favIconUrl = icon; + } + } + + return result; + }, + + getTabs(extension, window) { + if (!window.gBrowser) { + return []; + } + return [ for (tab of window.gBrowser.tabs) this.convert(extension, tab) ]; + }, +}; + +// Manages mapping between XUL windows and extension window IDs. +global.WindowManager = { + _windows: new WeakMap(), + _nextId: 0, + + WINDOW_ID_NONE: -1, + WINDOW_ID_CURRENT: -2, + + get topWindow() { + return Services.wm.getMostRecentWindow("navigator:browser"); + }, + + windowType(window) { + // TODO: Make this work. + return "normal"; + }, + + getId(window) { + if (this._windows.has(window)) { + return this._windows.get(window); + } + let id = this._nextId++; + this._windows.set(window, id); + return id; + }, + + getWindow(id) { + let e = Services.wm.getEnumerator("navigator:browser"); + while (e.hasMoreElements()) { + let window = e.getNext(); + if (this.getId(window) == id) { + return window; + } + } + return null; + }, + + convert(extension, window, getInfo) { + let result = { + id: this.getId(window), + focused: window == WindowManager.topWindow, + top: window.screenY, + left: window.screenX, + width: window.outerWidth, + height: window.outerHeight, + incognito: PrivateBrowsingUtils.isWindowPrivate(window), + + // We fudge on these next two. + type: this.windowType(window), + state: window.fullScreen ? "fullscreen" : "normal", + }; + + if (getInfo && getInfo.populate) { + results.tabs = TabManager.getTabs(extension, window); + } + + return result; + }, +}; + +// Manages listeners for window opening and closing. A window is +// considered open when the "load" event fires on it. A window is +// closed when a "domwindowclosed" notification fires for it. +global.WindowListManager = { + _openListeners: new Set(), + _closeListeners: new Set(), + + addOpenListener(listener, fireOnExisting = true) { + if (this._openListeners.length == 0 && this._closeListeners.length == 0) { + Services.ww.registerNotification(this); + } + this._openListeners.add(listener); + + let e = Services.wm.getEnumerator("navigator:browser"); + while (e.hasMoreElements()) { + let window = e.getNext(); + if (window.document.readyState != "complete") { + window.addEventListener("load", this); + } else if (fireOnExisting) { + listener(window); + } + } + }, + + removeOpenListener(listener) { + this._openListeners.delete(listener); + if (this._openListeners.length == 0 && this._closeListeners.length == 0) { + Services.ww.unregisterNotification(this); + } + }, + + addCloseListener(listener) { + if (this._openListeners.length == 0 && this._closeListeners.length == 0) { + Services.ww.registerNotification(this); + } + this._closeListeners.add(listener); + }, + + removeCloseListener(listener) { + this._closeListeners.delete(listener); + if (this._openListeners.length == 0 && this._closeListeners.length == 0) { + Services.ww.unregisterNotification(this); + } + }, + + handleEvent(event) { + let window = event.target.defaultView; + window.removeEventListener("load", this.loadListener); + if (window.document.documentElement.getAttribute("windowtype") != "navigator:browser") { + return; + } + + for (let listener of this._openListeners) { + listener(window); + } + }, + + queryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]), + + observe(window, topic, data) { + if (topic == "domwindowclosed") { + if (window.document.documentElement.getAttribute("windowtype") != "navigator:browser") { + return; + } + + window.removeEventListener("load", this); + for (let listener of this._closeListeners) { + listener(window); + } + } else { + window.addEventListener("load", this); + } + }, +}; + +// Provides a facility to listen for DOM events across all XUL windows. +global.AllWindowEvents = { + _listeners: new Map(), + + // If |type| is a normal event type, invoke |listener| each time + // that event fires in any open window. If |type| is "progress", add + // a web progress listener that covers all open windows. + addListener(type, listener) { + if (type == "domwindowopened") { + return WindowListManager.addOpenListener(listener); + } else if (type == "domwindowclosed") { + return WindowListManager.addCloseListener(listener); + } + + let needOpenListener = this._listeners.size == 0; + + if (!this._listeners.has(type)) { + this._listeners.set(type, new Set()); + } + let list = this._listeners.get(type); + list.add(listener); + + if (needOpenListener) { + WindowListManager.addOpenListener(this.openListener); + } + }, + + removeListener(type, listener) { + if (type == "domwindowopened") { + return WindowListManager.removeOpenListener(listener); + } else if (type == "domwindowclosed") { + return WindowListManager.removeCloseListener(listener); + } + + let listeners = this._listeners.get(type); + listeners.delete(listener); + if (listeners.length == 0) { + this._listeners.delete(type); + if (this._listeners.size == 0) { + WindowListManager.removeOpenListener(this.openListener); + } + } + + let e = Services.wm.getEnumerator("navigator:browser"); + while (e.hasMoreElements()) { + let window = e.getNext(); + if (type == "progress") { + window.gBrowser.removeTabsProgressListener(listener); + } else { + window.removeEventListener(type, listener); + } + } + }, + + // Runs whenever the "load" event fires for a new window. + openListener(window) { + for (let [eventType, listeners] of AllWindowEvents._listeners) { + for (let listener of listeners) { + if (eventType == "progress") { + window.gBrowser.addTabsProgressListener(listener); + } else { + window.addEventListener(eventType, listener); + } + } + } + }, +}; + +// Subclass of EventManager where we just need to call +// add/removeEventListener on each XUL window. +global.WindowEventManager = function(context, name, event, listener) +{ + EventManager.call(this, context, name, fire => { + let listener2 = (...args) => listener(fire, ...args); + AllWindowEvents.addListener(event, listener2); + return () => { + AllWindowEvents.removeListener(event, listener2); + } + }); +} + +WindowEventManager.prototype = Object.create(EventManager.prototype); diff --git a/browser/components/extensions/ext-windows.js b/browser/components/extensions/ext-windows.js new file mode 100644 index 000000000000..f83bc4b81949 --- /dev/null +++ b/browser/components/extensions/ext-windows.js @@ -0,0 +1,151 @@ +XPCOMUtils.defineLazyModuleGetter(this, "NewTabURL", + "resource:///modules/NewTabURL.jsm"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + EventManager, + ignoreEvent, + runSafe, +} = ExtensionUtils; + +extensions.registerAPI((extension, context) => { + return { + windows: { + WINDOW_ID_CURRENT: WindowManager.WINDOW_ID_CURRENT, + WINDOW_ID_NONE: WindowManager.WINDOW_ID_NONE, + + onCreated: + new WindowEventManager(context, "windows.onCreated", "domwindowopened", (fire, window) => { + fire(WindowManager.convert(extension, window)); + }).api(), + + onRemoved: + new WindowEventManager(context, "windows.onRemoved", "domwindowclosed", (fire, window) => { + fire(WindowManager.getId(window)); + }).api(), + + onFocusChanged: new EventManager(context, "windows.onFocusChanged", fire => { + // FIXME: This will send multiple messages for a single focus change. + let listener = event => { + let window = WindowManager.topWindow; + let windowId = window ? WindowManager.getId(window) : WindowManager.WINDOW_ID_NONE; + fire(windowId); + }; + AllWindowEvents.addListener("focus", listener); + AllWindowEvents.addListener("blur", listener); + return () => { + AllWindowEvents.removeListener("focus", listener); + AllWindowEvents.removeListener("blur", listener); + }; + }).api(), + + get: function(windowId, getInfo, callback) { + let window = WindowManager.getWindow(windowId); + runSafe(context, callback, WindowManager.convert(extension, window, getInfo)); + }, + + getCurrent: function(getInfo, callback) { + let window = context.contentWindow; + runSafe(context, callback, WindowManager.convert(extension, window, getInfo)); + }, + + getLastFocused: function(...args) { + let getInfo, callback; + if (args.length == 1) { + callback = args[0]; + } else { + [getInfo, callback] = args; + } + let window = WindowManager.topWindow; + runSafe(context, callback, WindowManager.convert(extension, window, getInfo)); + }, + + getAll: function(getAll, callback) { + let e = Services.wm.getEnumerator("navigator:browser"); + let windows = []; + while (e.hasMoreElements()) { + let window = e.getNext(); + windows.push(WindowManager.convert(extension, window, getInfo)); + } + runSafe(context, callback, windows); + }, + + create: function(createData, callback) { + function mkstr(s) { + let result = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); + result.data = s; + return result; + } + + let args = Cc["@mozilla.org/supports-array;1"].createInstance(Ci.nsISupportsArray); + if ("url" in createData) { + if (Array.isArray(createData.url)) { + let array = Cc["@mozilla.org/supports-array;1"].createInstance(Ci.nsISupportsArray); + for (let url of createData.url) { + array.AppendElement(mkstr(url)); + } + args.AppendElement(array); + } else { + args.AppendElement(mkstr(createData.url)); + } + } else { + args.AppendElement(mkstr(NewTabURL.get())); + } + + let extraFeatures = ""; + if ("incognito" in createData) { + if (createData.incognito) { + extraFeatures += ",private"; + } else { + extraFeatures += ",non-private"; + } + } + + let window = Services.ww.openWindow(null, "chrome://browser/content/browser.xul", "_blank", + "chrome,dialog=no,all" + extraFeatures, args); + + if ("left" in createData || "top" in createData) { + let left = "left" in createData ? createData.left : window.screenX; + let top = "top" in createData ? createData.top : window.screenY; + window.moveTo(left, top); + } + if ("width" in createData || "height" in createData) { + let width = "width" in createData ? createData.width : window.outerWidth; + let height = "height" in createData ? createData.height : window.outerHeight; + window.resizeTo(width, height); + } + + // TODO: focused, type, state + + window.addEventListener("load", function listener() { + window.removeEventListener("load", listener); + if (callback) { + runSafe(context, callback, WindowManager.convert(extension, window)); + } + }); + }, + + update: function(windowId, updateInfo, callback) { + let window = WindowManager.getWindow(windowId); + if (updateInfo.focused) { + Services.focus.activeWindow = window; + } + // TODO: All the other properties... + runSafe(context, callback, WindowManager.convert(extension, window)); + }, + + remove: function(windowId, callback) { + let window = WindowManager.getWindow(windowId); + window.close(); + + let listener = () => { + AllWindowEvents.removeListener("domwindowclosed", listener); + if (callback) { + runSafe(context, callback); + } + }; + AllWindowEvents.addListener("domwindowclosed", listener); + }, + }, + }; +}); diff --git a/browser/components/extensions/extension.svg b/browser/components/extensions/extension.svg new file mode 100644 index 000000000000..a16455253834 --- /dev/null +++ b/browser/components/extensions/extension.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/browser/components/extensions/jar.mn b/browser/components/extensions/jar.mn new file mode 100644 index 000000000000..ea0b27625cf0 --- /dev/null +++ b/browser/components/extensions/jar.mn @@ -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/. + +browser.jar: + content/browser/extension.svg (extension.svg) + content/browser/ext-utils.js (ext-utils.js) + content/browser/ext-contextMenus.js (ext-contextMenus.js) + content/browser/ext-browserAction.js (ext-browserAction.js) + content/browser/ext-tabs.js (ext-tabs.js) + content/browser/ext-windows.js (ext-windows.js) diff --git a/browser/components/extensions/moz.build b/browser/components/extensions/moz.build new file mode 100644 index 000000000000..3bbe6729759c --- /dev/null +++ b/browser/components/extensions/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +JAR_MANIFESTS += ['jar.mn'] diff --git a/browser/components/extensions/prepare.py b/browser/components/extensions/prepare.py new file mode 100644 index 000000000000..179adae45171 --- /dev/null +++ b/browser/components/extensions/prepare.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import argparse +import json +import uuid +import sys +import os.path + +parser = argparse.ArgumentParser(description='Create install.rdf from manifest.json') +parser.add_argument('--locale') +parser.add_argument('--profile') +parser.add_argument('--uuid') +parser.add_argument('dir') +args = parser.parse_args() + +manifestFile = os.path.join(args.dir, 'manifest.json') +manifest = json.load(open(manifestFile)) + +locale = args.locale +if not locale: + locale = manifest.get('default_locale', 'en-US') + +def process_locale(s): + if s.startswith('__MSG_') and s.endswith('__'): + tag = s[6:-2] + path = os.path.join(args.dir, '_locales', locale, 'messages.json') + data = json.load(open(path)) + return data[tag]['message'] + else: + return s + +id = args.uuid +if not id: + id = '{' + str(uuid.uuid4()) + '}' + +name = process_locale(manifest['name']) +desc = process_locale(manifest['description']) +version = manifest['version'] + +installFile = open(os.path.join(args.dir, 'install.rdf'), 'w') +print >>installFile, '' +print >>installFile, '>installFile, ' xmlns:em="http://www.mozilla.org/2004/em-rdf#">' +print >>installFile +print >>installFile, ' ' +print >>installFile, ' {}'.format(id) +print >>installFile, ' 2' +print >>installFile, ' {}'.format(name) +print >>installFile, ' {}'.format(desc) +print >>installFile, ' {}'.format(version) +print >>installFile, ' true' + +print >>installFile, ' ' +print >>installFile, ' ' +print >>installFile, ' {ec8030f7-c20a-464f-9b0e-13a3a9e97384}' +print >>installFile, ' 4.0' +print >>installFile, ' 50.0' +print >>installFile, ' ' +print >>installFile, ' ' + +print >>installFile, ' ' +print >>installFile, '' +installFile.close() + +bootstrapPath = os.path.join(os.path.dirname(sys.argv[0]), 'bootstrap.js') +data = open(bootstrapPath).read() +boot = open(os.path.join(args.dir, 'bootstrap.js'), 'w') +boot.write(data) +boot.close() + +if args.profile: + os.system('mkdir -p {}/extensions'.format(args.profile)) + output = open(args.profile + '/extensions/' + id, 'w') + print >>output, os.path.realpath(args.dir) + output.close() +else: + dir = os.path.realpath(args.dir) + if dir[-1] == os.sep: + dir = dir[:-1] + os.system('cd "{}"; zip ../"{}".xpi -r *'.format(args.dir, os.path.basename(dir))) diff --git a/browser/components/moz.build b/browser/components/moz.build index 8bcf35587a1e..673692144b9b 100644 --- a/browser/components/moz.build +++ b/browser/components/moz.build @@ -9,6 +9,7 @@ DIRS += [ 'customizableui', 'dirprovider', 'downloads', + 'extensions', 'feeds', 'loop', 'migration', diff --git a/browser/components/nsBrowserGlue.js b/browser/components/nsBrowserGlue.js index aad0240f2252..9f7e0d5d9280 100644 --- a/browser/components/nsBrowserGlue.js +++ b/browser/components/nsBrowserGlue.js @@ -169,6 +169,9 @@ XPCOMUtils.defineLazyModuleGetter(this, "AddonWatcher", XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager", "resource://gre/modules/LightweightThemeManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement", + "resource://gre/modules/ExtensionManagement.jsm"); + const PREF_PLUGINS_NOTIFYUSER = "plugins.update.notifyUser"; const PREF_PLUGINS_UPDATEURL = "plugins.update.url"; @@ -601,6 +604,12 @@ BrowserGlue.prototype = { os.addObserver(this, "xpi-signature-changed", false); os.addObserver(this, "autocomplete-did-enter-text", false); + ExtensionManagement.registerScript("chrome://browser/content/ext-utils.js"); + ExtensionManagement.registerScript("chrome://browser/content/ext-browserAction.js"); + ExtensionManagement.registerScript("chrome://browser/content/ext-contextMenus.js"); + ExtensionManagement.registerScript("chrome://browser/content/ext-tabs.js"); + ExtensionManagement.registerScript("chrome://browser/content/ext-windows.js"); + this._flashHangCount = 0; }, diff --git a/browser/modules/E10SUtils.jsm b/browser/modules/E10SUtils.jsm index a27c46ff8453..1798082069ca 100644 --- a/browser/modules/E10SUtils.jsm +++ b/browser/modules/E10SUtils.jsm @@ -59,6 +59,15 @@ this.E10SUtils = { mustLoadRemote = chromeReg.mustLoadURLRemotely(url); } + if (aURL.startsWith("moz-extension:")) { + canLoadRemote = false; + mustLoadRemote = false; + } + + if (aURL.startsWith("view-source:")) { + return this.canLoadURIInProcess(aURL.substr("view-source:".length), aProcess); + } + if (mustLoadRemote) return processIsRemote; diff --git a/browser/themes/linux/browser.css b/browser/themes/linux/browser.css index 856610f65477..d25dd0f72ca0 100644 --- a/browser/themes/linux/browser.css +++ b/browser/themes/linux/browser.css @@ -1951,3 +1951,7 @@ chatbox { -moz-padding-end: 0 !important; -moz-margin-end: 0 !important; } + +.browser-action-panel > .panel-arrowcontainer > .panel-arrowcontent { + padding: 0; +} diff --git a/browser/themes/osx/browser.css b/browser/themes/osx/browser.css index 0a23cfd5aae6..e7286466392d 100644 --- a/browser/themes/osx/browser.css +++ b/browser/themes/osx/browser.css @@ -3692,3 +3692,7 @@ window > chatbox { padding-left: 0; padding-right: 0; } + +.browser-action-panel > .panel-arrowcontainer > .panel-arrowcontent { + padding: 0; +} diff --git a/browser/themes/windows/browser.css b/browser/themes/windows/browser.css index 57410c902c0b..667ebf5de3de 100644 --- a/browser/themes/windows/browser.css +++ b/browser/themes/windows/browser.css @@ -2912,3 +2912,7 @@ chatbox { @media not all and (-moz-os-version: windows-xp) { %include browser-aero.css } + +.browser-action-panel > .panel-arrowcontainer > .panel-arrowcontent { + padding: 0; +} diff --git a/toolkit/components/extensions/Extension.jsm b/toolkit/components/extensions/Extension.jsm new file mode 100644 index 000000000000..c73b3eefd1cb --- /dev/null +++ b/toolkit/components/extensions/Extension.jsm @@ -0,0 +1,578 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["Extension"]; + +/* + * This file is the main entry point for extensions. When an extension + * loads, its bootstrap.js file creates a Extension instance + * and calls .startup() on it. It calls .shutdown() when the extension + * unloads. Extension manages any extension-specific state in + * the chrome process. + */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/devtools/event-emitter.js"); + +XPCOMUtils.defineLazyModuleGetter(this, "Locale", + "resource://gre/modules/Locale.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern", + "resource://gre/modules/MatchPattern.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); + +Cu.import("resource://gre/modules/ExtensionManagement.jsm"); + +// Register built-in parts of the API. Other parts may be registered +// in browser/, mobile/, or b2g/. +ExtensionManagement.registerScript("chrome://extensions/content/ext-alarms.js"); +ExtensionManagement.registerScript("chrome://extensions/content/ext-backgroundPage.js"); +ExtensionManagement.registerScript("chrome://extensions/content/ext-notifications.js"); +ExtensionManagement.registerScript("chrome://extensions/content/ext-i18n.js"); +ExtensionManagement.registerScript("chrome://extensions/content/ext-idle.js"); +ExtensionManagement.registerScript("chrome://extensions/content/ext-runtime.js"); +ExtensionManagement.registerScript("chrome://extensions/content/ext-extension.js"); +ExtensionManagement.registerScript("chrome://extensions/content/ext-webNavigation.js"); +ExtensionManagement.registerScript("chrome://extensions/content/ext-webRequest.js"); +ExtensionManagement.registerScript("chrome://extensions/content/ext-storage.js"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + MessageBroker, + Messenger, + injectAPI, +} = ExtensionUtils; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +let scriptScope = this; + +// This object loads the ext-*.js scripts that define the extension API. +let Management = { + initialized: false, + scopes: [], + apis: [], + emitter: new EventEmitter(), + + // Loads all the ext-*.js scripts currently registered. + lazyInit() { + if (this.initialized) { + return; + } + this.initialized = true; + + for (let script of ExtensionManagement.getScripts()) { + let scope = {extensions: this, global: scriptScope}; + Services.scriptloader.loadSubScript(script, scope, "UTF-8"); + + // Save the scope to avoid it being garbage collected. + this.scopes.push(scope); + } + }, + + // Called by an ext-*.js script to register an API. The |api| + // parameter should be an object of the form: + // { + // tabs: { + // create: ..., + // onCreated: ... + // } + // } + // This registers tabs.create and tabs.onCreated as part of the API. + registerAPI(api) { + this.apis.push({api}); + }, + + // Same as above, but only register the API is the add-on has the + // given permission. + registerPrivilegedAPI(permission, api) { + this.apis.push({api, permission}); + }, + + // Mash together into a single object all the APIs registered by the + // functions above. Return the merged object. + generateAPIs(extension, context) { + let obj = {}; + + // Recursively copy properties from source to dest. + function copy(dest, source) { + for (let prop in source) { + if (typeof(source[prop]) == "object") { + if (!(prop in dest)) { + dest[prop] = {}; + } + copy(dest[prop], source[prop]); + } else { + dest[prop] = source[prop]; + } + } + } + + for (let api of this.apis) { + if (api.permission) { + if (!extension.hasPermission(api.permission)) { + continue; + } + } + + api = api.api(extension, context); + copy(obj, api); + } + + return obj; + }, + + // The ext-*.js scripts can ask to be notified for certain hooks. + on(hook, callback) { + this.emitter.on(hook, callback); + }, + + // Ask to run all the callbacks that are registered for a given hook. + emit(hook, ...args) { + this.lazyInit(); + this.emitter.emit(hook, ...args); + }, +}; + +// A MessageBroker that's used to send and receive messages for +// extension pages (which run in the chrome process). +let globalBroker = new MessageBroker([Services.mm, Services.ppmm]); + +// An extension page is an execution context for any extension content +// that runs in the chrome process. It's used for background pages +// (type="background"), popups (type="popup"), and any extension +// content loaded into browser tabs (type="tab"). +// +// |params| is an object with the following properties: +// |type| is one of "background", "popup", or "tab". +// |contentWindow| is the DOM window the content runs in. +// |uri| is the URI of the content (optional). +// |docShell| is the docshell the content runs in (optional). +function ExtensionPage(extension, params) +{ + let {type, contentWindow, uri, docShell} = params; + this.extension = extension; + this.type = type; + this.contentWindow = contentWindow || null; + this.uri = uri || extension.baseURI; + this.onClose = new Set(); + + // This is the sender property passed to the Messenger for this + // page. It can be augmented by the "page-open" hook. + let sender = {id: extension.id}; + if (uri) { + sender.url = uri.spec; + } + let delegate = {}; + Management.emit("page-load", this, params, sender, delegate); + + let filter = {id: extension.id}; + this.messenger = new Messenger(this, globalBroker, sender, filter, delegate); + + this.extension.views.add(this); +} + +ExtensionPage.prototype = { + get cloneScope() { + return this.contentWindow; + }, + + callOnClose(obj) { + this.onClose.add(obj); + }, + + forgetOnClose(obj) { + this.onClose.delete(obj); + }, + + // Called when the extension shuts down. + shutdown() { + Management.emit("page-shutdown", this); + this.unload(); + }, + + // This method is called when an extension page navigates away or + // its tab is closed. + unload() { + Management.emit("page-unload", this); + + this.extension.views.delete(this); + + for (let obj of this.onClose) { + obj.close(); + } + }, +}; + +// Responsible for loading extension APIs into the right globals. +let GlobalManager = { + // Number of extensions currently enabled. + count: 0, + + // Map[docShell -> {extension, context}] where context is an ExtensionPage. + docShells: new Map(), + + // Map[extension ID -> Extension]. Determines which extension is + // responsible for content under a particular extension ID. + extensionMap: new Map(), + + init(extension) { + if (this.count == 0) { + Services.obs.addObserver(this, "content-document-global-created", false); + } + this.count++; + + this.extensionMap.set(extension.id, extension); + }, + + uninit(extension) { + this.count--; + if (this.count == 0) { + Services.obs.removeObserver(this, "content-document-global-created"); + } + + for (let [docShell, data] of this.docShells) { + if (extension == data.extension) { + this.docShells.delete(docShell); + } + } + + this.extensionMap.delete(extension.id); + }, + + injectInDocShell(docShell, extension, context) { + this.docShells.set(docShell, {extension, context}); + }, + + observe(contentWindow, topic, data) { + function inject(extension, context) { + let chromeObj = Cu.createObjectIn(contentWindow, {defineAs: "chrome"}); + let api = Management.generateAPIs(extension, context); + injectAPI(api, chromeObj); + } + + let docShell = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem) + .sameTypeRootTreeItem + .QueryInterface(Ci.nsIDocShell); + + if (this.docShells.has(docShell)) { + let {extension, context} = this.docShells.get(docShell); + inject(extension, context); + return; + } + + // We don't inject into sub-frames of a UI page. + if (contentWindow != contentWindow.top) { + return; + } + + // Find the add-on associated with this document via the + // principal's originAttributes. This value is computed by + // extensionURIToAddonID, which ensures that we don't inject our + // API into webAccessibleResources. + let principal = contentWindow.document.nodePrincipal; + let id = principal.originAttributes.addonId; + if (!this.extensionMap.has(id)) { + return; + } + let extension = this.extensionMap.get(id); + let uri = contentWindow.document.documentURIObject; + let context = new ExtensionPage(extension, {type: "tab", contentWindow, uri, docShell}); + inject(extension, context); + + let eventHandler = docShell.chromeEventHandler; + let listener = event => { + eventHandler.removeEventListener("unload", listener); + context.unload(); + }; + eventHandler.addEventListener("unload", listener, true); + }, +}; + +// We create one instance of this class per extension. |addonData| +// comes directly from bootstrap.js when initializing. +function Extension(addonData) +{ + let uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); + let uuid = uuidGenerator.generateUUID().number; + uuid = uuid.substring(1, uuid.length - 1); // Strip of { and } off the UUID. + this.uuid = uuid; + + this.addonData = addonData; + this.id = addonData.id; + this.baseURI = Services.io.newURI("moz-extension://" + uuid, null, null); + this.manifest = null; + this.localeMessages = null; + + this.views = new Set(); + + this.onStartup = null; + + this.hasShutdown = false; + this.onShutdown = new Set(); + + this.permissions = new Set(); + this.whiteListedHosts = null; + this.webAccessibleResources = new Set(); + + ExtensionManagement.startupExtension(this.uuid, this.addonData.resourceURI, this); +} + +Extension.prototype = { + // Representation of the extension to send to content + // processes. This should include anything the content process might + // need. + serialize() { + return { + id: this.id, + uuid: this.uuid, + manifest: this.manifest, + resourceURL: this.addonData.resourceURI.spec, + baseURL: this.baseURI.spec, + content_scripts: this.manifest.content_scripts || [], + webAccessibleResources: this.webAccessibleResources, + whiteListedHosts: this.whiteListedHosts.serialize(), + }; + }, + + // https://developer.chrome.com/extensions/i18n + localizeMessage(message, substitutions) { + if (message in this.localeMessages) { + let str = this.localeMessages[message].message; + + if (!substitutions) { + substitutions = []; + } + if (!Array.isArray(substitutions)) { + substitutions = [substitutions]; + } + + // https://developer.chrome.com/extensions/i18n-messages + // |str| may contain substrings of the form $1 or $PLACEHOLDER$. + // In the former case, we replace $n with substitutions[n - 1]. + // In the latter case, we consult the placeholders array. + // The placeholder may itself use $n to refer to substitutions. + let replacer = (matched, name) => { + if (name.length == 1 && name[0] >= '1' && name[0] <= '9') { + return substitutions[parseInt(name) - 1]; + } else { + let content = this.localeMessages[message].placeholders[name].content; + if (content[0] == '$') { + return replacer(matched, content[1]); + } else { + return content; + } + } + }; + return str.replace(/\$([A-Za-z_@]+)\$/, replacer) + .replace(/\$([0-9]+)/, replacer) + .replace(/\$\$/, "$"); + } + + // Check for certain pre-defined messages. + if (message == "@@extension_id") { + return this.id; + } else if (message == "@@ui_locale") { + return Locale.getLocale(); + } else if (message == "@@bidi_dir") { + return "ltr"; // FIXME + } + + Cu.reportError(`Unknown localization message ${message}`); + return "??"; + }, + + localize(str) { + if (!str) { + return str; + } + + if (str.startsWith("__MSG_") && str.endsWith("__")) { + let message = str.substring("__MSG_".length, str.length - "__".length); + return this.localizeMessage(message); + } + + return str; + }, + + readJSON(uri) { + return new Promise((resolve, reject) => { + NetUtil.asyncFetch({uri, loadUsingSystemPrincipal: true}, (inputStream, status) => { + if (!Components.isSuccessCode(status)) { + reject(status); + return; + } + let text = NetUtil.readInputStreamToString(inputStream, inputStream.available()); + try { + resolve(JSON.parse(text)); + } catch (e) { + reject(e); + } + }); + }); + }, + + readManifest() { + let manifestURI = Services.io.newURI("manifest.json", null, this.baseURI); + return this.readJSON(manifestURI); + }, + + readLocaleFile(locale) { + let dir = locale.replace("-", "_"); + let url = `_locales/${dir}/messages.json`; + let uri = Services.io.newURI(url, null, this.baseURI); + return this.readJSON(uri); + }, + + readLocaleMessages() { + let locales = []; + + // We need to base this off of this.addonData.resourceURI rather + // than baseURI since baseURI is a moz-extension URI, which always + // QIs to nsIFileURL. + let uri = Services.io.newURI("_locales", null, this.addonData.resourceURI); + if (uri instanceof Ci.nsIFileURL) { + let file = uri.file; + let enumerator; + try { + enumerator = file.directoryEntries; + } catch (e) { + return {}; + } + while (enumerator.hasMoreElements()) { + let file = enumerator.getNext().QueryInterface(Ci.nsIFile); + locales.push({ + name: file.leafName, + locales: [file.leafName.replace("_", "-")] + }); + } + } + + if (uri instanceof Ci.nsIJARURI && uri.JARFile instanceof Ci.nsIFileURL) { + let file = uri.JARFile.file; + let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance(Ci.nsIZipReader); + try { + zipReader.open(file); + let enumerator = zipReader.findEntries("_locales/*"); + while (enumerator.hasMore()) { + let name = enumerator.getNext(); + let match = name.match(new RegExp("_locales\/([^/]*)")); + if (match && match[1]) { + locales.push({ + name: match[1], + locales: [match[1].replace("_", "-")] + }); + } + } + } finally { + zipReader.close(); + } + } + + let locale = Locale.findClosestLocale(locales); + if (locale) { + return this.readLocaleFile(locale.name).catch(() => {}); + } + return {}; + }, + + runManifest(manifest) { + let permissions = manifest.permissions || []; + let webAccessibleResources = manifest.web_accessible_resources || []; + + let whitelist = []; + for (let perm of permissions) { + if (perm.match(/:\/\//)) { + whitelist.push(perm); + } else { + this.permissions.add(perm); + } + } + this.whiteListedHosts = new MatchPattern(whitelist); + + let resources = new Set(); + for (let url of webAccessibleResources) { + resources.add(url); + } + this.webAccessibleResources = resources; + + for (let directive in manifest) { + Management.emit("manifest_" + directive, directive, this, manifest); + } + + let data = Services.ppmm.initialProcessData; + if (!data["Extension:Extensions"]) { + data["Extension:Extensions"] = []; + } + let serial = this.serialize(); + data["Extension:Extensions"].push(serial); + Services.ppmm.broadcastAsyncMessage("Extension:Startup", serial); + }, + + callOnClose(obj) { + this.onShutdown.add(obj); + }, + + forgetOnClose(obj) { + this.onShutdown.delete(obj); + }, + + startup() { + GlobalManager.init(this); + + return Promise.all([this.readManifest(), this.readLocaleMessages()]).then(([manifest, messages]) => { + if (this.hasShutdown) { + return; + } + + this.manifest = manifest; + this.localeMessages = messages; + + Management.emit("startup", this); + + this.runManifest(manifest); + }).catch(e => { + dump(`Extension error: ${e} ${e.fileName}:${e.lineNumber}\n`); + Cu.reportError(e); + }); + }, + + shutdown() { + this.hasShutdown = true; + if (!this.manifest) { + return; + } + + GlobalManager.uninit(this); + + for (let view of this.views) { + view.shutdown(); + } + + for (let obj of this.onShutdown) { + obj.close(); + } + + Management.emit("shutdown", this); + + Services.ppmm.broadcastAsyncMessage("Extension:Shutdown", {id: this.id}); + + ExtensionManagement.shutdownExtension(this.uuid); + }, + + hasPermission(perm) { + return this.permissions.has(perm); + }, +}; + diff --git a/toolkit/components/extensions/ExtensionContent.jsm b/toolkit/components/extensions/ExtensionContent.jsm new file mode 100644 index 000000000000..81ea03e92128 --- /dev/null +++ b/toolkit/components/extensions/ExtensionContent.jsm @@ -0,0 +1,521 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["ExtensionContent"]; + +/* + * This file handles the content process side of extensions. It mainly + * takes care of content script injection, content script APIs, and + * messaging. + */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement", + "resource://gre/modules/ExtensionManagement.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern", + "resource://gre/modules/MatchPattern.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + runSafeWithoutClone, + MessageBroker, + Messenger, + ignoreEvent, + injectAPI, +} = ExtensionUtils; + +function isWhenBeforeOrSame(when1, when2) +{ + let table = {"document_start": 0, + "document_end": 1, + "document_idle": 2}; + return table[when1] <= table[when2]; +} + +// This is the fairly simple API that we inject into content +// scripts. +let api = context => { return { + runtime: { + connect: function(extensionId, connectInfo) { + let name = connectInfo && connectInfo.name || ""; + let recipient = extensionId ? {extensionId} : {extensionId: context.extensionId}; + return context.messenger.connect(context.messageManager, name, recipient); + }, + + getManifest: function(context) { + return context.extension.getManifest(); + }, + + getURL: function(path) { + return context.extension.baseURI.resolve(url); + }, + + onConnect: context.messenger.onConnect("runtime.onConnect"), + + onMessage: context.messenger.onMessage("runtime.onMessage"), + + sendMessage: function(...args) { + let extensionId, message, options, responseCallback; + if (args.length == 1) { + message = args[0]; + } else if (args.length == 2) { + [message, responseCallback] = args; + } else { + [extensionId, message, options, responseCallback] = args; + } + + let recipient = extensionId ? {extensionId} : {extensionId: context.extensionId}; + context.messenger.sendMessage(context.messageManager, message, recipient, responseCallback); + }, + }, + + extension: { + getURL: function(path) { + return context.extension.baseURI.resolve(url); + }, + + inIncognitoContext: PrivateBrowsingUtils.isContentWindowPrivate(context.contentWindow), + }, +}}; + +// Represents a content script. +function Script(options) +{ + this.options = options; + this.run_at = this.options.run_at; + this.js = this.options.js || []; + this.css = this.options.css || []; + + this.matches_ = new MatchPattern(this.options.matches); + this.exclude_matches_ = new MatchPattern(this.options.exclude_matches || null); + + // TODO: Support glob patterns. +} + +Script.prototype = { + matches(window) { + let uri = window.document.documentURIObject; + if (!this.matches_.matches(uri)) { + return false; + } + + if (this.exclude_matches_.matches(uri)) { + return false; + } + + if (!this.options.all_frames && window.top != window) { + return false; + } + + // TODO: match_about_blank. + + return true; + }, + + tryInject(extension, window, sandbox, shouldRun) { + if (!this.matches(window)) { + return; + } + + if (shouldRun("document_start")) { + let winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindowUtils); + + for (let url of this.css) { + url = extension.baseURI.resolve(url); + runSafeWithoutClone(winUtils.loadSheetUsingURIString, url, winUtils.AUTHOR_SHEET); + } + + if (this.options.cssCode) { + let url = "data:text/css;charset=utf-8," + encodeURIComponent(this.options.cssCode); + runSafeWithoutClone(winUtils.loadSheetUsingURIString, url, winUtils.AUTHOR_SHEET); + } + } + + let scheduled = this.run_at || "document_idle"; + if (shouldRun(scheduled)) { + for (let url of this.js) { + url = extension.baseURI.resolve(url); + Services.scriptloader.loadSubScript(url, sandbox); + } + + if (this.options.jsCode) { + Cu.evalInSandbox(this.options.jsCode, sandbox, "latest"); + } + } + }, +}; + +function getWindowMessageManager(contentWindow) +{ + let ir = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIInterfaceRequestor); + try { + return ir.getInterface(Ci.nsIContentFrameMessageManager); + } catch (e) { + // Some windows don't support this interface (hidden window). + return null; + } +} + +// Scope in which extension content script code can run. It uses +// Cu.Sandbox to run the code. There is a separate scope for each +// frame. +function ExtensionContext(extensionId, contentWindow) +{ + this.extension = ExtensionManager.get(extensionId); + this.extensionId = extensionId; + this.contentWindow = contentWindow; + + let utils = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + let outerWindowId = utils.outerWindowID; + let frameId = contentWindow == contentWindow.top ? 0 : outerWindowId; + this.frameId = frameId; + + let mm = getWindowMessageManager(contentWindow); + this.messageManager = mm; + + let prin = [contentWindow]; + if (Services.scriptSecurityManager.isSystemPrincipal(contentWindow.document.nodePrincipal)) { + // Make sure we don't hand out the system principal by accident. + prin = Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal); + } + + this.sandbox = Cu.Sandbox(prin, {sandboxPrototype: contentWindow, wantXrays: true}); + + let delegate = { + getSender(context, target, sender) { + // Nothing to do here. + } + }; + + let url = contentWindow.location.href; + let broker = ExtensionContent.getBroker(mm); + this.messenger = new Messenger(this, broker, {id: extensionId, frameId, url}, + {id: extensionId, frameId}, delegate); + + let chromeObj = Cu.createObjectIn(this.sandbox, {defineAs: "chrome"}); + injectAPI(api(this), chromeObj); + + this.onClose = new Set(); +} + +ExtensionContext.prototype = { + get cloneScope() { + return this.sandbox; + }, + + execute(script, shouldRun) { + script.tryInject(this.extension, this.contentWindow, this.sandbox, shouldRun); + }, + + callOnClose(obj) { + this.onClose.add(obj); + }, + + forgetOnClose(obj) { + this.onClose.delete(obj); + }, + + close() { + for (let obj of this.onClose) { + obj.close(); + } + }, +}; + +// Responsible for creating ExtensionContexts and injecting content +// scripts into them when new documents are created. +let DocumentManager = { + extensionCount: 0, + + // WeakMap[window -> Map[extensionId -> ExtensionContext]] + windows: new WeakMap(), + + init() { + Services.obs.addObserver(this, "document-element-inserted", false); + Services.obs.addObserver(this, "dom-window-destroyed", false); + }, + + uninit() { + Services.obs.removeObserver(this, "document-element-inserted"); + Services.obs.removeObserver(this, "dom-window-destroyed"); + }, + + getWindowState(contentWindow) { + let readyState = contentWindow.document.readyState; + if (readyState == "loading") { + return "document_start"; + } else if (readyState == "interactive") { + return "document_end"; + } else { + return "document_idle"; + } + }, + + observe: function(subject, topic, data) { + if (topic == "document-element-inserted") { + let document = subject; + let window = document && document.defaultView; + if (!document || !document.location || !window) { + return; + } + + // Make sure we only load into frames that ExtensionContent.init + // was called on (i.e., not frames for social or sidebars). + let mm = getWindowMessageManager(window); + if (!mm || !ExtensionContent.globals.has(mm)) { + return; + } + + this.windows.delete(window); + + this.trigger("document_start", window); + window.addEventListener("DOMContentLoaded", this, true); + window.addEventListener("load", this, true); + } else if (topic == "dom-window-destroyed") { + let window = subject; + if (!this.windows.has(window)) { + return; + } + + let extensions = this.windows.get(window); + for (let [extensionId, context] of extensions) { + context.close(); + } + + this.windows.delete(window); + } + }, + + handleEvent: function(event) { + let window = event.target.defaultView; + window.removeEventListener(event.type, this, true); + + // Need to check if we're still on the right page? Greasemonkey does this. + + if (event.type == "DOMContentLoaded") { + this.trigger("document_end", window); + } else if (event.type == "load") { + this.trigger("document_idle", window); + } + }, + + executeScript(global, extensionId, script) { + let window = global.content; + let extensions = this.windows.get(window); + if (!extensions) { + return; + } + let context = extensions.get(extensionId); + if (!context) { + return; + } + + // TODO: Somehow make sure we have the right permissions for this origin! + // FIXME: Need to keep this around so that I will execute it later if we're not in the right state. + context.execute(script, scheduled => scheduled == state); + }, + + enumerateWindows: function*(docShell) { + let window = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow) + yield [window, this.getWindowState(window)]; + + for (let i = 0; i < docShell.childCount; i++) { + let child = docShell.getChildAt(i).QueryInterface(Ci.nsIDocShell); + yield* this.enumerateWindows(child); + } + }, + + getContext(extensionId, window) { + if (!this.windows.has(window)) { + this.windows.set(window, new Map()); + } + let extensions = this.windows.get(window); + if (!extensions.has(extensionId)) { + let context = new ExtensionContext(extensionId, window); + extensions.set(extensionId, context); + } + return extensions.get(extensionId); + }, + + startupExtension(extensionId) { + if (this.extensionCount == 0) { + this.init(); + } + this.extensionCount++; + + let extension = ExtensionManager.get(extensionId); + for (let global of ExtensionContent.globals.keys()) { + for (let [window, state] of this.enumerateWindows(global.docShell)) { + for (let script of extension.scripts) { + if (script.matches(window)) { + let context = this.getContext(extensionId, window); + context.execute(script, scheduled => isWhenBeforeOrSame(scheduled, state)); + } + } + } + } + }, + + shutdownExtension(extensionId) { + for (let global of ExtensionContent.globals.keys()) { + for (let [window, state] of this.enumerateWindows(global.docShell)) { + let extensions = this.windows.get(window); + if (!extensions) { + continue; + } + let context = extensions.get(extensionId); + if (context) { + context.close(); + extensions.delete(extensionId); + } + } + } + + this.extensionCount--; + if (this.extensionCount == 0) { + this.uninit(); + } + }, + + trigger(when, window) { + let state = this.getWindowState(window); + for (let [extensionId, extension] of ExtensionManager.extensions) { + for (let script of extension.scripts) { + if (script.matches(window)) { + let context = this.getContext(extensionId, window); + context.execute(script, scheduled => isWhenBeforeOrSame(scheduled, state)); + } + } + } + }, +}; + +// Represents a browser extension in the content process. +function BrowserExtensionContent(data) +{ + this.id = data.id; + this.uuid = data.uuid; + this.data = data; + this.scripts = [ for (scriptData of data.content_scripts) new Script(scriptData) ]; + this.webAccessibleResources = data.webAccessibleResources; + this.whiteListedHosts = data.whiteListedHosts; + + this.manifest = data.manifest; + this.baseURI = Services.io.newURI(data.baseURL, null, null); + + let uri = Services.io.newURI(data.resourceURL, null, null); + + if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) { + // Extension.jsm takes care of this in the parent. + ExtensionManagement.startupExtension(this.uuid, uri, this); + } +}; + +BrowserExtensionContent.prototype = { + shutdown() { + if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) { + ExtensionManagement.shutdownExtension(this.uuid); + } + }, +}; + +let ExtensionManager = { + // Map[extensionId, BrowserExtensionContent] + extensions: new Map(), + + init() { + Services.cpmm.addMessageListener("Extension:Startup", this); + Services.cpmm.addMessageListener("Extension:Shutdown", this); + + if (Services.cpmm.initialProcessData && "Extension:Extensions" in Services.cpmm.initialProcessData) { + let extensions = Services.cpmm.initialProcessData["Extension:Extensions"]; + for (let data of extensions) { + this.extensions.set(data.id, new BrowserExtensionContent(data)); + DocumentManager.startupExtension(data.id); + } + } + }, + + get(extensionId) { + return this.extensions.get(extensionId); + }, + + receiveMessage({name, data}) { + let extension; + switch (name) { + case "Extension:Startup": + extension = new BrowserExtensionContent(data); + this.extensions.set(data.id, extension); + DocumentManager.startupExtension(data.id); + break; + + case "Extension:Shutdown": + extension = this.extensions.get(data.id); + extension.shutdown(); + DocumentManager.shutdownExtension(data.id); + this.extensions.delete(data.id); + break; + } + } +}; + +let ExtensionContent = { + globals: new Map(), + + init(global) { + let broker = new MessageBroker([global]); + this.globals.set(global, broker); + + global.addMessageListener("Extension:Execute", this); + + let windowId = global.content + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + global.sendAsyncMessage("Extension:TopWindowID", {windowId}); + }, + + uninit(global) { + this.globals.delete(global); + + let windowId = global.content + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + global.sendAsyncMessage("Extension:RemoveTopWindowID", {windowId}); + }, + + getBroker(messageManager) { + return this.globals.get(messageManager); + }, + + receiveMessage({target, name, data}) { + switch (name) { + case "Extension:Execute": + data.options.matches = ""; + let script = new Script(data.options); + let {extensionId} = data; + DocumentManager.executeScript(target, extensionId, script); + break; + } + }, +}; + +ExtensionManager.init(); diff --git a/toolkit/components/extensions/ExtensionManagement.jsm b/toolkit/components/extensions/ExtensionManagement.jsm new file mode 100644 index 000000000000..a79735f54400 --- /dev/null +++ b/toolkit/components/extensions/ExtensionManagement.jsm @@ -0,0 +1,201 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["ExtensionManagement"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +/* + * This file should be kept short and simple since it's loaded even + * when no extensions are running. + */ + +// Keep track of frame IDs for content windows. Mostly we can just use +// the outer window ID as the frame ID. However, the API specifies +// that top-level windows have a frame ID of 0. So we need to keep +// track of which windows are top-level. This code listens to messages +// from ExtensionContent to do that. +let Frames = { + // Window IDs of top-level content windows. + topWindowIds: new Set(), + + init() { + if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) { + return; + } + + Services.mm.addMessageListener("Extension:TopWindowID", this); + Services.mm.addMessageListener("Extension:RemoveTopWindowID", this); + }, + + isTopWindowId(windowId) { + return this.topWindowIds.has(windowId); + }, + + // Convert an outer window ID to a frame ID. An outer window ID of 0 + // is invalid. + getId(windowId) { + if (this.isTopWindowId(windowId)) { + return 0; + } else if (windowId == 0) { + return -1; + } else { + return windowId; + } + }, + + // Convert an outer window ID for a parent window to a frame + // ID. Outer window IDs follow the same convention that + // |window.top.parent === window.top|. The API works differently, + // giving a frame ID of -1 for the the parent of a top-level + // window. This function handles the conversion. + getParentId(parentWindowId, windowId) { + if (parentWindowId == windowId) { + // We have a top-level window. + return -1; + } + + // Not a top-level window. Just return the ID as normal. + return this.getId(parentWindowId); + }, + + receiveMessage({name, data}) { + switch (name) { + case "Extension:TopWindowID": + // FIXME: Need to handle the case where the content process + // crashes. Right now we leak its top window IDs. + this.topWindowIds.add(data.windowId); + break; + + case "Extension:RemoveTopWindowID": + this.topWindowIds.delete(data.windowId); + break; + } + }, +}; +Frames.init(); + +// Manage the collection of ext-*.js scripts that define the extension API. +let Scripts = { + scripts: new Set(), + + register(script) { + this.scripts.add(script); + }, + + getScripts() { + return this.scripts; + }, +}; + +// This object manages various platform-level issues related to +// moz-extension:// URIs. It lives here so that it can be used in both +// the parent and child processes. +// +// moz-extension URIs have the form moz-extension://uuid/path. Each +// extension has its own UUID, unique to the machine it's installed +// on. This is easier and more secure than using the extension ID, +// since it makes it slightly harder to fingerprint for extensions if +// each user uses different URIs for the extension. +let Service = { + initialized: false, + + // Map[uuid -> extension]. + // extension can be an Extension (parent process) or BrowserExtensionContent (child process). + uuidMap: new Map(), + + init() { + let aps = Cc["@mozilla.org/addons/policy-service;1"].getService(Ci.nsIAddonPolicyService); + aps = aps.wrappedJSObject; + this.aps = aps; + aps.setExtensionURILoadCallback(this.extensionURILoadableByAnyone.bind(this)); + aps.setExtensionURIToAddonIdCallback(this.extensionURIToAddonID.bind(this)); + }, + + // Called when a new extension is loaded. + startupExtension(uuid, uri, extension) { + if (!this.initialized) { + this.initialized = true; + this.init(); + } + + // Create the moz-extension://uuid mapping. + let handler = Services.io.getProtocolHandler("moz-extension"); + handler.QueryInterface(Ci.nsISubstitutingProtocolHandler); + handler.setSubstitution(uuid, uri); + + this.uuidMap.set(uuid, extension); + this.aps.setAddonLoadURICallback(extension.id, this.checkAddonMayLoad.bind(this, extension)); + }, + + // Called when an extension is unloaded. + shutdownExtension(uuid) { + let extension = this.uuidMap.get(uuid); + this.uuidMap.delete(uuid); + this.aps.setAddonLoadURICallback(extension.id, null); + + let handler = Services.io.getProtocolHandler("moz-extension"); + handler.QueryInterface(Ci.nsISubstitutingProtocolHandler); + handler.setSubstitution(uuid, null); + }, + + // Return true if the given URI can be loaded from arbitrary web + // content. The manifest.json |web_accessible_resources| directive + // determines this. + extensionURILoadableByAnyone(uri) { + let uuid = uri.host; + let extension = this.uuidMap.get(uuid); + if (!extension) { + return false; + } + + let path = uri.path; + if (path.length > 0 && path[0] == '/') { + path = path.substr(1); + } + return extension.webAccessibleResources.has(path); + }, + + // Checks whether a given extension can load this URI (typically via + // an XML HTTP request). The manifest.json |permissions| directive + // determines this. + checkAddonMayLoad(extension, uri) { + return extension.whiteListedHosts.matchesIgnoringPath(uri); + }, + + // Finds the add-on ID associated with a given moz-extension:// URI. + // This is used to set the addonId on the originAttributes for the + // nsIPrincipal attached to the URI. + extensionURIToAddonID(uri) { + if (this.extensionURILoadableByAnyone(uri)) { + // We don't want webAccessibleResources to be associated with + // the add-on. That way they don't get any special privileges. + return null; + } + + let uuid = uri.host; + let extension = this.uuidMap.get(uuid); + return extension ? extension.id : undefined; + }, +}; + +let ExtensionManagement = { + startupExtension: Service.startupExtension.bind(Service), + shutdownExtension: Service.shutdownExtension.bind(Service), + + registerScript: Scripts.register.bind(Scripts), + getScripts: Scripts.getScripts.bind(Scripts), + + getFrameId: Frames.getId.bind(Frames), + getParentFrameId: Frames.getParentId.bind(Frames), +}; + diff --git a/toolkit/components/extensions/ExtensionStorage.jsm b/toolkit/components/extensions/ExtensionStorage.jsm new file mode 100644 index 000000000000..125e03ea4943 --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorage.jsm @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["ExtensionStorage"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/osfile.jsm") +Cu.import("resource://gre/modules/AsyncShutdown.jsm"); + +let Path = OS.Path; +let profileDir = OS.Constants.Path.profileDir; + +let ExtensionStorage = { + cache: new Map(), + listeners: new Map(), + + extensionDir: Path.join(profileDir, "browser-extension-data"), + + getExtensionDir(extensionId) { + return Path.join(this.extensionDir, extensionId); + }, + + getStorageFile(extensionId) { + return Path.join(this.extensionDir, extensionId, "storage.js"); + }, + + read(extensionId) { + if (this.cache.has(extensionId)) { + return this.cache.get(extensionId); + } + + let path = this.getStorageFile(extensionId); + let decoder = new TextDecoder(); + let promise = OS.File.read(path); + promise = promise.then(array => { + return JSON.parse(decoder.decode(array)); + }).catch(() => { + Cu.reportError("Unable to parse JSON data for extension storage."); + return {}; + }); + this.cache.set(extensionId, promise); + return promise; + }, + + write(extensionId) { + let promise = this.read(extensionId).then(extData => { + let encoder = new TextEncoder(); + let array = encoder.encode(JSON.stringify(extData)); + let path = this.getStorageFile(extensionId); + OS.File.makeDir(this.getExtensionDir(extensionId), {ignoreExisting: true, from: profileDir}); + let promise = OS.File.writeAtomic(path, array); + return promise; + }).catch(() => { + // Make sure this promise is never rejected. + Cu.reportError("Unable to write JSON data for extension storage."); + }); + + AsyncShutdown.profileBeforeChange.addBlocker( + "ExtensionStorage: Finish writing extension data", + promise); + + return promise.then(() => { + AsyncShutdown.profileBeforeChange.removeBlocker(promise); + }); + }, + + set(extensionId, items) { + return this.read(extensionId).then(extData => { + let changes = {}; + for (let prop in items) { + changes[prop] = {oldValue: extData[prop], newValue: items[prop]}; + extData[prop] = items[prop]; + } + + let listeners = this.listeners.get(extensionId); + if (listeners) { + for (let listener of listeners) { + listener(changes); + } + } + + return this.write(extensionId); + }); + }, + + remove(extensionId, items) { + return this.read(extensionId).then(extData => { + let changes = {}; + for (let prop in items) { + changes[prop] = {oldValue: extData[prop]}; + delete extData[prop]; + } + + let listeners = this.listeners.get(extensionId); + if (listeners) { + for (let listener of listeners) { + listener(changes); + } + } + + return this.write(extensionId); + }); + }, + + get(extensionId, keys) { + return this.read(extensionId).then(extData => { + let result = {}; + if (keys === null) { + Object.assign(result, extData); + } else if (typeof(keys) == "object") { + for (let prop in keys) { + if (prop in extData) { + result[prop] = extData[prop]; + } else { + result[prop] = keys[prop]; + } + } + } else if (typeof(keys) == "string") { + result[prop] = extData[prop] || undefined; + } else { + for (let prop of keys) { + result[prop] = extData[prop] || undefined; + } + } + + return result; + }); + }, + + addOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId) || new Set(); + listeners.add(listener); + this.listeners.set(extensionId, listeners); + }, + + removeOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId); + listeners.delete(listener); + }, +}; diff --git a/toolkit/components/extensions/ExtensionUtils.jsm b/toolkit/components/extensions/ExtensionUtils.jsm new file mode 100644 index 000000000000..a92185ab8015 --- /dev/null +++ b/toolkit/components/extensions/ExtensionUtils.jsm @@ -0,0 +1,540 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["ExtensionUtils"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +// Run a function and report exceptions. +function runSafeWithoutClone(f, ...args) +{ + try { + return f(...args); + } catch (e) { + dump(`Extension error: ${e} ${e.fileName} ${e.lineNumber}\n${e.stack}\n${Error().stack}`); + Cu.reportError(e); + } +} + +// Run a function, cloning arguments into context.cloneScope, and +// report exceptions. |f| is expected to be in context.cloneScope. +function runSafe(context, f, ...args) +{ + try { + args = Cu.cloneInto(args, context.cloneScope); + } catch (e) { + dump(`runSafe failure\n${context.cloneScope}\n${Error().stack}`); + } + return runSafeWithoutClone(f, ...args); +} + +// Similar to a WeakMap, but returns a particular default value for +// |get| if a key is not present. +function DefaultWeakMap(defaultValue) +{ + this.defaultValue = defaultValue; + this.weakmap = new WeakMap(); +} + +DefaultWeakMap.prototype = { + get(key) { + if (this.weakmap.has(key)) { + return this.weakmap.get(key); + } + return this.defaultValue; + }, + + set(key, value) { + if (key) { + this.weakmap.set(key, value); + } else { + this.defaultValue = value; + } + }, +}; + +// This is a generic class for managing event listeners. Example usage: +// +// new EventManager(context, "api.subAPI", fire => { +// let listener = (...) => { +// // Fire any listeners registered with addListener. +// fire(arg1, arg2); +// }; +// // Register the listener. +// SomehowRegisterListener(listener); +// return () => { +// // Return a way to unregister the listener. +// SomehowUnregisterListener(listener); +// }; +// }).api() +// +// The result is an object with addListener, removeListener, and +// hasListener methods. |context| is an add-on scope (either an +// ExtensionPage in the chrome process or ExtensionContext in a +// content process). |name| is for debugging. |register| is a function +// to register the listener. |register| is only called once, event if +// multiple listeners are registered. |register| should return an +// unregister function that will unregister the listener. +function EventManager(context, name, register) +{ + this.context = context; + this.name = name; + this.register = register; + this.unregister = null; + this.callbacks = new Set(); + this.registered = false; +} + +EventManager.prototype = { + addListener(callback) { + if (!this.registered) { + this.context.callOnClose(this); + + let fireFunc = this.fire.bind(this); + let fireWithoutClone = this.fireWithoutClone.bind(this); + fireFunc.withoutClone = fireWithoutClone; + this.unregister = this.register(fireFunc); + } + this.callbacks.add(callback); + }, + + removeListener(callback) { + if (!this.registered) { + return; + } + + this.callbacks.delete(callback); + if (this.callbacks.length == 0) { + this.unregister(); + + this.context.forgetOnClose(this); + } + }, + + hasListener(callback) { + return this.callbacks.has(callback); + }, + + fire(...args) { + for (let callback of this.callbacks) { + runSafe(this.context, callback, ...args); + } + }, + + fireWithoutClone(...args) { + for (let callback of this.callbacks) { + runSafeWithoutClone(callback, ...args); + } + }, + + close() { + this.unregister(); + }, + + api() { + return { + addListener: callback => this.addListener(callback), + removeListener: callback => this.removeListener(callback), + hasListener: callback => this.hasListener(callback), + }; + }, +}; + +// Similar to EventManager, but it doesn't try to consolidate event +// notifications. Each addListener call causes us to register once. It +// allows extra arguments to be passed to addListener. +function SingletonEventManager(context, name, register) +{ + this.context = context; + this.name = name; + this.register = register; + this.unregister = new Map(); + context.callOnClose(this); +} + +SingletonEventManager.prototype = { + addListener(callback, ...args) { + let unregister = this.register(callback, ...args); + this.unregister.set(callback, unregister); + }, + + removeListener(callback) { + if (!this.unregister.has(callback)) { + return; + } + + let unregister = this.unregister.get(callback); + this.unregister.delete(callback); + this.unregister(); + }, + + hasListener(callback) { + return this.unregister.has(callback); + }, + + close() { + for (let unregister of this.unregister.values()) { + unregister(); + } + }, + + api() { + return { + addListener: (...args) => this.addListener(...args), + removeListener: (...args) => this.removeListener(...args), + hasListener: (...args) => this.hasListener(...args), + }; + }, +}; + +// Simple API for event listeners where events never fire. +function ignoreEvent() +{ + return { + addListener: function(context, callback) {}, + removeListener: function(context, callback) {}, + hasListener: function(context, callback) {}, + }; +} + +// Copy an API object from |source| into the scope |dest|. +function injectAPI(source, dest) +{ + for (let prop in source) { + // Skip names prefixed with '_'. + if (prop[0] == '_') { + continue; + } + + let value = source[prop]; + if (typeof(value) == "function") { + Cu.exportFunction(value, dest, {defineAs: prop}); + } else if (typeof(value) == "object") { + let obj = Cu.createObjectIn(dest, {defineAs: prop}); + injectAPI(value, obj); + } else { + dest[prop] = value; + } + } +} + +/* + * Messaging primitives. + */ + +let nextBrokerId = 1; + +let MESSAGES = [ + "Extension:Message", + "Extension:Connect", +]; + +// Receives messages from multiple message managers and directs them +// to a set of listeners. On the child side: one broker per frame +// script. On the parent side: one broker total, covering both the +// global MM and the ppmm. Message must be tagged with a recipient, +// which is an object with properties. Listeners can filter for +// messages that have a certain value for a particular property in the +// recipient. (If a message doesn't specify the given property, it's +// considered a match.) +function MessageBroker(messageManagers) +{ + this.messageManagers = messageManagers; + for (let mm of this.messageManagers) { + for (let message of MESSAGES) { + mm.addMessageListener(message, this); + } + } + + this.listeners = {message: [], connect: []}; +} + +MessageBroker.prototype = { + uninit() { + for (let mm of this.messageManagers) { + for (let message of MESSAGES) { + mm.removeMessageListener(message, this); + } + } + + this.listeners = null; + }, + + makeId() { + return nextBrokerId++; + }, + + addListener(type, listener, filter) { + this.listeners[type].push({filter, listener}); + }, + + removeListener(type, listener) { + let index = -1; + for (let i = 0; i < this.listeners[type].length; i++) { + if (this.listeners[type][i].listener == listener) { + this.listeners[type].splice(i, 1); + return; + } + } + }, + + runListeners(type, target, data) { + let listeners = []; + for (let {listener, filter} of this.listeners[type]) { + let pass = true; + for (let prop in filter) { + if (prop in data.recipient && filter[prop] != data.recipient[prop]) { + pass = false; + break; + } + } + + // Save up the list of listeners to call in case they modify the + // set of listeners. + if (pass) { + listeners.push(listener); + } + } + + for (let listener of listeners) { + listener(type, target, data.message, data.sender, data.recipient); + } + }, + + receiveMessage({name, data, target}) { + switch (name) { + case "Extension:Message": + this.runListeners("message", target, data); + break; + + case "Extension:Connect": + this.runListeners("connect", target, data); + break; + } + }, + + sendMessage(messageManager, type, message, sender, recipient) { + let data = {message, sender, recipient}; + let names = {message: "Extension:Message", connect: "Extension:Connect"}; + messageManager.sendAsyncMessage(names[type], data); + }, +}; + +// Abstraction for a Port object in the extension API. Each port has a unique ID. +function Port(context, messageManager, name, id, sender) +{ + this.context = context; + this.messageManager = messageManager; + this.name = name; + this.id = id; + this.listenerName = `Extension:Port-${this.id}`; + this.disconnectName = `Extension:Disconnect-${this.id}`; + this.sender = sender; + this.disconnected = false; +} + +Port.prototype = { + api() { + let portObj = Cu.createObjectIn(this.context.cloneScope); + + // We want a close() notification when the window is destroyed. + this.context.callOnClose(this); + + let publicAPI = { + name: this.name, + disconnect: () => { + this.disconnect(); + }, + postMessage: json => { + if (this.disconnected) { + throw "Attempt to postMessage on disconnected port"; + } + this.messageManager.sendAsyncMessage(this.listenerName, json); + }, + onDisconnect: new EventManager(this.context, "Port.onDisconnect", fire => { + let listener = () => { + if (!this.disconnected) { + fire(); + } + }; + + this.messageManager.addMessageListener(this.disconnectName, listener, true); + return () => { + this.messageManager.removeMessageListener(this.disconnectName, listener); + }; + }).api(), + onMessage: new EventManager(this.context, "Port.onMessage", fire => { + let listener = ({data}) => { + if (!this.disconnected) { + fire(data); + } + }; + + this.messageManager.addMessageListener(this.listenerName, listener); + return () => { + this.messageManager.removeMessageListener(this.listenerName, listener); + }; + }).api(), + }; + + if (this.sender) { + publicAPI.sender = this.sender; + } + + injectAPI(publicAPI, portObj); + return portObj; + }, + + disconnect() { + this.context.forgetOnClose(this); + this.disconnect = true; + this.messageManager.sendAsyncMessage(this.disconnectName); + }, + + close() { + this.disconnect(); + }, +}; + +function getMessageManager(target) +{ + if (target instanceof Ci.nsIDOMXULElement) { + return target.messageManager; + } else { + return target; + } +} + +// Each extension scope gets its own Messenger object. It handles the +// basics of sendMessage, onMessage, connect, and onConnect. +// +// |context| is the extension scope. +// |broker| is a MessageBroker used to receive and send messages. +// |sender| is an object describing the sender (usually giving its extensionId, tabId, etc.) +// |filter| is a recipient filter to apply to incoming messages from the broker. +// |delegate| is an object that must implement a few methods: +// getSender(context, messageManagerTarget, sender): returns a MessageSender +// See https://developer.chrome.com/extensions/runtime#type-MessageSender. +function Messenger(context, broker, sender, filter, delegate) +{ + this.context = context; + this.broker = broker; + this.sender = sender; + this.filter = filter; + this.delegate = delegate; +} + +Messenger.prototype = { + sendMessage(messageManager, msg, recipient, responseCallback) { + let id = this.broker.makeId(); + let replyName = `Extension:Reply-${id}`; + recipient.messageId = id; + this.broker.sendMessage(messageManager, "message", msg, this.sender, recipient); + + let onClose; + let listener = ({data: response}) => { + messageManager.removeMessageListener(replyName, listener); + this.context.forgetOnClose(onClose); + + if (response.gotData) { + // TODO: Handle failure to connect to the extension? + runSafe(this.context, responseCallback, response.data); + } + }; + onClose = { + close() { + messageManager.removeMessageListener(replyName, listener); + } + }; + if (responseCallback) { + messageManager.addMessageListener(replyName, listener); + this.context.callOnClose(onClose); + } + }, + + onMessage(name) { + return new EventManager(this.context, name, fire => { + let listener = (type, target, message, sender, recipient) => { + message = Cu.cloneInto(message, this.context.cloneScope); + if (this.delegate) { + this.delegate.getSender(this.context, target, sender); + } + sender = Cu.cloneInto(sender, this.context.cloneScope); + + let mm = getMessageManager(target); + let replyName = `Extension:Reply-${recipient.messageId}`; + + let valid = true, sent = false; + let sendResponse = data => { + if (!valid) { + return; + } + sent = true; + mm.sendAsyncMessage(replyName, {data, gotData: true}); + }; + sendResponse = Cu.exportFunction(sendResponse, this.context.cloneScope); + + let result = fire.withoutClone(message, sender, sendResponse); + if (result !== true) { + valid = false; + } + if (!sent) { + mm.sendAsyncMessage(replyName, {gotData: false}); + } + }; + + this.broker.addListener("message", listener, this.filter); + return () => { + this.broker.removeListener("message", listener); + }; + }).api(); + }, + + connect(messageManager, name, recipient) { + let portId = this.broker.makeId(); + let port = new Port(this.context, messageManager, name, portId, null); + let msg = {name, portId}; + this.broker.sendMessage(messageManager, "connect", msg, this.sender, recipient); + return port.api(); + }, + + onConnect(name) { + return new EventManager(this.context, name, fire => { + let listener = (type, target, message, sender, recipient) => { + let {name, portId} = message; + let mm = getMessageManager(target); + if (this.delegate) { + this.delegate.getSender(this.context, target, sender); + } + let port = new Port(this.context, mm, name, portId, sender); + fire.withoutClone(port.api()); + }; + + this.broker.addListener("connect", listener, this.filter); + return () => { + this.broker.removeListener("connect", listener); + }; + }).api(); + }, +}; + +let ExtensionUtils = { + runSafe, + DefaultWeakMap, + EventManager, + SingletonEventManager, + ignoreEvent, + injectAPI, + MessageBroker, + Messenger, +}; + diff --git a/toolkit/components/extensions/ext-alarms.js b/toolkit/components/extensions/ext-alarms.js new file mode 100644 index 000000000000..b534c674638c --- /dev/null +++ b/toolkit/components/extensions/ext-alarms.js @@ -0,0 +1,168 @@ +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + EventManager, + ignoreEvent, +} = ExtensionUtils; + +// WeakMap[Extension -> Set[Alarm]] +let alarmsMap = new WeakMap(); + +// WeakMap[Extension -> callback] +let alarmCallbacksMap = new WeakMap(); + +// Manages an alarm created by the extension (alarms API). +function Alarm(extension, name, alarmInfo) +{ + this.extension = extension; + this.name = name; + this.when = alarmInfo.when; + this.delayInMinutes = alarmInfo.delayInMinutes; + this.periodInMinutes = alarmInfo.periodInMinutes; + this.canceled = false; + + let delay, scheduledTime; + if (this.when) { + scheduledTime = this.when; + delay = this.when - Date.now(); + } else { + delay = this.delayInMinutes * 60 * 1000; + scheduledTime = Date.now() + delay; + } + + this.scheduledTime = scheduledTime; + + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init(this, delay, Ci.nsITimer.TYPE_ONE_SHOT); + this.timer = timer; +} + +Alarm.prototype = { + clear() { + this.timer.cancel(); + alarmsMap.get(this.extension).delete(this); + this.canceled = true; + }, + + observe(subject, topic, data) { + if (alarmCallbacksMap.has(this.extension)) { + alarmCallbacksMap.get(this.extension)(this); + } + if (this.canceled) { + return; + } + + if (!this.periodInMinutes) { + this.clear(); + return; + } + + let delay = this.periodInMinutes * 60 * 1000; + this.scheduledTime = Date.now() + delay; + this.timer.init(this, delay, Ci.nsITimer.TYPE_ONE_SHOT); + }, + + get data() { + return { + name: this.name, + scheduledTime: this.scheduledTime, + periodInMinutes: this.periodInMinutes, + }; + }, +}; + +extensions.on("startup", (type, extension) => { + alarmsMap.set(extension, new Set()); +}); + +extensions.on("shutdown", (type, extension) => { + for (let alarm of alarmsMap.get(extension)) { + alarm.clear(); + } + alarmsMap.delete(extension); +}); + +extensions.registerAPI((extension, context) => { + return { + alarms: { + create: function(...args) { + let name = "", alarmInfo; + if (args.length == 1) { + alarmInfo = args[0]; + } else { + [name, alarmInfo] = args; + } + + let alarm = new Alarm(extension, name, alarmInfo); + alarmsMap.get(extension).add(alarm); + }, + + get: function(args) { + let name = "", callback; + if (args.length == 1) { + callback = args[0]; + } else { + [name, callback] = args; + } + + for (let alarm of alarmsMap.get(extension)) { + if (alarm.name == name) { + runSafe(context, callback, alarm.data); + break; + } + } + }, + + getAll: function(callback) { + let alarms = alarmsMap.get(extension); + result = [ for (alarm of alarms) alarm.data ]; + runSafe(context, callback, result); + }, + + clear: function(...args) { + let name = "", callback; + if (args.length == 1) { + callback = args[0]; + } else { + [name, callback] = args; + } + + let alarms = alarmsMap.get(extension); + let cleared = false; + for (let alarm of alarms) { + if (alarm.name == name) { + alarm.clear(); + cleared = true; + break; + } + } + + if (callback) { + runSafe(context, callback, cleared); + } + }, + + clearAll: function(callback) { + let alarms = alarmsMap.get(extension); + let cleared = false; + for (let alarm of alarms) { + alarm.clear(); + cleared = true; + } + if (callback) { + runSafe(context, callback, cleared); + } + }, + + onAlarm: new EventManager(context, "alarms.onAlarm", fire => { + let callback = alarm => { + fire(alarm.data); + }; + + alarmCallbacksMap.set(extension, callback); + return () => { + alarmCallbacksMap.delete(extension); + }; + }).api(), + }, + }; +}); diff --git a/toolkit/components/extensions/ext-backgroundPage.js b/toolkit/components/extensions/ext-backgroundPage.js new file mode 100644 index 000000000000..4cdf28054205 --- /dev/null +++ b/toolkit/components/extensions/ext-backgroundPage.js @@ -0,0 +1,92 @@ +// WeakMap[Extension -> BackgroundPage] +let backgroundPagesMap = new WeakMap(); + +// Responsible for the background_page section of the manifest. +function BackgroundPage(options, extension) +{ + this.extension = extension; + this.scripts = options.scripts || []; + this.page = options.page || null; + this.contentWindow = null; + this.webNav = null; + this.context = null; +} + +BackgroundPage.prototype = { + build() { + let webNav = Services.appShell.createWindowlessBrowser(false); + this.webNav = webNav; + + let principal = Services.scriptSecurityManager.createCodebasePrincipal(this.extension.baseURI, + {addonId: this.extension.id}); + + let interfaceRequestor = webNav.QueryInterface(Ci.nsIInterfaceRequestor); + let docShell = interfaceRequestor.getInterface(Ci.nsIDocShell); + + this.context = new ExtensionPage(this.extension, {type: "background", docShell}); + GlobalManager.injectInDocShell(docShell, this.extension, this.context); + + docShell.createAboutBlankContentViewer(principal); + + let window = webNav.document.defaultView; + this.contentWindow = window; + this.context.contentWindow = window; + + let url; + if (this.page) { + url = this.extension.baseURI.resolve(this.page); + } else { + url = this.extension.baseURI.resolve("_blank.html"); + } + webNav.loadURI(url, 0, null, null, null); + + // TODO: Right now we run onStartup after the background page + // finishes. See if this is what Chrome does. + window.windowRoot.addEventListener("load", () => { + if (this.scripts) { + let doc = window.document; + for (let script of this.scripts) { + let url = this.extension.baseURI.resolve(script); + let tag = doc.createElement("script"); + tag.setAttribute("src", url); + tag.async = false; + doc.body.appendChild(tag); + } + } + + if (this.extension.onStartup) { + this.extension.onStartup(); + } + }, true); + }, + + shutdown() { + // Navigate away from the background page to invalidate any + // setTimeouts or other callbacks. + this.webNav.loadURI("about:blank", 0, null, null, null); + this.webNav = null; + }, +}; + +extensions.on("manifest_background", (type, directive, extension, manifest) => { + let bgPage = new BackgroundPage(manifest.background, extension); + bgPage.build(); + backgroundPagesMap.set(extension, bgPage); +}); + +extensions.on("shutdown", (type, extension) => { + if (backgroundPagesMap.has(extension)) { + backgroundPagesMap.get(extension).shutdown(); + backgroundPagesMap.delete(extension); + } +}); + +extensions.registerAPI((extension, context) => { + return { + extension: { + getBackgroundPage: function() { + return backgroundPagesMap.get(extension).contentWindow; + }, + }, + }; +}); diff --git a/toolkit/components/extensions/ext-extension.js b/toolkit/components/extensions/ext-extension.js new file mode 100644 index 000000000000..deaebebc4fed --- /dev/null +++ b/toolkit/components/extensions/ext-extension.js @@ -0,0 +1,10 @@ +extensions.registerAPI((extension, context) => { + return { + extension: { + getURL: function(url) { + return extension.baseURI.resolve(url); + }, + }, + }; +}); + diff --git a/toolkit/components/extensions/ext-i18n.js b/toolkit/components/extensions/ext-i18n.js new file mode 100644 index 000000000000..769331e8ad7e --- /dev/null +++ b/toolkit/components/extensions/ext-i18n.js @@ -0,0 +1,9 @@ +extensions.registerAPI((extension, context) => { + return { + i18n: { + getMessage: function(messageName, substitutions) { + return extension.localizeMessage(messageName, substitutions); + }, + }, + }; +}); diff --git a/toolkit/components/extensions/ext-idle.js b/toolkit/components/extensions/ext-idle.js new file mode 100644 index 000000000000..96bc3ead80bd --- /dev/null +++ b/toolkit/components/extensions/ext-idle.js @@ -0,0 +1,9 @@ +extensions.registerPrivilegedAPI("idle", (extension, context) => { + return { + idle: { + queryState: function(detectionIntervalInSeconds, callback) { + runSafe(context, callback, "active"); + }, + }, + }; +}); diff --git a/toolkit/components/extensions/ext-notifications.js b/toolkit/components/extensions/ext-notifications.js new file mode 100644 index 000000000000..0138154408cf --- /dev/null +++ b/toolkit/components/extensions/ext-notifications.js @@ -0,0 +1,140 @@ +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + EventManager, + ignoreEvent, +} = ExtensionUtils; + +// WeakMap[Extension -> Set[Notification]] +let notificationsMap = new WeakMap(); + +// WeakMap[Extension -> callback] +let notificationCallbacksMap = new WeakMap(); + +// Manages a notification popup (notifications API) created by the extension. +function Notification(extension, id, options) +{ + this.extension = extension; + this.id = id; + this.options = options; + + let imageURL; + if (options.iconUrl) { + imageURL = this.extension.baseURI.resolve(options.iconUrl); + } + + try { + let svc = Cc["@mozilla.org/alerts-service;1"].getService(Ci.nsIAlertsService); + svc.showAlertNotification(imageURL, + options.title, + options.message, + false, // textClickable + this.id, + this, + this.id); + } catch (e) { + // This will fail if alerts aren't available on the system. + } +} + +Notification.prototype = { + clear() { + try { + let svc = Cc["@mozilla.org/alerts-service;1"].getService(Ci.nsIAlertsService); + svc.closeAlert(this.id); + } catch (e) { + // This will fail if the OS doesn't support this function. + } + notificationsMap.get(this.extension).delete(this); + }, + + observe(subject, topic, data) { + if (topic != "alertfinished") { + return; + } + + if (notificationCallbacksMap.has(this.extension)) { + notificationCallbackMap.get(this.extension)(this); + } + + notificationsMap.get(this.extension).delete(this); + }, +}; + +extensions.on("startup", (type, extension) => { + notificationsMap.set(extension, new Set()); +}); + +extensions.on("shutdown", (type, extension) => { + for (let notification of notificationsMap.get(extension)) { + notification.clear(); + } + notificationsMap.delete(extension); +}); + +let nextId = 0; + +extensions.registerPrivilegedAPI("notifications", (extension, context) => { + return { + notifications: { + create: function(...args) { + let notificationId, options, callback; + if (args.length == 1) { + options = args[0]; + } else { + [notificationId, options, callback] = args; + } + + if (!notificationId) { + notificationId = nextId++; + } + + // FIXME: Lots of options still aren't supported, especially + // buttons. + let notification = new Notification(extension, notificationId, options); + notificationsMap.get(extension).add(notification); + + if (callback) { + runSafe(context, callback, notificationId); + } + }, + + clear: function(notificationId, callback) { + let notifications = notificationsMap.get(extension); + let cleared = false; + for (let notification of notifications) { + if (notification.id == notificationId) { + notification.clear(); + cleared = true; + break; + } + } + + if (callback) { + runSafe(context, callback, cleared); + } + }, + + getAll: function(callback) { + let notifications = notificationsMap.get(extension); + notifications = [ for (notification of notifications) notification.id ]; + runSafe(context, callback, notifications); + }, + + onClosed: new EventManager(context, "notifications.onClosed", fire => { + let listener = notification => { + // FIXME: Support the byUser argument. + fire(notification.id, true); + }; + + notificationCallbackMap.set(extension, listener); + return () => { + notificationCallbackMap.delete(extension); + }; + }).api(), + + // FIXME + onButtonClicked: ignoreEvent(), + onClicked: ignoreEvent(), + }, + }; +}); diff --git a/toolkit/components/extensions/ext-runtime.js b/toolkit/components/extensions/ext-runtime.js new file mode 100644 index 000000000000..e9c633b26c00 --- /dev/null +++ b/toolkit/components/extensions/ext-runtime.js @@ -0,0 +1,47 @@ +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + EventManager, + ignoreEvent, +} = ExtensionUtils; + +extensions.registerAPI((extension, context) => { + return { + runtime: { + onStartup: new EventManager(context, "runtime.onStartup", fire => { + extension.onStartup = fire; + return () => { + extension.onStartup = null; + }; + }).api(), + + onInstalled: ignoreEvent(), + + onMessage: context.messenger.onMessage("runtime.onMessage"), + + onConnect: context.messenger.onConnect("runtime.onConnect"), + + sendMessage: function(...args) { + let extensionId, message, options, responseCallback; + if (args.length == 1) { + message = args[0]; + } else if (args.length == 2) { + [message, responseCallback] = args; + } else { + [extensionId, message, options, responseCallback] = args; + } + let recipient = {extensionId: extensionId ? extensionId : extension.id}; + return context.messenger.sendMessage(Services.cpmm, message, recipient, responseCallback); + }, + + getManifest() { + return Cu.cloneInto(extension.manifest, context.cloneScope); + }, + + id: extension.id, + + getURL: function(url) { + return extension.baseURI.resolve(url); + }, + }, + }; +}); diff --git a/toolkit/components/extensions/ext-storage.js b/toolkit/components/extensions/ext-storage.js new file mode 100644 index 000000000000..c6c1530460ad --- /dev/null +++ b/toolkit/components/extensions/ext-storage.js @@ -0,0 +1,48 @@ +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage", + "resource://gre/modules/ExtensionStorage.jsm"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + EventManager, + ignoreEvent, + runSafe, +} = ExtensionUtils; + +extensions.registerPrivilegedAPI("storage", (extension, context) => { + return { + storage: { + local: { + get: function(keys, callback) { + ExtensionStorage.get(extension.id, keys).then(result => { + runSafe(context, callback, result); + }); + }, + set: function(items, callback) { + ExtensionStorage.set(extension.id, items).then(() => { + if (callback) { + runSafe(context, callback); + } + }); + }, + remove: function(items, callback) { + ExtensionStorage.remove(extension.id, items).then(() => { + if (callback) { + runSafe(context, callback); + } + }); + }, + }, + + onChanged: new EventManager(context, "storage.local.onChanged", fire => { + let listener = changes => { + fire(changes, "local"); + }; + + ExtensionStorage.addOnChangedListener(extension.id, listener); + return () => { + ExtensionStorage.removeOnChangedListener(extension.id, listener); + }; + }).api(), + }, + }; +}); diff --git a/toolkit/components/extensions/ext-webNavigation.js b/toolkit/components/extensions/ext-webNavigation.js new file mode 100644 index 000000000000..32d63f1c8be9 --- /dev/null +++ b/toolkit/components/extensions/ext-webNavigation.js @@ -0,0 +1,70 @@ +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement", + "resource://gre/modules/ExtensionManagement.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern", + "resource://gre/modules/MatchPattern.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebNavigation", + "resource://gre/modules/WebNavigation.jsm"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + SingletonEventManager, + ignoreEvent, + runSafe, +} = ExtensionUtils; + +// Similar to WebRequestEventManager but for WebNavigation. +function WebNavigationEventManager(context, eventName) +{ + let name = `webNavigation.${eventName}`; + let register = callback => { + let listener = data => { + if (!data.browser) { + return; + } + + let tabId = TabManager.getBrowserId(data.browser); + if (tabId == -1) { + return; + } + + let data2 = { + url: data.url, + timeStamp: Date.now(), + frameId: ExtensionManagement.getFrameId(data.windowId), + parentFrameId: ExtensionManagement.getParentFrameId(data.parentWindowId, data.windowId), + }; + + // Fills in tabId typically. + let result = {}; + extensions.emit("fill-browser-data", data.browser, data2, result); + if (result.cancel) { + return; + } + + return runSafe(context, callback, data2); + }; + + WebNavigation[eventName].addListener(listener); + return () => { + WebNavigation[eventName].removeListener(listener); + }; + }; + + return SingletonEventManager.call(this, context, name, register); +} + +WebNavigationEventManager.prototype = Object.create(SingletonEventManager.prototype); + +extensions.registerPrivilegedAPI("webNavigation", (extension, context) => { + return { + webNavigation: { + onBeforeNavigate: new WebNavigationEventManager(context, "onBeforeNavigate").api(), + onCommitted: new WebNavigationEventManager(context, "onCommitted").api(), + onDOMContentLoaded: new WebNavigationEventManager(context, "onDOMContentLoaded").api(), + onCompleted: new WebNavigationEventManager(context, "onCompleted").api(), + onErrorOccurred: new WebNavigationEventManager(context, "onErrorOccurred").api(), + onReferenceFragmentUpdated: new WebNavigationEventManager(context, "onReferenceFragmentUpdated").api(), + onCreatedNavigationTarget: ignoreEvent(), + }, + }; +}); diff --git a/toolkit/components/extensions/ext-webRequest.js b/toolkit/components/extensions/ext-webRequest.js new file mode 100644 index 000000000000..b6169c381c92 --- /dev/null +++ b/toolkit/components/extensions/ext-webRequest.js @@ -0,0 +1,104 @@ +XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern", + "resource://gre/modules/MatchPattern.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebRequest", + "resource://gre/modules/WebRequest.jsm"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +let { + SingletonEventManager, + runSafe, +} = ExtensionUtils; + +// EventManager-like class specifically for WebRequest. Inherits from +// SingletonEventManager. Takes care of converting |details| parameter +// when invoking listeners. +function WebRequestEventManager(context, eventName) +{ + let name = `webRequest.${eventName}`; + let register = (callback, filter, info) => { + let listener = data => { + if (!data.browser) { + return; + } + + let tabId = TabManager.getBrowserId(data.browser); + if (tabId == -1) { + return; + } + + let data2 = { + url: data.url, + method: data.method, + type: data.type, + timeStamp: Date.now(), + frameId: ExtensionManagement.getFrameId(data.windowId), + parentFrameId: ExtensionManagement.getParentFrameId(data.parentWindowId, data.windowId), + }; + + // Fills in tabId typically. + let result = {}; + extensions.emit("fill-browser-data", data.browser, data2, result); + if (result.cancel) { + return; + } + + let optional = ["requestHeaders", "responseHeaders", "statusCode"]; + for (let opt of optional) { + if (opt in data) { + data2[opt] = data[opt]; + } + } + + return runSafe(context, callback, data2); + }; + + let filter2 = {}; + filter2.urls = new MatchPattern(filter.urls); + if (filter.types) { + filter2.types = filter.types; + } + if (filter.tabId) { + filter2.tabId = filter.tabId; + } + if (filter.windowId) { + filter2.windowId = filter.windowId; + } + + let info2 = []; + if (info) { + for (let desc of info) { + if (desc == "blocking" && !context.extension.hasPermission("webRequestBlocking")) { + Cu.reportError("Using webRequest.addListener with the blocking option " + + "requires the 'webRequestBlocking' permission."); + } else { + info2.push(desc); + } + } + } + + WebRequest[eventName].addListener(listener, filter2, info2); + return () => { + WebRequest[eventName].removeListener(listener); + }; + }; + + return SingletonEventManager.call(this, context, name, register); +} + +WebRequestEventManager.prototype = Object.create(SingletonEventManager.prototype); + +extensions.registerPrivilegedAPI("webRequest", (extension, context) => { + return { + webRequest: { + onBeforeRequest: new WebRequestEventManager(context, "onBeforeRequest").api(), + onBeforeSendHeaders: new WebRequestEventManager(context, "onBeforeSendHeaders").api(), + onSendHeaders: new WebRequestEventManager(context, "onSendHeaders").api(), + onHeadersReceived: new WebRequestEventManager(context, "onHeadersReceived").api(), + onResponseStarted: new WebRequestEventManager(context, "onResponseStarted").api(), + onCompleted: new WebRequestEventManager(context, "onCompleted").api(), + handlerBehaviorChanged: function() { + // TODO: Flush all caches. + }, + }, + }; +}); diff --git a/toolkit/components/extensions/jar.mn b/toolkit/components/extensions/jar.mn new file mode 100644 index 000000000000..8f48a5653733 --- /dev/null +++ b/toolkit/components/extensions/jar.mn @@ -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/. + +toolkit.jar: +% content extensions %content/extensions/ + content/extensions/ext-alarms.js (ext-alarms.js) + content/extensions/ext-backgroundPage.js (ext-backgroundPage.js) + content/extensions/ext-notifications.js (ext-notifications.js) + content/extensions/ext-i18n.js (ext-i18n.js) + content/extensions/ext-idle.js (ext-idle.js) + content/extensions/ext-webRequest.js (ext-webRequest.js) + content/extensions/ext-webNavigation.js (ext-webNavigation.js) + content/extensions/ext-runtime.js (ext-runtime.js) + content/extensions/ext-extension.js (ext-extension.js) + content/extensions/ext-storage.js (ext-storage.js) diff --git a/toolkit/components/extensions/moz.build b/toolkit/components/extensions/moz.build new file mode 100644 index 000000000000..d6f48aa67686 --- /dev/null +++ b/toolkit/components/extensions/moz.build @@ -0,0 +1,15 @@ +# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES += [ + 'Extension.jsm', + 'ExtensionContent.jsm', + 'ExtensionManagement.jsm', + 'ExtensionStorage.jsm', + 'ExtensionUtils.jsm', +] + +JAR_MANIFESTS += ['jar.mn'] diff --git a/toolkit/components/moz.build b/toolkit/components/moz.build index 493e396881ff..479c90c58372 100644 --- a/toolkit/components/moz.build +++ b/toolkit/components/moz.build @@ -22,6 +22,7 @@ DIRS += [ 'crashmonitor', 'diskspacewatcher', 'downloads', + 'extensions', 'exthelper', 'filepicker', 'filewatcher', diff --git a/toolkit/components/utils/simpleServices.js b/toolkit/components/utils/simpleServices.js index 69b727d9eced..608c62475803 100644 --- a/toolkit/components/utils/simpleServices.js +++ b/toolkit/components/utils/simpleServices.js @@ -104,7 +104,11 @@ AddonPolicyService.prototype = { * directly. */ setAddonLoadURICallback(aAddonId, aCallback) { - this.mayLoadURICallbacks[aAddonId] = aCallback; + if (aCallback) { + this.mayLoadURICallbacks[aAddonId] = aCallback; + } else { + delete this.mayLoadURICallbacks[aAddonId]; + } }, /* diff --git a/toolkit/modules/Locale.jsm b/toolkit/modules/Locale.jsm new file mode 100644 index 000000000000..886de9e3a1c7 --- /dev/null +++ b/toolkit/modules/Locale.jsm @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +this.EXPORTED_SYMBOLS = ["Locale"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); + +const PREF_MATCH_OS_LOCALE = "intl.locale.matchOS"; +const PREF_SELECTED_LOCALE = "general.useragent.locale"; + +this.Locale = { + /** + * Gets the currently selected locale for display. + * @return the selected locale or "en-US" if none is selected + */ + getLocale() { + if (Preferences.get(PREF_MATCH_OS_LOCALE, false)) + return Services.locale.getLocaleComponentForUserAgent(); + try { + let locale = Preferences.get(PREF_SELECTED_LOCALE, null, Ci.nsIPrefLocalizedString); + if (locale) + return locale; + } + catch (e) {} + return Preferences.get(PREF_SELECTED_LOCALE, "en-US"); + }, + + /** + * Selects the closest matching locale from a list of locales. + * + * @param aLocales + * An array of locales + * @return the best match for the currently selected locale + */ + findClosestLocale(aLocales) { + let appLocale = this.getLocale(); + + // Holds the best matching localized resource + var bestmatch = null; + // The number of locale parts it matched with + var bestmatchcount = 0; + // The number of locale parts in the match + var bestpartcount = 0; + + var matchLocales = [appLocale.toLowerCase()]; + /* If the current locale is English then it will find a match if there is + a valid match for en-US so no point searching that locale too. */ + if (matchLocales[0].substring(0, 3) != "en-") + matchLocales.push("en-us"); + + for (let locale of matchLocales) { + var lparts = locale.split("-"); + for (let localized of aLocales) { + for (let found of localized.locales) { + found = found.toLowerCase(); + // Exact match is returned immediately + if (locale == found) + return localized; + + var fparts = found.split("-"); + /* If we have found a possible match and this one isn't any longer + then we dont need to check further. */ + if (bestmatch && fparts.length < bestmatchcount) + continue; + + // Count the number of parts that match + var maxmatchcount = Math.min(fparts.length, lparts.length); + var matchcount = 0; + while (matchcount < maxmatchcount && + fparts[matchcount] == lparts[matchcount]) + matchcount++; + + /* If we matched more than the last best match or matched the same and + this locale is less specific than the last best match. */ + if (matchcount > bestmatchcount || + (matchcount == bestmatchcount && fparts.length < bestpartcount)) { + bestmatch = localized; + bestmatchcount = matchcount; + bestpartcount = fparts.length; + } + } + } + // If we found a valid match for this locale return it + if (bestmatch) + return bestmatch; + } + return null; + }, +}; diff --git a/toolkit/modules/addons/MatchPattern.jsm b/toolkit/modules/addons/MatchPattern.jsm index 91d4bf6b30ca..86f089f66f00 100644 --- a/toolkit/modules/addons/MatchPattern.jsm +++ b/toolkit/modules/addons/MatchPattern.jsm @@ -63,7 +63,7 @@ function SingleMatchPattern(pat) } SingleMatchPattern.prototype = { - matches(uri) { + matches(uri, ignorePath = false) { if (this.scheme.indexOf(uri.scheme) == -1) { return false; } @@ -83,7 +83,7 @@ SingleMatchPattern.prototype = { } } - if (!this.path.test(uri.path)) { + if (!ignorePath && !this.path.test(uri.path)) { return false; } @@ -114,6 +114,15 @@ MatchPattern.prototype = { return false; }, + matchesIgnoringPath(uri) { + for (let matcher of this.matchers) { + if (matcher.matches(uri, true)) { + return true; + } + } + return false; + }, + serialize() { return this.pat; }, diff --git a/toolkit/modules/moz.build b/toolkit/modules/moz.build index 2a0cfb3f3f5b..2d8e15397992 100644 --- a/toolkit/modules/moz.build +++ b/toolkit/modules/moz.build @@ -36,6 +36,7 @@ EXTRA_JS_MODULES += [ 'InlineSpellChecker.jsm', 'InlineSpellCheckerContent.jsm', 'LoadContextInfo.jsm', + 'Locale.jsm', 'Log.jsm', 'NewTabUtils.jsm', 'ObjectUtils.jsm', diff --git a/toolkit/mozapps/extensions/internal/XPIProvider.jsm b/toolkit/mozapps/extensions/internal/XPIProvider.jsm index d363960abcac..cb59cee344bb 100644 --- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm +++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm @@ -22,6 +22,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "ChromeManifestParser", "resource://gre/modules/ChromeManifestParser.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager", "resource://gre/modules/LightweightThemeManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Locale", + "resource://gre/modules/Locale.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ZipUtils", @@ -67,8 +69,6 @@ const PREF_INSTALL_CACHE = "extensions.installCache"; const PREF_XPI_STATE = "extensions.xpiState"; const PREF_BOOTSTRAP_ADDONS = "extensions.bootstrappedAddons"; const PREF_PENDING_OPERATIONS = "extensions.pendingOperations"; -const PREF_MATCH_OS_LOCALE = "intl.locale.matchOS"; -const PREF_SELECTED_LOCALE = "general.useragent.locale"; const PREF_EM_DSS_ENABLED = "extensions.dss.enabled"; const PREF_DSS_SWITCHPENDING = "extensions.dss.switchPending"; const PREF_DSS_SKIN_TO_SELECT = "extensions.lastSelectedSkin"; @@ -514,84 +514,6 @@ SafeInstallOperation.prototype = { } }; -/** - * Gets the currently selected locale for display. - * @return the selected locale or "en-US" if none is selected - */ -function getLocale() { - if (Preferences.get(PREF_MATCH_OS_LOCALE, false)) - return Services.locale.getLocaleComponentForUserAgent(); - try { - let locale = Preferences.get(PREF_SELECTED_LOCALE, null, Ci.nsIPrefLocalizedString); - if (locale) - return locale; - } - catch (e) {} - return Preferences.get(PREF_SELECTED_LOCALE, "en-US"); -} - -/** - * Selects the closest matching locale from a list of locales. - * - * @param aLocales - * An array of locales - * @return the best match for the currently selected locale - */ -function findClosestLocale(aLocales) { - let appLocale = getLocale(); - - // Holds the best matching localized resource - var bestmatch = null; - // The number of locale parts it matched with - var bestmatchcount = 0; - // The number of locale parts in the match - var bestpartcount = 0; - - var matchLocales = [appLocale.toLowerCase()]; - /* If the current locale is English then it will find a match if there is - a valid match for en-US so no point searching that locale too. */ - if (matchLocales[0].substring(0, 3) != "en-") - matchLocales.push("en-us"); - - for each (var locale in matchLocales) { - var lparts = locale.split("-"); - for each (var localized in aLocales) { - for each (let found in localized.locales) { - found = found.toLowerCase(); - // Exact match is returned immediately - if (locale == found) - return localized; - - var fparts = found.split("-"); - /* If we have found a possible match and this one isn't any longer - then we dont need to check further. */ - if (bestmatch && fparts.length < bestmatchcount) - continue; - - // Count the number of parts that match - var maxmatchcount = Math.min(fparts.length, lparts.length); - var matchcount = 0; - while (matchcount < maxmatchcount && - fparts[matchcount] == lparts[matchcount]) - matchcount++; - - /* If we matched more than the last best match or matched the same and - this locale is less specific than the last best match. */ - if (matchcount > bestmatchcount || - (matchcount == bestmatchcount && fparts.length < bestpartcount)) { - bestmatch = localized; - bestmatchcount = matchcount; - bestpartcount = fparts.length; - } - } - } - // If we found a valid match for this locale return it - if (bestmatch) - return bestmatch; - } - return null; -} - /** * Sets the userDisabled and softDisabled properties of an add-on based on what * values those properties had for a previous instance of the add-on. The @@ -6493,7 +6415,7 @@ AddonInternal.prototype = { get selectedLocale() { if (this._selectedLocale) return this._selectedLocale; - let locale = findClosestLocale(this.locales); + let locale = Locale.findClosestLocale(this.locales); this._selectedLocale = locale ? locale : this.defaultLocale; return this._selectedLocale; },