summaryrefslogtreecommitdiffstats
path: root/browser/components/extensions/parent
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/extensions/parent')
-rw-r--r--browser/components/extensions/parent/.eslintrc.js35
-rw-r--r--browser/components/extensions/parent/ext-bookmarks.js491
-rw-r--r--browser/components/extensions/parent/ext-browser.js1250
-rw-r--r--browser/components/extensions/parent/ext-browserAction.js689
-rw-r--r--browser/components/extensions/parent/ext-chrome-settings-overrides.js537
-rw-r--r--browser/components/extensions/parent/ext-commands.js57
-rw-r--r--browser/components/extensions/parent/ext-devtools-inspectedWindow.js53
-rw-r--r--browser/components/extensions/parent/ext-devtools-network.js82
-rw-r--r--browser/components/extensions/parent/ext-devtools-panels.js710
-rw-r--r--browser/components/extensions/parent/ext-devtools.js509
-rw-r--r--browser/components/extensions/parent/ext-find.js274
-rw-r--r--browser/components/extensions/parent/ext-history.js309
-rw-r--r--browser/components/extensions/parent/ext-menus.js1354
-rw-r--r--browser/components/extensions/parent/ext-normandyAddonStudy.js84
-rw-r--r--browser/components/extensions/parent/ext-omnibox.js125
-rw-r--r--browser/components/extensions/parent/ext-pageAction.js365
-rw-r--r--browser/components/extensions/parent/ext-pkcs11.js166
-rw-r--r--browser/components/extensions/parent/ext-search.js82
-rw-r--r--browser/components/extensions/parent/ext-sessions.js272
-rw-r--r--browser/components/extensions/parent/ext-sidebarAction.js529
-rw-r--r--browser/components/extensions/parent/ext-tabs.js1574
-rw-r--r--browser/components/extensions/parent/ext-topSites.js116
-rw-r--r--browser/components/extensions/parent/ext-url-overrides.js210
-rw-r--r--browser/components/extensions/parent/ext-urlbar.js152
-rw-r--r--browser/components/extensions/parent/ext-windows.js443
25 files changed, 10468 insertions, 0 deletions
diff --git a/browser/components/extensions/parent/.eslintrc.js b/browser/components/extensions/parent/.eslintrc.js
new file mode 100644
index 0000000000..4713c6192b
--- /dev/null
+++ b/browser/components/extensions/parent/.eslintrc.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+module.exports = {
+ extends: "../../../../toolkit/components/extensions/parent/.eslintrc.js",
+
+ globals: {
+ Tab: true,
+ TabContext: true,
+ Window: true,
+ actionContextMenu: true,
+ browserActionFor: true,
+ clickModifiersFromEvent: true,
+ getContainerForCookieStoreId: true,
+ getInspectedWindowFront: true,
+ getTargetTabIdForToolbox: true,
+ getToolboxEvalOptions: true,
+ isContainerCookieStoreId: true,
+ isPrivateCookieStoreId: true,
+ isValidCookieStoreId: true,
+ makeWidgetId: true,
+ openOptionsPage: true,
+ pageActionFor: true,
+ replaceUrlInTab: true,
+ searchInitialized: true,
+ sidebarActionFor: true,
+ tabGetSender: true,
+ tabTracker: true,
+ waitForTabLoaded: true,
+ windowTracker: true,
+ },
+};
diff --git a/browser/components/extensions/parent/ext-bookmarks.js b/browser/components/extensions/parent/ext-bookmarks.js
new file mode 100644
index 0000000000..b8ef49ef04
--- /dev/null
+++ b/browser/components/extensions/parent/ext-bookmarks.js
@@ -0,0 +1,491 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
+});
+
+var { ExtensionError } = ExtensionUtils;
+
+const { TYPE_BOOKMARK, TYPE_FOLDER, TYPE_SEPARATOR } = PlacesUtils.bookmarks;
+
+const BOOKMARKS_TYPES_TO_API_TYPES_MAP = new Map([
+ [TYPE_BOOKMARK, "bookmark"],
+ [TYPE_FOLDER, "folder"],
+ [TYPE_SEPARATOR, "separator"],
+]);
+
+const BOOKMARK_SEPERATOR_URL = "data:";
+
+XPCOMUtils.defineLazyGetter(this, "API_TYPES_TO_BOOKMARKS_TYPES_MAP", () => {
+ let theMap = new Map();
+
+ for (let [code, name] of BOOKMARKS_TYPES_TO_API_TYPES_MAP) {
+ theMap.set(name, code);
+ }
+ return theMap;
+});
+
+let listenerCount = 0;
+
+function getUrl(type, url) {
+ switch (type) {
+ case TYPE_BOOKMARK:
+ return url;
+ case TYPE_SEPARATOR:
+ return BOOKMARK_SEPERATOR_URL;
+ default:
+ return undefined;
+ }
+}
+
+const getTree = (rootGuid, onlyChildren) => {
+ function convert(node, parent) {
+ let treenode = {
+ id: node.guid,
+ title: PlacesUtils.bookmarks.getLocalizedTitle(node) || "",
+ index: node.index,
+ dateAdded: node.dateAdded / 1000,
+ type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(node.typeCode),
+ url: getUrl(node.typeCode, node.uri),
+ };
+
+ if (parent && node.guid != PlacesUtils.bookmarks.rootGuid) {
+ treenode.parentId = parent.guid;
+ }
+
+ if (node.typeCode == TYPE_FOLDER) {
+ treenode.dateGroupModified = node.lastModified / 1000;
+
+ if (!onlyChildren) {
+ treenode.children = node.children
+ ? node.children.map(child => convert(child, node))
+ : [];
+ }
+ }
+
+ return treenode;
+ }
+
+ return PlacesUtils.promiseBookmarksTree(rootGuid)
+ .then(root => {
+ if (onlyChildren) {
+ let children = root.children || [];
+ return children.map(child => convert(child, root));
+ }
+ let treenode = convert(root, null);
+ treenode.parentId = root.parentGuid;
+ // It seems like the array always just contains the root node.
+ return [treenode];
+ })
+ .catch(e => Promise.reject({ message: e.message }));
+};
+
+const convertBookmarks = result => {
+ let node = {
+ id: result.guid,
+ title: PlacesUtils.bookmarks.getLocalizedTitle(result) || "",
+ index: result.index,
+ dateAdded: result.dateAdded.getTime(),
+ type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(result.type),
+ url: getUrl(result.type, result.url && result.url.href),
+ };
+
+ if (result.guid != PlacesUtils.bookmarks.rootGuid) {
+ node.parentId = result.parentGuid;
+ }
+
+ if (result.type == TYPE_FOLDER) {
+ node.dateGroupModified = result.lastModified.getTime();
+ }
+
+ return node;
+};
+
+const throwIfRootId = id => {
+ if (id == PlacesUtils.bookmarks.rootGuid) {
+ throw new ExtensionError("The bookmark root cannot be modified");
+ }
+};
+
+let observer = new (class extends EventEmitter {
+ constructor() {
+ super();
+
+ this.skipTags = true;
+
+ this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
+ }
+
+ onBeginUpdateBatch() {}
+ onEndUpdateBatch() {}
+
+ handlePlacesEvents(events) {
+ for (let event of events) {
+ switch (event.type) {
+ case "bookmark-added":
+ if (event.isTagging) {
+ continue;
+ }
+ let bookmark = {
+ id: event.guid,
+ parentId: event.parentGuid,
+ index: event.index,
+ title: event.title,
+ dateAdded: event.dateAdded,
+ type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(event.itemType),
+ url: getUrl(event.itemType, event.url),
+ };
+
+ if (event.itemType == TYPE_FOLDER) {
+ bookmark.dateGroupModified = bookmark.dateAdded;
+ }
+
+ this.emit("created", bookmark);
+ break;
+ case "bookmark-removed":
+ if (event.isTagging || event.isDescendantRemoval) {
+ continue;
+ }
+ let node = {
+ id: event.guid,
+ parentId: event.parentGuid,
+ index: event.index,
+ type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(event.itemType),
+ url: getUrl(event.itemType, event.url),
+ };
+
+ this.emit("removed", {
+ guid: event.guid,
+ info: { parentId: event.parentGuid, index: event.index, node },
+ });
+ }
+ }
+ }
+
+ onItemMoved(
+ id,
+ oldParentId,
+ oldIndex,
+ newParentId,
+ newIndex,
+ itemType,
+ guid,
+ oldParentGuid,
+ newParentGuid,
+ source
+ ) {
+ let info = {
+ parentId: newParentGuid,
+ index: newIndex,
+ oldParentId: oldParentGuid,
+ oldIndex,
+ };
+ this.emit("moved", { guid, info });
+ }
+
+ onItemChanged(
+ id,
+ prop,
+ isAnno,
+ val,
+ lastMod,
+ itemType,
+ parentId,
+ guid,
+ parentGuid,
+ oldVal,
+ source
+ ) {
+ let info = {};
+ if (prop == "title") {
+ info.title = val;
+ } else if (prop == "uri") {
+ info.url = val;
+ } else {
+ // Not defined yet.
+ return;
+ }
+
+ this.emit("changed", { guid, info });
+ }
+})();
+
+const decrementListeners = () => {
+ listenerCount -= 1;
+ if (!listenerCount) {
+ PlacesUtils.bookmarks.removeObserver(observer);
+ PlacesUtils.observers.removeListener(
+ ["bookmark-added", "bookmark-removed"],
+ observer.handlePlacesEvents
+ );
+ }
+};
+
+const incrementListeners = () => {
+ listenerCount++;
+ if (listenerCount == 1) {
+ PlacesUtils.bookmarks.addObserver(observer);
+ PlacesUtils.observers.addListener(
+ ["bookmark-added", "bookmark-removed"],
+ observer.handlePlacesEvents
+ );
+ }
+};
+
+this.bookmarks = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ bookmarks: {
+ async get(idOrIdList) {
+ let list = Array.isArray(idOrIdList) ? idOrIdList : [idOrIdList];
+
+ try {
+ let bookmarks = [];
+ for (let id of list) {
+ let bookmark = await PlacesUtils.bookmarks.fetch({ guid: id });
+ if (!bookmark) {
+ throw new Error("Bookmark not found");
+ }
+ bookmarks.push(convertBookmarks(bookmark));
+ }
+ return bookmarks;
+ } catch (error) {
+ return Promise.reject({ message: error.message });
+ }
+ },
+
+ getChildren: function(id) {
+ // TODO: We should optimize this.
+ return getTree(id, true);
+ },
+
+ getTree: function() {
+ return getTree(PlacesUtils.bookmarks.rootGuid, false);
+ },
+
+ getSubTree: function(id) {
+ return getTree(id, false);
+ },
+
+ search: function(query) {
+ return PlacesUtils.bookmarks
+ .search(query)
+ .then(result => result.map(convertBookmarks));
+ },
+
+ getRecent: function(numberOfItems) {
+ return PlacesUtils.bookmarks
+ .getRecent(numberOfItems)
+ .then(result => result.map(convertBookmarks));
+ },
+
+ create: function(bookmark) {
+ let info = {
+ title: bookmark.title || "",
+ };
+
+ info.type = API_TYPES_TO_BOOKMARKS_TYPES_MAP.get(bookmark.type);
+ if (!info.type) {
+ // If url is NULL or missing, it will be a folder.
+ if (bookmark.url !== null) {
+ info.type = TYPE_BOOKMARK;
+ } else {
+ info.type = TYPE_FOLDER;
+ }
+ }
+
+ if (info.type === TYPE_BOOKMARK) {
+ info.url = bookmark.url || "";
+ }
+
+ if (bookmark.index !== null) {
+ info.index = bookmark.index;
+ }
+
+ if (bookmark.parentId !== null) {
+ throwIfRootId(bookmark.parentId);
+ info.parentGuid = bookmark.parentId;
+ } else {
+ info.parentGuid = PlacesUtils.bookmarks.unfiledGuid;
+ }
+
+ try {
+ return PlacesUtils.bookmarks
+ .insert(info)
+ .then(convertBookmarks)
+ .catch(error => Promise.reject({ message: error.message }));
+ } catch (e) {
+ return Promise.reject({
+ message: `Invalid bookmark: ${JSON.stringify(info)}`,
+ });
+ }
+ },
+
+ move: function(id, destination) {
+ throwIfRootId(id);
+ let info = {
+ guid: id,
+ };
+
+ if (destination.parentId !== null) {
+ throwIfRootId(destination.parentId);
+ info.parentGuid = destination.parentId;
+ }
+ info.index =
+ destination.index === null
+ ? PlacesUtils.bookmarks.DEFAULT_INDEX
+ : destination.index;
+
+ try {
+ return PlacesUtils.bookmarks
+ .update(info)
+ .then(convertBookmarks)
+ .catch(error => Promise.reject({ message: error.message }));
+ } catch (e) {
+ return Promise.reject({
+ message: `Invalid bookmark: ${JSON.stringify(info)}`,
+ });
+ }
+ },
+
+ update: function(id, changes) {
+ throwIfRootId(id);
+ let info = {
+ guid: id,
+ };
+
+ if (changes.title !== null) {
+ info.title = changes.title;
+ }
+ if (changes.url !== null) {
+ info.url = changes.url;
+ }
+
+ try {
+ return PlacesUtils.bookmarks
+ .update(info)
+ .then(convertBookmarks)
+ .catch(error => Promise.reject({ message: error.message }));
+ } catch (e) {
+ return Promise.reject({
+ message: `Invalid bookmark: ${JSON.stringify(info)}`,
+ });
+ }
+ },
+
+ remove: function(id) {
+ throwIfRootId(id);
+ let info = {
+ guid: id,
+ };
+
+ // The API doesn't give you the old bookmark at the moment
+ try {
+ return PlacesUtils.bookmarks
+ .remove(info, { preventRemovalOfNonEmptyFolders: true })
+ .catch(error => Promise.reject({ message: error.message }));
+ } catch (e) {
+ return Promise.reject({
+ message: `Invalid bookmark: ${JSON.stringify(info)}`,
+ });
+ }
+ },
+
+ removeTree: function(id) {
+ throwIfRootId(id);
+ let info = {
+ guid: id,
+ };
+
+ try {
+ return PlacesUtils.bookmarks
+ .remove(info)
+ .catch(error => Promise.reject({ message: error.message }));
+ } catch (e) {
+ return Promise.reject({
+ message: `Invalid bookmark: ${JSON.stringify(info)}`,
+ });
+ }
+ },
+
+ onCreated: new EventManager({
+ context,
+ name: "bookmarks.onCreated",
+ register: fire => {
+ let listener = (event, bookmark) => {
+ fire.sync(bookmark.id, bookmark);
+ };
+
+ observer.on("created", listener);
+ incrementListeners();
+ return () => {
+ observer.off("created", listener);
+ decrementListeners();
+ };
+ },
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ name: "bookmarks.onRemoved",
+ register: fire => {
+ let listener = (event, data) => {
+ fire.sync(data.guid, data.info);
+ };
+
+ observer.on("removed", listener);
+ incrementListeners();
+ return () => {
+ observer.off("removed", listener);
+ decrementListeners();
+ };
+ },
+ }).api(),
+
+ onChanged: new EventManager({
+ context,
+ name: "bookmarks.onChanged",
+ register: fire => {
+ let listener = (event, data) => {
+ fire.sync(data.guid, data.info);
+ };
+
+ observer.on("changed", listener);
+ incrementListeners();
+ return () => {
+ observer.off("changed", listener);
+ decrementListeners();
+ };
+ },
+ }).api(),
+
+ onMoved: new EventManager({
+ context,
+ name: "bookmarks.onMoved",
+ register: fire => {
+ let listener = (event, data) => {
+ fire.sync(data.guid, data.info);
+ };
+
+ observer.on("moved", listener);
+ incrementListeners();
+ return () => {
+ observer.off("moved", listener);
+ decrementListeners();
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-browser.js b/browser/components/extensions/parent/ext-browser.js
new file mode 100644
index 0000000000..a88c69b1b8
--- /dev/null
+++ b/browser/components/extensions/parent/ext-browser.js
@@ -0,0 +1,1250 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// 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.
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserWindowTracker",
+ "resource:///modules/BrowserWindowTracker.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PromiseUtils",
+ "resource://gre/modules/PromiseUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "AboutReaderParent",
+ "resource:///actors/AboutReaderParent.jsm"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+var { defineLazyGetter } = ExtensionCommon;
+
+const READER_MODE_PREFIX = "about:reader";
+
+let tabTracker;
+let windowTracker;
+
+function isPrivateTab(nativeTab) {
+ return PrivateBrowsingUtils.isBrowserPrivate(nativeTab.linkedBrowser);
+}
+
+function isPrivateWindow(window) {
+ return PrivateBrowsingUtils.isContentWindowPrivate(window);
+}
+
+// This function is pretty tightly tied to Extension.jsm.
+// Its job is to fill in the |tab| property of the sender.
+const getSender = (extension, target, sender) => {
+ let tabId;
+ if ("tabId" in sender) {
+ // The message came from a privileged extension page running in a tab. In
+ // that case, it should include a tabId property (which is filled in by the
+ // page-open listener below).
+ tabId = sender.tabId;
+ delete sender.tabId;
+ } else if (
+ ExtensionCommon.instanceOf(target, "XULFrameElement") ||
+ ExtensionCommon.instanceOf(target, "HTMLIFrameElement")
+ ) {
+ tabId = tabTracker.getBrowserData(target).tabId;
+ }
+
+ if (tabId) {
+ let tab = extension.tabManager.get(tabId, null);
+ if (tab) {
+ sender.tab = tab.convert();
+ }
+ }
+};
+
+// Used by Extension.jsm
+global.tabGetSender = getSender;
+
+/* eslint-disable mozilla/balanced-listeners */
+extensions.on("uninstalling", (msg, extension) => {
+ if (extension.uninstallURL) {
+ let browser = windowTracker.topWindow.gBrowser;
+ browser.addTab(extension.uninstallURL, {
+ disallowInheritPrincipal: true,
+ relatedToCurrent: true,
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ });
+ }
+});
+
+extensions.on("page-shutdown", (type, context) => {
+ if (context.viewType == "tab") {
+ if (context.extension.id !== context.xulBrowser.contentPrincipal.addonId) {
+ // Only close extension tabs.
+ // This check prevents about:addons from closing when it contains a
+ // WebExtension as an embedded inline options page.
+ return;
+ }
+ let { gBrowser } = context.xulBrowser.ownerGlobal;
+ if (gBrowser && gBrowser.getTabForBrowser) {
+ let nativeTab = gBrowser.getTabForBrowser(context.xulBrowser);
+ if (nativeTab) {
+ gBrowser.removeTab(nativeTab);
+ }
+ }
+ }
+});
+/* eslint-enable mozilla/balanced-listeners */
+
+global.openOptionsPage = extension => {
+ let window = windowTracker.topWindow;
+ if (!window) {
+ return Promise.reject({ message: "No browser window available" });
+ }
+
+ if (extension.manifest.options_ui.open_in_tab) {
+ window.switchToTabHavingURI(extension.manifest.options_ui.page, true, {
+ triggeringPrincipal: extension.principal,
+ });
+ return Promise.resolve();
+ }
+
+ let viewId = `addons://detail/${encodeURIComponent(
+ extension.id
+ )}/preferences`;
+
+ return window.BrowserOpenAddonsMgr(viewId);
+};
+
+global.makeWidgetId = id => {
+ id = id.toLowerCase();
+ // FIXME: This allows for collisions.
+ return id.replace(/[^a-z0-9_-]/g, "_");
+};
+
+global.clickModifiersFromEvent = event => {
+ const map = {
+ shiftKey: "Shift",
+ altKey: "Alt",
+ metaKey: "Command",
+ ctrlKey: "Ctrl",
+ };
+ let modifiers = Object.keys(map)
+ .filter(key => event[key])
+ .map(key => map[key]);
+
+ if (event.ctrlKey && AppConstants.platform === "macosx") {
+ modifiers.push("MacCtrl");
+ }
+
+ return modifiers;
+};
+
+global.waitForTabLoaded = (tab, url) => {
+ return new Promise(resolve => {
+ windowTracker.addListener("progress", {
+ onLocationChange(browser, webProgress, request, locationURI, flags) {
+ if (
+ webProgress.isTopLevel &&
+ browser.ownerGlobal.gBrowser.getTabForBrowser(browser) == tab &&
+ (!url || locationURI.spec == url)
+ ) {
+ windowTracker.removeListener("progress", this);
+ resolve();
+ }
+ },
+ });
+ });
+};
+
+global.replaceUrlInTab = (gBrowser, tab, url) => {
+ let loaded = waitForTabLoaded(tab, url);
+ gBrowser.loadURI(url, {
+ flags: Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), // This is safe from this functions usage however it would be preferred not to dot his.
+ });
+ return loaded;
+};
+
+/**
+ * Manages tab-specific and window-specific context data, and dispatches
+ * tab select events across all windows.
+ */
+global.TabContext = class extends EventEmitter {
+ /**
+ * @param {Function} getDefaultPrototype
+ * Provides the prototype of the context value for a tab or window when there is none.
+ * Called with a XULElement or ChromeWindow argument.
+ * Should return an object or null.
+ */
+ constructor(getDefaultPrototype) {
+ super();
+
+ this.getDefaultPrototype = getDefaultPrototype;
+
+ this.tabData = new WeakMap();
+
+ windowTracker.addListener("progress", this);
+ windowTracker.addListener("TabSelect", this);
+
+ this.tabAdopted = this.tabAdopted.bind(this);
+ tabTracker.on("tab-adopted", this.tabAdopted);
+ }
+
+ /**
+ * Returns the context data associated with `keyObject`.
+ *
+ * @param {XULElement|ChromeWindow} keyObject
+ * Browser tab or browser chrome window.
+ * @returns {Object}
+ */
+ get(keyObject) {
+ if (!this.tabData.has(keyObject)) {
+ let data = Object.create(this.getDefaultPrototype(keyObject));
+ this.tabData.set(keyObject, data);
+ }
+
+ return this.tabData.get(keyObject);
+ }
+
+ /**
+ * Clears the context data associated with `keyObject`.
+ *
+ * @param {XULElement|ChromeWindow} keyObject
+ * Browser tab or browser chrome window.
+ */
+ clear(keyObject) {
+ this.tabData.delete(keyObject);
+ }
+
+ handleEvent(event) {
+ if (event.type == "TabSelect") {
+ let nativeTab = event.target;
+ this.emit("tab-select", nativeTab);
+ this.emit("location-change", nativeTab);
+ }
+ }
+
+ onLocationChange(browser, webProgress, request, locationURI, flags) {
+ if (!webProgress.isTopLevel) {
+ // Only pageAction and browserAction are consuming the "location-change" event
+ // to update their per-tab status, and they should only do so in response of
+ // location changes related to the top level frame (See Bug 1493470 for a rationale).
+ return;
+ }
+ let gBrowser = browser.ownerGlobal.gBrowser;
+ let tab = gBrowser.getTabForBrowser(browser);
+ // fromBrowse will be false in case of e.g. a hash change or history.pushState
+ let fromBrowse = !(
+ flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
+ );
+ this.emit("location-change", tab, fromBrowse);
+ }
+
+ /**
+ * Persists context data when a tab is moved between windows.
+ *
+ * @param {string} eventType
+ * Event type, should be "tab-adopted".
+ * @param {NativeTab} adoptingTab
+ * The tab which is being opened and adopting `adoptedTab`.
+ * @param {NativeTab} adoptedTab
+ * The tab which is being closed and adopted by `adoptingTab`.
+ */
+ tabAdopted(eventType, adoptingTab, adoptedTab) {
+ if (!this.tabData.has(adoptedTab)) {
+ return;
+ }
+ // Create a new object (possibly with different inheritance) when a tab is moved
+ // into a new window. But then reassign own properties from the old object.
+ let newData = this.get(adoptingTab);
+ let oldData = this.tabData.get(adoptedTab);
+ this.tabData.delete(adoptedTab);
+ Object.assign(newData, oldData);
+ }
+
+ /**
+ * Makes the TabContext instance stop emitting events.
+ */
+ shutdown() {
+ windowTracker.removeListener("progress", this);
+ windowTracker.removeListener("TabSelect", this);
+ tabTracker.off("tab-adopted", this.tabAdopted);
+ }
+};
+
+// This promise is used to wait for the search service to be initialized.
+// None of the code in the WebExtension modules requests that initialization.
+// It is assumed that it is started at some point. That might never happen,
+// e.g. if the application shuts down before the search service initializes.
+XPCOMUtils.defineLazyGetter(global, "searchInitialized", () => {
+ if (Services.search.isInitialized) {
+ return Promise.resolve();
+ }
+ return ExtensionUtils.promiseObserved(
+ "browser-search-service",
+ (_, data) => data == "init-complete"
+ );
+});
+
+class WindowTracker extends WindowTrackerBase {
+ addProgressListener(window, listener) {
+ window.gBrowser.addTabsProgressListener(listener);
+ }
+
+ removeProgressListener(window, listener) {
+ window.gBrowser.removeTabsProgressListener(listener);
+ }
+
+ /**
+ * @param {BaseContext} context
+ * The extension context
+ * @returns {DOMWindow|null} topNormalWindow
+ * The currently active, or topmost, browser window, or null if no
+ * browser window is currently open.
+ * Will return the topmost "normal" (i.e., not popup) window.
+ */
+ getTopNormalWindow(context) {
+ let options = { allowPopups: false };
+ if (!context.privateBrowsingAllowed) {
+ options.private = false;
+ }
+ return BrowserWindowTracker.getTopWindow(options);
+ }
+}
+
+class TabTracker extends TabTrackerBase {
+ constructor() {
+ super();
+
+ this._tabs = new WeakMap();
+ this._browsers = new WeakMap();
+ this._tabIds = new Map();
+ this._nextId = 1;
+ this._deferredTabOpenEvents = new WeakMap();
+
+ this._handleTabDestroyed = this._handleTabDestroyed.bind(this);
+ }
+
+ init() {
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+
+ this.adoptedTabs = new WeakSet();
+
+ this._handleWindowOpen = this._handleWindowOpen.bind(this);
+ this._handleWindowClose = this._handleWindowClose.bind(this);
+
+ windowTracker.addListener("TabClose", this);
+ windowTracker.addListener("TabOpen", this);
+ windowTracker.addListener("TabSelect", this);
+ windowTracker.addListener("TabMultiSelect", this);
+ windowTracker.addOpenListener(this._handleWindowOpen);
+ windowTracker.addCloseListener(this._handleWindowClose);
+
+ AboutReaderParent.addMessageListener("Reader:UpdateReaderButton", this);
+
+ /* eslint-disable mozilla/balanced-listeners */
+ this.on("tab-detached", this._handleTabDestroyed);
+ this.on("tab-removed", this._handleTabDestroyed);
+ /* eslint-enable mozilla/balanced-listeners */
+ }
+
+ getId(nativeTab) {
+ let id = this._tabs.get(nativeTab);
+ if (id) {
+ return id;
+ }
+
+ this.init();
+
+ id = this._nextId++;
+ this.setId(nativeTab, id);
+ return id;
+ }
+
+ getBrowserTabId(browser) {
+ let id = this._browsers.get(browser);
+ if (id) {
+ return id;
+ }
+
+ let tab = browser.ownerGlobal.gBrowser.getTabForBrowser(browser);
+ if (tab) {
+ id = this.getId(tab);
+ this._browsers.set(browser, id);
+ return id;
+ }
+ return -1;
+ }
+
+ setId(nativeTab, id) {
+ if (!nativeTab.parentNode) {
+ throw new Error("Cannot attach ID to a destroyed tab.");
+ }
+ this._tabs.set(nativeTab, id);
+ if (nativeTab.linkedBrowser) {
+ this._browsers.set(nativeTab.linkedBrowser, id);
+ }
+ this._tabIds.set(id, nativeTab);
+ }
+
+ /**
+ * Handles tab adoption when a tab is moved between windows.
+ * Ensures the new tab will have the same ID as the old one, and
+ * emits "tab-adopted", "tab-detached" and "tab-attached" events.
+ *
+ * @param {NativeTab} adoptingTab
+ * The tab which is being opened and adopting `adoptedTab`.
+ * @param {NativeTab} adoptedTab
+ * The tab which is being closed and adopted by `adoptingTab`.
+ */
+ adopt(adoptingTab, adoptedTab) {
+ if (this.adoptedTabs.has(adoptedTab)) {
+ // The adoption has already been handled.
+ return;
+ }
+ this.adoptedTabs.add(adoptedTab);
+ let tabId = this.getId(adoptedTab);
+ this.setId(adoptingTab, tabId);
+ this.emit("tab-adopted", adoptingTab, adoptedTab);
+ if (this.has("tab-detached")) {
+ let nativeTab = adoptedTab;
+ let isPrivate = isPrivateTab(nativeTab);
+ let adoptedBy = adoptingTab;
+ let oldWindowId = windowTracker.getId(nativeTab.ownerGlobal);
+ let oldPosition = nativeTab._tPos;
+ this.emit("tab-detached", {
+ nativeTab,
+ adoptedBy,
+ tabId,
+ oldWindowId,
+ oldPosition,
+ isPrivate,
+ });
+ }
+ if (this.has("tab-attached")) {
+ let nativeTab = adoptingTab;
+ let isPrivate = isPrivateTab(nativeTab);
+ let newWindowId = windowTracker.getId(nativeTab.ownerGlobal);
+ let newPosition = nativeTab._tPos;
+ this.emit("tab-attached", {
+ nativeTab,
+ tabId,
+ newWindowId,
+ newPosition,
+ isPrivate,
+ });
+ }
+ }
+
+ _handleTabDestroyed(event, { nativeTab }) {
+ let id = this._tabs.get(nativeTab);
+ if (id) {
+ this._tabs.delete(nativeTab);
+ if (this._tabIds.get(id) === nativeTab) {
+ this._tabIds.delete(id);
+ }
+ }
+ }
+
+ /**
+ * Returns the XUL <tab> element associated with the given tab ID. If no tab
+ * with the given ID exists, and no default value is provided, an error is
+ * raised, belonging to the scope of the given context.
+ *
+ * @param {integer} tabId
+ * The ID of the tab to retrieve.
+ * @param {*} default_
+ * The value to return if no tab exists with the given ID.
+ * @returns {Element<tab>}
+ * A XUL <tab> element.
+ */
+ getTab(tabId, default_ = undefined) {
+ let nativeTab = this._tabIds.get(tabId);
+ if (nativeTab) {
+ return nativeTab;
+ }
+ if (default_ !== undefined) {
+ return default_;
+ }
+ throw new ExtensionError(`Invalid tab ID: ${tabId}`);
+ }
+
+ /**
+ * Sets the opener of `tab` to the ID `openerTab`. Both tabs must be in the
+ * same window, or this function will throw a type error.
+ *
+ * @param {Element} tab The tab for which to set the owner.
+ * @param {Element} openerTab The opener of <tab>.
+ */
+ setOpener(tab, openerTab) {
+ if (tab.ownerDocument !== openerTab.ownerDocument) {
+ throw new Error("Tab must be in the same window as its opener");
+ }
+ tab.openerTab = openerTab;
+ }
+
+ deferredForTabOpen(nativeTab) {
+ let deferred = this._deferredTabOpenEvents.get(nativeTab);
+ if (!deferred) {
+ deferred = PromiseUtils.defer();
+ this._deferredTabOpenEvents.set(nativeTab, deferred);
+ deferred.promise.then(() => {
+ this._deferredTabOpenEvents.delete(nativeTab);
+ });
+ }
+ return deferred;
+ }
+
+ async maybeWaitForTabOpen(nativeTab) {
+ let deferred = this._deferredTabOpenEvents.get(nativeTab);
+ return deferred && deferred.promise;
+ }
+
+ /**
+ * @param {Event} event
+ * The DOM Event to handle.
+ * @private
+ */
+ handleEvent(event) {
+ let nativeTab = event.target;
+
+ switch (event.type) {
+ case "TabOpen":
+ let { adoptedTab } = event.detail;
+ if (adoptedTab) {
+ // This tab is being created to adopt a tab from a different window.
+ // Handle the adoption.
+ this.adopt(nativeTab, adoptedTab);
+ if (adoptedTab.linkedPanel) {
+ adoptedTab.linkedBrowser.messageManager.sendAsyncMessage(
+ "Extension:SetFrameData",
+ {
+ windowId: windowTracker.getId(nativeTab.ownerGlobal),
+ }
+ );
+ }
+ } else {
+ // Save the size of the current tab, since the newly-created tab will
+ // likely be active by the time the promise below resolves and the
+ // event is dispatched.
+ const currentTab = nativeTab.ownerGlobal.gBrowser.selectedTab;
+ const { frameLoader } = currentTab.linkedBrowser;
+ const currentTabSize = {
+ width: frameLoader.lazyWidth,
+ height: frameLoader.lazyHeight,
+ };
+
+ // We need to delay sending this event until the next tick, since the
+ // tab can become selected immediately after "TabOpen", then onCreated
+ // should be fired with `active: true`.
+ let deferred = this.deferredForTabOpen(event.originalTarget);
+ Promise.resolve().then(() => {
+ deferred.resolve();
+ if (!event.originalTarget.parentNode) {
+ // If the tab is already be destroyed, do nothing.
+ return;
+ }
+ this.emitCreated(event.originalTarget, currentTabSize);
+ });
+ }
+ break;
+
+ case "TabClose":
+ let { adoptedBy } = event.detail;
+ if (adoptedBy) {
+ // This tab is being closed because it was adopted by a new window.
+ // Handle the adoption in case it was created as the first tab of a
+ // new window, and did not have an `adoptedTab` detail when it was
+ // opened.
+ this.adopt(adoptedBy, nativeTab);
+ } else {
+ this.emitRemoved(nativeTab, false);
+ }
+ break;
+
+ case "TabSelect":
+ // Because we are delaying calling emitCreated above, we also need to
+ // delay sending this event because it shouldn't fire before onCreated.
+ this.maybeWaitForTabOpen(nativeTab).then(() => {
+ if (!nativeTab.parentNode) {
+ // If the tab is already be destroyed, do nothing.
+ return;
+ }
+ this.emitActivated(nativeTab, event.detail.previousTab);
+ });
+ break;
+
+ case "TabMultiSelect":
+ if (this.has("tabs-highlighted")) {
+ // Because we are delaying calling emitCreated above, we also need to
+ // delay sending this event because it shouldn't fire before onCreated.
+ // event.target is gBrowser, so we don't use maybeWaitForTabOpen.
+ Promise.resolve().then(() => {
+ this.emitHighlighted(event.target.ownerGlobal);
+ });
+ }
+ break;
+ }
+ }
+
+ /**
+ * @param {Object} message
+ * The message to handle.
+ * @private
+ */
+ receiveMessage(message) {
+ switch (message.name) {
+ case "Reader:UpdateReaderButton":
+ if (message.data && message.data.isArticle !== undefined) {
+ this.emit("tab-isarticle", message);
+ }
+ break;
+ }
+ }
+
+ /**
+ * A private method which is called whenever a new browser window is opened,
+ * and dispatches the necessary events for it.
+ *
+ * @param {DOMWindow} window
+ * The window being opened.
+ * @private
+ */
+ _handleWindowOpen(window) {
+ const tabToAdopt = window.gBrowserInit.getTabToAdopt();
+ if (tabToAdopt) {
+ // Note that this event handler depends on running before the
+ // delayed startup code in browser.js, which is currently triggered
+ // by the first MozAfterPaint event. That code handles finally
+ // adopting the tab, and clears it from the arguments list in the
+ // process, so if we run later than it, we're too late.
+ let adoptedBy = window.gBrowser.tabs[0];
+ this.adopt(adoptedBy, tabToAdopt);
+ } else {
+ for (let nativeTab of window.gBrowser.tabs) {
+ this.emitCreated(nativeTab);
+ }
+
+ // emitActivated to trigger tab.onActivated/tab.onHighlighted for a newly opened window.
+ this.emitActivated(window.gBrowser.tabs[0]);
+ if (this.has("tabs-highlighted")) {
+ this.emitHighlighted(window);
+ }
+ }
+ }
+
+ /**
+ * A private method which is called whenever a browser window is closed,
+ * and dispatches the necessary events for it.
+ *
+ * @param {DOMWindow} window
+ * The window being closed.
+ * @private
+ */
+ _handleWindowClose(window) {
+ for (let nativeTab of window.gBrowser.tabs) {
+ if (!this.adoptedTabs.has(nativeTab)) {
+ this.emitRemoved(nativeTab, true);
+ }
+ }
+ }
+
+ /**
+ * Emits a "tab-activated" event for the given tab element.
+ *
+ * @param {NativeTab} nativeTab
+ * The tab element which has been activated.
+ * @param {NativeTab} previousTab
+ * The tab element which was previously activated.
+ * @private
+ */
+ emitActivated(nativeTab, previousTab = undefined) {
+ let previousTabIsPrivate, previousTabId;
+ if (previousTab && !previousTab.closing) {
+ previousTabId = this.getId(previousTab);
+ previousTabIsPrivate = isPrivateTab(previousTab);
+ }
+ this.emit("tab-activated", {
+ isPrivate: isPrivateTab(nativeTab),
+ tabId: this.getId(nativeTab),
+ previousTabId,
+ previousTabIsPrivate,
+ windowId: windowTracker.getId(nativeTab.ownerGlobal),
+ });
+ }
+
+ /**
+ * Emits a "tabs-highlighted" event for the given tab element.
+ *
+ * @param {ChromeWindow} window
+ * The window in which the active tab or the set of multiselected tabs changed.
+ * @private
+ */
+ emitHighlighted(window) {
+ let tabIds = window.gBrowser.selectedTabs.map(tab => this.getId(tab));
+ let windowId = windowTracker.getId(window);
+ this.emit("tabs-highlighted", {
+ tabIds,
+ windowId,
+ isPrivate: isPrivateWindow(window),
+ });
+ }
+
+ /**
+ * Emits a "tab-created" event for the given tab element.
+ *
+ * @param {NativeTab} nativeTab
+ * The tab element which is being created.
+ * @param {Object} [currentTabSize]
+ * The size of the tab element for the currently active tab.
+ * @private
+ */
+ emitCreated(nativeTab, currentTabSize) {
+ this.emit("tab-created", {
+ nativeTab,
+ currentTabSize,
+ isPrivate: isPrivateTab(nativeTab),
+ });
+ }
+
+ /**
+ * Emits a "tab-removed" event for the given tab element.
+ *
+ * @param {NativeTab} nativeTab
+ * The tab element which is being removed.
+ * @param {boolean} isWindowClosing
+ * True if the tab is being removed because the browser window is
+ * closing.
+ * @private
+ */
+ emitRemoved(nativeTab, isWindowClosing) {
+ let windowId = windowTracker.getId(nativeTab.ownerGlobal);
+ let tabId = this.getId(nativeTab);
+
+ this.emit("tab-removed", {
+ nativeTab,
+ tabId,
+ windowId,
+ isWindowClosing,
+ isPrivate: isPrivateTab(nativeTab),
+ });
+ }
+
+ getBrowserData(browser) {
+ let window = browser.ownerGlobal;
+ if (!window) {
+ return {
+ tabId: -1,
+ windowId: -1,
+ };
+ }
+ let { gBrowser } = window;
+ // Some non-browser windows have gBrowser but not getTabForBrowser!
+ if (!gBrowser || !gBrowser.getTabForBrowser) {
+ if (window.top.document.documentURI === "about:addons") {
+ // When we're loaded into a <browser> inside about:addons, we need to go up
+ // one more level.
+ browser = window.docShell.chromeEventHandler;
+
+ ({ gBrowser } = browser.ownerGlobal);
+ } else {
+ return {
+ tabId: -1,
+ windowId: -1,
+ };
+ }
+ }
+
+ return {
+ tabId: this.getBrowserTabId(browser),
+ windowId: windowTracker.getId(browser.ownerGlobal),
+ };
+ }
+
+ get activeTab() {
+ let window = windowTracker.topWindow;
+ if (window && window.gBrowser) {
+ return window.gBrowser.selectedTab;
+ }
+ return null;
+ }
+}
+
+windowTracker = new WindowTracker();
+tabTracker = new TabTracker();
+
+Object.assign(global, { tabTracker, windowTracker });
+
+class Tab extends TabBase {
+ get _favIconUrl() {
+ return this.window.gBrowser.getIcon(this.nativeTab);
+ }
+
+ get attention() {
+ return this.nativeTab.getAttribute("attention") === "true";
+ }
+
+ get audible() {
+ return this.nativeTab.soundPlaying;
+ }
+
+ get browser() {
+ return this.nativeTab.linkedBrowser;
+ }
+
+ get discarded() {
+ return !this.nativeTab.linkedPanel;
+ }
+
+ get frameLoader() {
+ // If we don't have a frameLoader yet, just return a dummy with no width and
+ // height.
+ return super.frameLoader || { lazyWidth: 0, lazyHeight: 0 };
+ }
+
+ get hidden() {
+ return this.nativeTab.hidden;
+ }
+
+ get sharingState() {
+ return this.window.gBrowser.getTabSharingState(this.nativeTab);
+ }
+
+ get cookieStoreId() {
+ return getCookieStoreIdForTab(this, this.nativeTab);
+ }
+
+ get openerTabId() {
+ let opener = this.nativeTab.openerTab;
+ if (
+ opener &&
+ opener.parentNode &&
+ opener.ownerDocument == this.nativeTab.ownerDocument
+ ) {
+ return tabTracker.getId(opener);
+ }
+ return null;
+ }
+
+ get height() {
+ return this.frameLoader.lazyHeight;
+ }
+
+ get index() {
+ return this.nativeTab._tPos;
+ }
+
+ get mutedInfo() {
+ let { nativeTab } = this;
+
+ let mutedInfo = { muted: nativeTab.muted };
+ if (nativeTab.muteReason === null) {
+ mutedInfo.reason = "user";
+ } else if (nativeTab.muteReason) {
+ mutedInfo.reason = "extension";
+ mutedInfo.extensionId = nativeTab.muteReason;
+ }
+
+ return mutedInfo;
+ }
+
+ get lastAccessed() {
+ return this.nativeTab.lastAccessed;
+ }
+
+ get pinned() {
+ return this.nativeTab.pinned;
+ }
+
+ get active() {
+ return this.nativeTab.selected;
+ }
+
+ get highlighted() {
+ let { selected, multiselected } = this.nativeTab;
+ return selected || multiselected;
+ }
+
+ get selected() {
+ return this.nativeTab.selected;
+ }
+
+ get status() {
+ if (this.nativeTab.getAttribute("busy") === "true") {
+ return "loading";
+ }
+ return "complete";
+ }
+
+ get width() {
+ return this.frameLoader.lazyWidth;
+ }
+
+ get window() {
+ return this.nativeTab.ownerGlobal;
+ }
+
+ get windowId() {
+ return windowTracker.getId(this.window);
+ }
+
+ get isArticle() {
+ return this.nativeTab.linkedBrowser.isArticle;
+ }
+
+ get isInReaderMode() {
+ return this.url && this.url.startsWith(READER_MODE_PREFIX);
+ }
+
+ get successorTabId() {
+ const { successor } = this.nativeTab;
+ return successor ? tabTracker.getId(successor) : -1;
+ }
+
+ /**
+ * Converts session store data to an object compatible with the return value
+ * of the convert() method, representing that data.
+ *
+ * @param {Extension} extension
+ * The extension for which to convert the data.
+ * @param {Object} tabData
+ * Session store data for a closed tab, as returned by
+ * `SessionStore.getClosedTabData()`.
+ * @param {DOMWindow} [window = null]
+ * The browser window which the tab belonged to before it was closed.
+ * May be null if the window the tab belonged to no longer exists.
+ *
+ * @returns {Object}
+ * @static
+ */
+ static convertFromSessionStoreClosedData(extension, tabData, window = null) {
+ let result = {
+ sessionId: String(tabData.closedId),
+ index: tabData.pos ? tabData.pos : 0,
+ windowId: window && windowTracker.getId(window),
+ highlighted: false,
+ active: false,
+ pinned: false,
+ hidden: tabData.state ? tabData.state.hidden : tabData.hidden,
+ incognito: Boolean(tabData.state && tabData.state.isPrivate),
+ lastAccessed: tabData.state
+ ? tabData.state.lastAccessed
+ : tabData.lastAccessed,
+ };
+
+ // tabData is a representation of a tab, as stored in the session data,
+ // and given that is not a real nativeTab, we only need to check if the extension
+ // has the "tabs" permission (because tabData represents a closed tab, and so we
+ // already know that it can't be the activeTab).
+ if (extension.hasPermission("tabs")) {
+ let entries = tabData.state ? tabData.state.entries : tabData.entries;
+ let lastTabIndex = tabData.state ? tabData.state.index : tabData.index;
+ // We need to take lastTabIndex - 1 because the index in the tab data is
+ // 1-based rather than 0-based.
+ let entry = entries[lastTabIndex - 1];
+ result.url = entry.url;
+ result.title = entry.title;
+ if (tabData.image) {
+ result.favIconUrl = tabData.image;
+ }
+ }
+
+ return result;
+ }
+}
+
+class Window extends WindowBase {
+ /**
+ * Update the geometry of the browser window.
+ *
+ * @param {Object} options
+ * An object containing new values for the window's geometry.
+ * @param {integer} [options.left]
+ * The new pixel distance of the left side of the browser window from
+ * the left of the screen.
+ * @param {integer} [options.top]
+ * The new pixel distance of the top side of the browser window from
+ * the top of the screen.
+ * @param {integer} [options.width]
+ * The new pixel width of the window.
+ * @param {integer} [options.height]
+ * The new pixel height of the window.
+ */
+ updateGeometry(options) {
+ let { window } = this;
+
+ if (options.left !== null || options.top !== null) {
+ let left = options.left !== null ? options.left : window.screenX;
+ let top = options.top !== null ? options.top : window.screenY;
+ window.moveTo(left, top);
+ }
+
+ if (options.width !== null || options.height !== null) {
+ let width = options.width !== null ? options.width : window.outerWidth;
+ let height =
+ options.height !== null ? options.height : window.outerHeight;
+ window.resizeTo(width, height);
+ }
+ }
+
+ get _title() {
+ return this.window.document.title;
+ }
+
+ setTitlePreface(titlePreface) {
+ this.window.document.documentElement.setAttribute(
+ "titlepreface",
+ titlePreface
+ );
+ }
+
+ get focused() {
+ return this.window.document.hasFocus();
+ }
+
+ get top() {
+ return this.window.screenY;
+ }
+
+ get left() {
+ return this.window.screenX;
+ }
+
+ get width() {
+ return this.window.outerWidth;
+ }
+
+ get height() {
+ return this.window.outerHeight;
+ }
+
+ get incognito() {
+ return PrivateBrowsingUtils.isWindowPrivate(this.window);
+ }
+
+ get alwaysOnTop() {
+ return this.appWindow.zLevel >= Ci.nsIAppWindow.raisedZ;
+ }
+
+ get isLastFocused() {
+ return this.window === windowTracker.topWindow;
+ }
+
+ static getState(window) {
+ const STATES = {
+ [window.STATE_MAXIMIZED]: "maximized",
+ [window.STATE_MINIMIZED]: "minimized",
+ [window.STATE_NORMAL]: "normal",
+ };
+ let state = STATES[window.windowState];
+ if (window.fullScreen) {
+ state = "fullscreen";
+ }
+ return state;
+ }
+
+ get state() {
+ return Window.getState(this.window);
+ }
+
+ set state(state) {
+ let { window } = this;
+ if (state !== "fullscreen" && window.fullScreen) {
+ window.fullScreen = false;
+ }
+
+ switch (state) {
+ case "maximized":
+ window.maximize();
+ break;
+
+ case "minimized":
+ case "docked":
+ window.minimize();
+ break;
+
+ case "normal":
+ // Restore sometimes returns the window to its previous state, rather
+ // than to the "normal" state, so it may need to be called anywhere from
+ // zero to two times.
+ window.restore();
+ if (window.windowState !== window.STATE_NORMAL) {
+ window.restore();
+ }
+ if (window.windowState !== window.STATE_NORMAL) {
+ // And on OS-X, where normal vs. maximized is basically a heuristic,
+ // we need to cheat.
+ window.sizeToContent();
+ }
+ break;
+
+ case "fullscreen":
+ window.fullScreen = true;
+ break;
+
+ default:
+ throw new Error(`Unexpected window state: ${state}`);
+ }
+ }
+
+ *getTabs() {
+ // A new window is being opened and it is adopting an existing tab, we return
+ // an empty iterator here because there should be no other tabs to return during
+ // that duration (See Bug 1458918 for a rationale).
+ if (this.window.gBrowserInit.isAdoptingTab()) {
+ return;
+ }
+
+ let { tabManager } = this.extension;
+
+ for (let nativeTab of this.window.gBrowser.tabs) {
+ yield tabManager.getWrapper(nativeTab);
+ }
+ }
+
+ *getHighlightedTabs() {
+ let { tabManager } = this.extension;
+ for (let tab of this.window.gBrowser.selectedTabs) {
+ yield tabManager.getWrapper(tab);
+ }
+ }
+
+ get activeTab() {
+ let { tabManager } = this.extension;
+
+ // A new window is being opened and it is adopting a tab, and we do not create
+ // a TabWrapper for the tab being adopted because it will go away once the tab
+ // adoption has been completed (See Bug 1458918 for rationale).
+ if (this.window.gBrowserInit.isAdoptingTab()) {
+ return null;
+ }
+
+ return tabManager.getWrapper(this.window.gBrowser.selectedTab);
+ }
+
+ getTabAtIndex(index) {
+ let nativeTab = this.window.gBrowser.tabs[index];
+ if (nativeTab) {
+ return this.extension.tabManager.getWrapper(nativeTab);
+ }
+ }
+
+ /**
+ * Converts session store data to an object compatible with the return value
+ * of the convert() method, representing that data.
+ *
+ * @param {Extension} extension
+ * The extension for which to convert the data.
+ * @param {Object} windowData
+ * Session store data for a closed window, as returned by
+ * `SessionStore.getClosedWindowData()`.
+ *
+ * @returns {Object}
+ * @static
+ */
+ static convertFromSessionStoreClosedData(extension, windowData) {
+ let result = {
+ sessionId: String(windowData.closedId),
+ focused: false,
+ incognito: false,
+ type: "normal", // this is always "normal" for a closed window
+ // Surely this does not actually work?
+ state: this.getState(windowData),
+ alwaysOnTop: false,
+ };
+
+ if (windowData.tabs.length) {
+ result.tabs = windowData.tabs.map(tabData => {
+ return Tab.convertFromSessionStoreClosedData(extension, tabData);
+ });
+ }
+
+ return result;
+ }
+}
+
+Object.assign(global, { Tab, Window });
+
+class TabManager extends TabManagerBase {
+ get(tabId, default_ = undefined) {
+ let nativeTab = tabTracker.getTab(tabId, default_);
+
+ if (nativeTab) {
+ if (!this.extension.canAccessWindow(nativeTab.ownerGlobal)) {
+ throw new ExtensionError(`Invalid tab ID: ${tabId}`);
+ }
+ return this.getWrapper(nativeTab);
+ }
+ return default_;
+ }
+
+ addActiveTabPermission(nativeTab = tabTracker.activeTab) {
+ return super.addActiveTabPermission(nativeTab);
+ }
+
+ revokeActiveTabPermission(nativeTab = tabTracker.activeTab) {
+ return super.revokeActiveTabPermission(nativeTab);
+ }
+
+ canAccessTab(nativeTab) {
+ return (
+ this.extension.privateBrowsingAllowed ||
+ !PrivateBrowsingUtils.isBrowserPrivate(nativeTab.linkedBrowser)
+ );
+ }
+
+ wrapTab(nativeTab) {
+ return new Tab(this.extension, nativeTab, tabTracker.getId(nativeTab));
+ }
+}
+
+class WindowManager extends WindowManagerBase {
+ get(windowId, context) {
+ let window = windowTracker.getWindow(windowId, context);
+
+ return this.getWrapper(window);
+ }
+
+ *getAll(context) {
+ for (let window of windowTracker.browserWindows()) {
+ if (!this.canAccessWindow(window, context)) {
+ continue;
+ }
+ let wrapped = this.getWrapper(window);
+ if (wrapped) {
+ yield wrapped;
+ }
+ }
+ }
+
+ wrapWindow(window) {
+ return new Window(this.extension, window, windowTracker.getId(window));
+ }
+}
+
+// eslint-disable-next-line mozilla/balanced-listeners
+extensions.on("startup", (type, extension) => {
+ defineLazyGetter(extension, "tabManager", () => new TabManager(extension));
+ defineLazyGetter(
+ extension,
+ "windowManager",
+ () => new WindowManager(extension)
+ );
+});
diff --git a/browser/components/extensions/parent/ext-browserAction.js b/browser/components/extensions/parent/ext-browserAction.js
new file mode 100644
index 0000000000..8351ab7ddf
--- /dev/null
+++ b/browser/components/extensions/parent/ext-browserAction.js
@@ -0,0 +1,689 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "CustomizableUI",
+ "resource:///modules/CustomizableUI.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "clearTimeout",
+ "resource://gre/modules/Timer.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionTelemetry",
+ "resource://gre/modules/ExtensionTelemetry.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "setTimeout",
+ "resource://gre/modules/Timer.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ViewPopup",
+ "resource:///modules/ExtensionPopups.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserUsageTelemetry",
+ "resource:///modules/BrowserUsageTelemetry.jsm"
+);
+
+var { DefaultWeakMap } = ExtensionUtils;
+
+var { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+var { BrowserActionBase } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionActions.jsm"
+);
+
+var { IconDetails, StartupCache } = ExtensionParent;
+
+const POPUP_PRELOAD_TIMEOUT_MS = 200;
+
+// WeakMap[Extension -> BrowserAction]
+const browserActionMap = new WeakMap();
+
+XPCOMUtils.defineLazyGetter(this, "browserAreas", () => {
+ return {
+ navbar: CustomizableUI.AREA_NAVBAR,
+ menupanel: CustomizableUI.AREA_FIXED_OVERFLOW_PANEL,
+ tabstrip: CustomizableUI.AREA_TABSTRIP,
+ personaltoolbar: CustomizableUI.AREA_BOOKMARKS,
+ };
+});
+
+function actionWidgetId(widgetId) {
+ return `${widgetId}-browser-action`;
+}
+
+class BrowserAction extends BrowserActionBase {
+ constructor(extension, buttonDelegate) {
+ let tabContext = new TabContext(target => {
+ let window = target.ownerGlobal;
+ if (target === window) {
+ return this.getContextData(null);
+ }
+ return tabContext.get(window);
+ });
+ super(tabContext, extension);
+ this.buttonDelegate = buttonDelegate;
+ }
+
+ updateOnChange(target) {
+ if (target) {
+ let window = target.ownerGlobal;
+ if (target === window || target.selected) {
+ this.buttonDelegate.updateWindow(window);
+ }
+ } else {
+ for (let window of windowTracker.browserWindows()) {
+ this.buttonDelegate.updateWindow(window);
+ }
+ }
+ }
+
+ getTab(tabId) {
+ if (tabId !== null) {
+ return tabTracker.getTab(tabId);
+ }
+ return null;
+ }
+
+ getWindow(windowId) {
+ if (windowId !== null) {
+ return windowTracker.getWindow(windowId);
+ }
+ return null;
+ }
+}
+
+this.browserAction = class extends ExtensionAPI {
+ static for(extension) {
+ return browserActionMap.get(extension);
+ }
+
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+
+ let options = extension.manifest.browser_action;
+
+ this.action = new BrowserAction(extension, this);
+ await this.action.loadIconData();
+
+ this.iconData = new DefaultWeakMap(icons => this.getIconData(icons));
+ this.iconData.set(
+ this.action.getIcon(),
+ await StartupCache.get(
+ extension,
+ ["browserAction", "default_icon_data"],
+ () => this.getIconData(this.action.getIcon())
+ )
+ );
+
+ let widgetId = makeWidgetId(extension.id);
+ this.id = actionWidgetId(widgetId);
+ this.viewId = `PanelUI-webext-${widgetId}-browser-action-view`;
+ this.widget = null;
+
+ this.pendingPopup = null;
+ this.pendingPopupTimeout = null;
+ this.eventQueue = [];
+
+ this.tabManager = extension.tabManager;
+ this.browserStyle = options.browser_style;
+
+ browserActionMap.set(extension, this);
+
+ this.build();
+ }
+
+ static onUpdate(id, manifest) {
+ if (!("browser_action" in manifest)) {
+ // If the new version has no browser action then mark this widget as
+ // hidden in the telemetry. If it is already marked hidden then this will
+ // do nothing.
+ BrowserUsageTelemetry.recordWidgetChange(
+ actionWidgetId(makeWidgetId(id)),
+ null,
+ "addon"
+ );
+ }
+ }
+
+ static onDisable(id) {
+ BrowserUsageTelemetry.recordWidgetChange(
+ actionWidgetId(makeWidgetId(id)),
+ null,
+ "addon"
+ );
+ }
+
+ static onUninstall(id) {
+ // If the telemetry already has this widget as hidden then this will not
+ // record anything.
+ BrowserUsageTelemetry.recordWidgetChange(
+ actionWidgetId(makeWidgetId(id)),
+ null,
+ "addon"
+ );
+ }
+
+ onShutdown() {
+ browserActionMap.delete(this.extension);
+ this.action.onShutdown();
+
+ CustomizableUI.destroyWidget(this.id);
+
+ this.clearPopup();
+ }
+
+ build() {
+ let widget = CustomizableUI.createWidget({
+ id: this.id,
+ viewId: this.viewId,
+ type: "view",
+ removable: true,
+ label: this.action.getProperty(null, "title"),
+ tooltiptext: this.action.getProperty(null, "title"),
+ defaultArea: browserAreas[this.action.getDefaultArea()],
+ showInPrivateBrowsing: this.extension.privateBrowsingAllowed,
+
+ // Don't attempt to load properties from the built-in widget string
+ // bundle.
+ localized: false,
+
+ onBeforeCreated: document => {
+ let view = document.createXULElement("panelview");
+ view.id = this.viewId;
+ view.setAttribute("flex", "1");
+ view.setAttribute("extension", true);
+
+ document.getElementById("appMenu-viewCache").appendChild(view);
+
+ if (
+ this.extension.hasPermission("menus") ||
+ this.extension.hasPermission("contextMenus")
+ ) {
+ document.addEventListener("popupshowing", this);
+ }
+ },
+
+ onDestroyed: document => {
+ document.removeEventListener("popupshowing", this);
+
+ let view = document.getElementById(this.viewId);
+ if (view) {
+ this.clearPopup();
+ CustomizableUI.hidePanelForNode(view);
+ view.remove();
+ }
+ },
+
+ onCreated: node => {
+ node.classList.add("panel-no-padding");
+ node.classList.add("webextension-browser-action");
+ node.setAttribute("badged", "true");
+ node.setAttribute("constrain-size", "true");
+ node.setAttribute("data-extensionid", this.extension.id);
+
+ node.onmousedown = event => this.handleEvent(event);
+ node.onmouseover = event => this.handleEvent(event);
+ node.onmouseout = event => this.handleEvent(event);
+ node.onauxclick = event => this.handleEvent(event);
+
+ this.updateButton(node, this.action.getContextData(null), true);
+ },
+
+ onBeforeCommand: event => {
+ this.lastClickInfo = {
+ button: event.button || 0,
+ modifiers: clickModifiersFromEvent(event),
+ };
+ },
+
+ onViewShowing: async event => {
+ const { extension } = this;
+
+ ExtensionTelemetry.browserActionPopupOpen.stopwatchStart(
+ extension,
+ this
+ );
+ let document = event.target.ownerDocument;
+ let tabbrowser = document.defaultView.gBrowser;
+
+ let tab = tabbrowser.selectedTab;
+ let popupURL = this.action.getProperty(tab, "popup");
+ this.tabManager.addActiveTabPermission(tab);
+
+ // Popups are shown only if a popup URL is defined; otherwise
+ // a "click" event is dispatched. This is done for compatibility with the
+ // Google Chrome onClicked extension API.
+ if (popupURL) {
+ try {
+ let popup = this.getPopup(document.defaultView, popupURL);
+ let attachPromise = popup.attach(event.target);
+ event.detail.addBlocker(attachPromise);
+ await attachPromise;
+ ExtensionTelemetry.browserActionPopupOpen.stopwatchFinish(
+ extension,
+ this
+ );
+ if (this.eventQueue.length) {
+ ExtensionTelemetry.browserActionPreloadResult.histogramAdd({
+ category: "popupShown",
+ extension,
+ });
+ this.eventQueue = [];
+ }
+ } catch (e) {
+ ExtensionTelemetry.browserActionPopupOpen.stopwatchCancel(
+ extension,
+ this
+ );
+ Cu.reportError(e);
+ event.preventDefault();
+ }
+ } else {
+ ExtensionTelemetry.browserActionPopupOpen.stopwatchCancel(
+ extension,
+ this
+ );
+ // This isn't not a hack, but it seems to provide the correct behavior
+ // with the fewest complications.
+ event.preventDefault();
+ this.emit("click", tabbrowser.selectedBrowser);
+ // Ensure we close any popups this node was in:
+ CustomizableUI.hidePanelForNode(event.target);
+ }
+ },
+ });
+
+ if (this.extension.startupReason != "APP_STARTUP") {
+ // Make sure the browser telemetry has the correct state for this widget.
+ // Defer loading BrowserUsageTelemetry until after startup is complete.
+ ExtensionParent.browserStartupPromise.then(() => {
+ let placement = CustomizableUI.getPlacementOfWidget(widget.id);
+ BrowserUsageTelemetry.recordWidgetChange(
+ widget.id,
+ placement?.area || null,
+ "addon"
+ );
+ });
+ }
+
+ this.widget = widget;
+ }
+
+ /**
+ * Triggers this browser action for the given window, with the same effects as
+ * if it were clicked by a user.
+ *
+ * This has no effect if the browser action is disabled for, or not
+ * present in, the given window.
+ *
+ * @param {Window} window
+ */
+ async triggerAction(window) {
+ let popup = ViewPopup.for(this.extension, window);
+ if (!this.pendingPopup && popup) {
+ popup.closePopup();
+ return;
+ }
+
+ let widget = this.widget.forWindow(window);
+ let tab = window.gBrowser.selectedTab;
+
+ if (!widget.node || !this.action.getProperty(tab, "enabled")) {
+ return;
+ }
+
+ // Popups are shown only if a popup URL is defined; otherwise
+ // a "click" event is dispatched. This is done for compatibility with the
+ // Google Chrome onClicked extension API.
+ if (this.action.getProperty(tab, "popup")) {
+ if (this.widget.areaType == CustomizableUI.TYPE_MENU_PANEL) {
+ await window.document.getElementById("nav-bar").overflowable.show();
+ }
+
+ let event = new window.CustomEvent("command", {
+ bubbles: true,
+ cancelable: true,
+ });
+ widget.node.dispatchEvent(event);
+ } else {
+ this.tabManager.addActiveTabPermission(tab);
+ this.lastClickInfo = { button: 0, modifiers: [] };
+ this.emit("click");
+ }
+ }
+
+ handleEvent(event) {
+ let button = event.target;
+ let window = button.ownerGlobal;
+
+ switch (event.type) {
+ case "mousedown":
+ if (event.button == 0) {
+ // Begin pre-loading the browser for the popup, so it's more likely to
+ // be ready by the time we get a complete click.
+ let tab = window.gBrowser.selectedTab;
+ let popupURL = this.action.getProperty(tab, "popup");
+ let enabled = this.action.getProperty(tab, "enabled");
+
+ if (
+ popupURL &&
+ enabled &&
+ (this.pendingPopup || !ViewPopup.for(this.extension, window))
+ ) {
+ this.eventQueue.push("Mousedown");
+ // Add permission for the active tab so it will exist for the popup.
+ // Store the tab to revoke the permission during clearPopup.
+ if (!this.tabManager.hasActiveTabPermission(tab)) {
+ this.tabManager.addActiveTabPermission(tab);
+ this.tabToRevokeDuringClearPopup = tab;
+ }
+
+ this.pendingPopup = this.getPopup(window, popupURL);
+ window.addEventListener("mouseup", this, true);
+ } else {
+ this.clearPopup();
+ }
+ }
+ break;
+
+ case "mouseup":
+ if (event.button == 0) {
+ this.clearPopupTimeout();
+ // If we have a pending pre-loaded popup, cancel it after we've waited
+ // long enough that we can be relatively certain it won't be opening.
+ if (this.pendingPopup) {
+ let node = window.gBrowser && this.widget.forWindow(window).node;
+ if (node && node.contains(event.originalTarget)) {
+ this.pendingPopupTimeout = setTimeout(
+ () => this.clearPopup(),
+ POPUP_PRELOAD_TIMEOUT_MS
+ );
+ } else {
+ this.clearPopup();
+ }
+ }
+ }
+ break;
+
+ case "mouseover": {
+ // Begin pre-loading the browser for the popup, so it's more likely to
+ // be ready by the time we get a complete click.
+ let tab = window.gBrowser.selectedTab;
+ let popupURL = this.action.getProperty(tab, "popup");
+ let enabled = this.action.getProperty(tab, "enabled");
+
+ if (
+ popupURL &&
+ enabled &&
+ (this.pendingPopup || !ViewPopup.for(this.extension, window))
+ ) {
+ this.eventQueue.push("Hover");
+ this.pendingPopup = this.getPopup(window, popupURL, true);
+ }
+ break;
+ }
+
+ case "mouseout":
+ if (this.pendingPopup) {
+ if (this.eventQueue.length) {
+ ExtensionTelemetry.browserActionPreloadResult.histogramAdd({
+ category: `clearAfter${this.eventQueue.pop()}`,
+ extension: this.extension,
+ });
+ this.eventQueue = [];
+ }
+ this.clearPopup();
+ }
+ break;
+
+ case "popupshowing":
+ const menu = event.target;
+ const trigger = menu.triggerNode;
+ const node = window.document.getElementById(this.id);
+ const contexts = [
+ "toolbar-context-menu",
+ "customizationPanelItemContextMenu",
+ ];
+
+ if (contexts.includes(menu.id) && node && node.contains(trigger)) {
+ global.actionContextMenu({
+ extension: this.extension,
+ onBrowserAction: true,
+ menu: menu,
+ });
+ }
+ break;
+
+ case "auxclick":
+ if (event.button !== 1) {
+ return;
+ }
+
+ let { gBrowser } = window;
+ if (this.action.getProperty(gBrowser.selectedTab, "enabled")) {
+ this.lastClickInfo = {
+ button: 1,
+ modifiers: clickModifiersFromEvent(event),
+ };
+
+ this.emit("click", gBrowser.selectedBrowser);
+ // Ensure we close any popups this node was in:
+ CustomizableUI.hidePanelForNode(event.target);
+ }
+ break;
+ }
+ }
+
+ /**
+ * Returns a potentially pre-loaded popup for the given URL in the given
+ * window. If a matching pre-load popup already exists, returns that.
+ * Otherwise, initializes a new one.
+ *
+ * If a pre-load popup exists which does not match, it is destroyed before a
+ * new one is created.
+ *
+ * @param {Window} window
+ * The browser window in which to create the popup.
+ * @param {string} popupURL
+ * The URL to load into the popup.
+ * @param {boolean} [blockParser = false]
+ * True if the HTML parser should initially be blocked.
+ * @returns {ViewPopup}
+ */
+ getPopup(window, popupURL, blockParser = false) {
+ this.clearPopupTimeout();
+ let { pendingPopup } = this;
+ this.pendingPopup = null;
+
+ if (pendingPopup) {
+ if (
+ pendingPopup.window === window &&
+ pendingPopup.popupURL === popupURL
+ ) {
+ if (!blockParser) {
+ pendingPopup.unblockParser();
+ }
+
+ return pendingPopup;
+ }
+ pendingPopup.destroy();
+ }
+
+ let fixedWidth =
+ this.widget.areaType == CustomizableUI.TYPE_MENU_PANEL ||
+ this.widget.forWindow(window).overflowed;
+ return new ViewPopup(
+ this.extension,
+ window,
+ popupURL,
+ this.browserStyle,
+ fixedWidth,
+ blockParser
+ );
+ }
+
+ /**
+ * Clears any pending pre-loaded popup and related timeouts.
+ */
+ clearPopup() {
+ this.clearPopupTimeout();
+ if (this.pendingPopup) {
+ if (this.tabToRevokeDuringClearPopup) {
+ this.tabManager.revokeActiveTabPermission(
+ this.tabToRevokeDuringClearPopup
+ );
+ }
+ this.pendingPopup.destroy();
+ this.pendingPopup = null;
+ }
+ this.tabToRevokeDuringClearPopup = null;
+ }
+
+ /**
+ * Clears any pending timeouts to clear stale, pre-loaded popups.
+ */
+ clearPopupTimeout() {
+ if (this.pendingPopup) {
+ this.pendingPopup.window.removeEventListener("mouseup", this, true);
+ }
+
+ if (this.pendingPopupTimeout) {
+ clearTimeout(this.pendingPopupTimeout);
+ this.pendingPopupTimeout = null;
+ }
+ }
+
+ // Update the toolbar button |node| with the tab context data
+ // in |tabData|.
+ updateButton(node, tabData, sync = false) {
+ let title = tabData.title || this.extension.name;
+ let callback = () => {
+ node.setAttribute("tooltiptext", title);
+ node.setAttribute("label", title);
+
+ if (tabData.badgeText) {
+ node.setAttribute("badge", tabData.badgeText);
+ } else {
+ node.removeAttribute("badge");
+ }
+
+ if (tabData.enabled) {
+ node.removeAttribute("disabled");
+ } else {
+ node.setAttribute("disabled", "true");
+ }
+
+ let serializeColor = ([r, g, b, a]) =>
+ `rgba(${r}, ${g}, ${b}, ${a / 255})`;
+ node.setAttribute(
+ "badgeStyle",
+ [
+ `background-color: ${serializeColor(tabData.badgeBackgroundColor)}`,
+ `color: ${serializeColor(this.action.getTextColor(tabData))}`,
+ ].join("; ")
+ );
+
+ let style = this.iconData.get(tabData.icon);
+ node.setAttribute("style", style);
+ };
+ if (sync) {
+ callback();
+ } else {
+ node.ownerGlobal.requestAnimationFrame(callback);
+ }
+ }
+
+ getIconData(icons) {
+ let getIcon = (icon, theme) => {
+ if (typeof icon === "object") {
+ return IconDetails.escapeUrl(icon[theme]);
+ }
+ return IconDetails.escapeUrl(icon);
+ };
+
+ let getStyle = (name, icon) => {
+ return `
+ --webextension-${name}: url("${getIcon(icon, "default")}");
+ --webextension-${name}-light: url("${getIcon(icon, "light")}");
+ --webextension-${name}-dark: url("${getIcon(icon, "dark")}");
+ `;
+ };
+
+ let icon16 = IconDetails.getPreferredIcon(icons, this.extension, 16).icon;
+ let icon32 = IconDetails.getPreferredIcon(icons, this.extension, 32).icon;
+ return `
+ ${getStyle("menupanel-image", icon16)}
+ ${getStyle("menupanel-image-2x", icon32)}
+ ${getStyle("toolbar-image", icon16)}
+ ${getStyle("toolbar-image-2x", icon32)}
+ `;
+ }
+
+ /**
+ * Update the toolbar button for a given window.
+ *
+ * @param {ChromeWindow} window
+ * Browser chrome window.
+ */
+ updateWindow(window) {
+ let node = this.widget.forWindow(window).node;
+ if (node) {
+ let tab = window.gBrowser.selectedTab;
+ this.updateButton(node, this.action.getContextData(tab));
+ }
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+ let { action } = this;
+
+ return {
+ browserAction: {
+ ...action.api(context),
+
+ onClicked: new EventManager({
+ context,
+ name: "browserAction.onClicked",
+ inputHandling: true,
+ register: fire => {
+ let listener = (event, browser) => {
+ context.withPendingBrowser(browser, () =>
+ fire.sync(
+ tabManager.convert(tabTracker.activeTab),
+ this.lastClickInfo
+ )
+ );
+ };
+ this.on("click", listener);
+ return () => {
+ this.off("click", listener);
+ };
+ },
+ }).api(),
+
+ openPopup: () => {
+ let window = windowTracker.topWindow;
+ this.triggerAction(window);
+ },
+ },
+ };
+ }
+};
+
+global.browserActionFor = this.browserAction.for;
diff --git a/browser/components/extensions/parent/ext-chrome-settings-overrides.js b/browser/components/extensions/parent/ext-chrome-settings-overrides.js
new file mode 100644
index 0000000000..266dd88dbd
--- /dev/null
+++ b/browser/components/extensions/parent/ext-chrome-settings-overrides.js
@@ -0,0 +1,537 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionPreferencesManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPreferencesManager.jsm"
+);
+var { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionPermissions",
+ "resource://gre/modules/ExtensionPermissions.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionSettingsStore",
+ "resource://gre/modules/ExtensionSettingsStore.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionControlledPopup",
+ "resource:///modules/ExtensionControlledPopup.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "HomePage",
+ "resource:///modules/HomePage.jsm"
+);
+
+const DEFAULT_SEARCH_STORE_TYPE = "default_search";
+const DEFAULT_SEARCH_SETTING_NAME = "defaultSearch";
+const ENGINE_ADDED_SETTING_NAME = "engineAdded";
+
+const HOMEPAGE_PREF = "browser.startup.homepage";
+const HOMEPAGE_PRIVATE_ALLOWED =
+ "browser.startup.homepage_override.privateAllowed";
+const HOMEPAGE_EXTENSION_CONTROLLED =
+ "browser.startup.homepage_override.extensionControlled";
+const HOMEPAGE_CONFIRMED_TYPE = "homepageNotification";
+const HOMEPAGE_SETTING_TYPE = "prefs";
+const HOMEPAGE_SETTING_NAME = "homepage_override";
+
+XPCOMUtils.defineLazyGetter(this, "homepagePopup", () => {
+ return new ExtensionControlledPopup({
+ confirmedType: HOMEPAGE_CONFIRMED_TYPE,
+ observerTopic: "browser-open-homepage-start",
+ popupnotificationId: "extension-homepage-notification",
+ settingType: HOMEPAGE_SETTING_TYPE,
+ settingKey: HOMEPAGE_SETTING_NAME,
+ descriptionId: "extension-homepage-notification-description",
+ descriptionMessageId: "homepageControlled.message",
+ learnMoreMessageId: "homepageControlled.learnMore",
+ learnMoreLink: "extension-home",
+ preferencesLocation: "home-homeOverride",
+ preferencesEntrypoint: "addon-manage-home-override",
+ async beforeDisableAddon(popup, win) {
+ // Disabling an add-on should remove the tabs that it has open, but we want
+ // to open the new homepage in this tab (which might get closed).
+ // 1. Replace the tab's URL with about:blank, wait for it to change
+ // 2. Now that this tab isn't associated with the add-on, disable the add-on
+ // 3. Trigger the browser's homepage method
+ let gBrowser = win.gBrowser;
+ let tab = gBrowser.selectedTab;
+ await replaceUrlInTab(gBrowser, tab, "about:blank");
+ Services.prefs.addObserver(HOMEPAGE_PREF, async function prefObserver() {
+ Services.prefs.removeObserver(HOMEPAGE_PREF, prefObserver);
+ let loaded = waitForTabLoaded(tab);
+ win.BrowserHome();
+ await loaded;
+ // Manually trigger an event in case this is controlled again.
+ popup.open();
+ });
+ },
+ });
+});
+
+// When the browser starts up it will trigger the observer topic we're expecting
+// but that happens before our observer has been registered. To handle the
+// startup case we need to check if the preferences are set to load the homepage
+// and check if the homepage is active, then show the doorhanger in that case.
+async function handleInitialHomepagePopup(extensionId, homepageUrl) {
+ // browser.startup.page == 1 is show homepage.
+ if (
+ Services.prefs.getIntPref("browser.startup.page") == 1 &&
+ windowTracker.topWindow
+ ) {
+ let { gBrowser } = windowTracker.topWindow;
+ let tab = gBrowser.selectedTab;
+ let currentUrl = gBrowser.currentURI.spec;
+ // When the first window is still loading the URL might be about:blank.
+ // Wait for that the actual page to load before checking the URL, unless
+ // the homepage is set to about:blank.
+ if (currentUrl != homepageUrl && currentUrl == "about:blank") {
+ await waitForTabLoaded(tab);
+ currentUrl = gBrowser.currentURI.spec;
+ }
+ // Once the page has loaded, if necessary and the active tab hasn't changed,
+ // then show the popup now.
+ if (currentUrl == homepageUrl && gBrowser.selectedTab == tab) {
+ homepagePopup.open();
+ return;
+ }
+ }
+ homepagePopup.addObserver(extensionId);
+}
+
+/**
+ * Handles the homepage url setting for an extension.
+ *
+ * @param {object} extension
+ * The extension setting the hompage url.
+ * @param {string} homepageUrl
+ * The homepage url to set.
+ */
+async function handleHomepageUrl(extension, homepageUrl) {
+ let inControl;
+ if (
+ extension.startupReason == "ADDON_INSTALL" ||
+ extension.startupReason == "ADDON_ENABLE"
+ ) {
+ inControl = await ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "homepage_override",
+ homepageUrl
+ );
+ } else {
+ let item = await ExtensionPreferencesManager.getSetting(
+ "homepage_override"
+ );
+ inControl = item && item.id && item.id == extension.id;
+ }
+
+ if (inControl) {
+ Services.prefs.setBoolPref(
+ HOMEPAGE_PRIVATE_ALLOWED,
+ extension.privateBrowsingAllowed
+ );
+ // Also set this now as an upgraded browser will need this.
+ Services.prefs.setBoolPref(HOMEPAGE_EXTENSION_CONTROLLED, true);
+ if (extension.startupReason == "APP_STARTUP") {
+ handleInitialHomepagePopup(extension.id, homepageUrl);
+ } else {
+ homepagePopup.addObserver(extension.id);
+ }
+ }
+
+ // We need to monitor permission change and update the preferences.
+ // eslint-disable-next-line mozilla/balanced-listeners
+ extension.on("add-permissions", async (ignoreEvent, permissions) => {
+ if (permissions.permissions.includes("internal:privateBrowsingAllowed")) {
+ let item = await ExtensionPreferencesManager.getSetting(
+ "homepage_override"
+ );
+ if (item && item.id == extension.id) {
+ Services.prefs.setBoolPref(HOMEPAGE_PRIVATE_ALLOWED, true);
+ }
+ }
+ });
+ // eslint-disable-next-line mozilla/balanced-listeners
+ extension.on("remove-permissions", async (ignoreEvent, permissions) => {
+ if (permissions.permissions.includes("internal:privateBrowsingAllowed")) {
+ let item = await ExtensionPreferencesManager.getSetting(
+ "homepage_override"
+ );
+ if (item && item.id == extension.id) {
+ Services.prefs.setBoolPref(HOMEPAGE_PRIVATE_ALLOWED, false);
+ }
+ }
+ });
+}
+
+// When an extension starts up, a search engine may asynchronously be
+// registered, without blocking the startup. When an extension is
+// uninstalled, we need to wait for this registration to finish
+// before running the uninstallation handler.
+// Map[extension id -> Promise]
+var pendingSearchSetupTasks = new Map();
+
+this.chrome_settings_overrides = class extends ExtensionAPI {
+ static async processDefaultSearchSetting(action, id) {
+ await ExtensionSettingsStore.initialize();
+ let item = ExtensionSettingsStore.getSetting(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ id
+ );
+ if (!item) {
+ return;
+ }
+ let control = await ExtensionSettingsStore.getLevelOfControl(
+ id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ item = ExtensionSettingsStore[action](
+ id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ if (item && control == "controlled_by_this_extension") {
+ try {
+ let engine = Services.search.getEngineByName(
+ item.value || item.initialValue
+ );
+ if (engine) {
+ Services.search.defaultEngine = engine;
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ }
+
+ static async removeEngine(id) {
+ await ExtensionSettingsStore.initialize();
+ let item = await ExtensionSettingsStore.getSetting(
+ DEFAULT_SEARCH_STORE_TYPE,
+ ENGINE_ADDED_SETTING_NAME,
+ id
+ );
+ if (item) {
+ ExtensionSettingsStore.removeSetting(
+ id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ ENGINE_ADDED_SETTING_NAME
+ );
+ }
+
+ try {
+ await Services.search.removeWebExtensionEngine(id);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+
+ static removeSearchSettings(id) {
+ return Promise.all([
+ this.processDefaultSearchSetting("removeSetting", id),
+ this.removeEngine(id),
+ ]);
+ }
+
+ static async onEnabling(id) {
+ await ExtensionSettingsStore.initialize();
+ let item = await ExtensionSettingsStore.getSetting(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ id
+ );
+ if (item) {
+ ExtensionSettingsStore.enable(
+ id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+ }
+
+ static async onUninstall(id) {
+ let searchStartupPromise = pendingSearchSetupTasks.get(id);
+ if (searchStartupPromise) {
+ await searchStartupPromise.catch(Cu.reportError);
+ }
+ // Note: We do not have to deal with homepage here as it is managed by
+ // the ExtensionPreferencesManager.
+ return Promise.all([
+ this.removeSearchSettings(id),
+ homepagePopup.clearConfirmation(id),
+ ]);
+ }
+
+ static async onUpdate(id, manifest) {
+ let haveHomepage =
+ manifest &&
+ manifest.chrome_settings_overrides &&
+ manifest.chrome_settings_overrides.homepage;
+
+ if (!haveHomepage) {
+ ExtensionPreferencesManager.removeSetting(id, "homepage_override");
+ }
+
+ let haveSearchProvider =
+ manifest &&
+ manifest.chrome_settings_overrides &&
+ manifest.chrome_settings_overrides.search_provider;
+
+ if (!haveSearchProvider) {
+ this.removeSearchSettings(id);
+ } else if (
+ !!haveSearchProvider.is_default &&
+ (await ExtensionSettingsStore.initialize()) &&
+ ExtensionSettingsStore.hasSetting(
+ id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ )
+ ) {
+ // is_default has been removed, but we still have a setting. Remove it.
+ chrome_settings_overrides.processDefaultSearchSetting(
+ "removeSetting",
+ id
+ );
+ }
+ }
+
+ static async onDisable(id) {
+ homepagePopup.clearConfirmation(id);
+
+ await chrome_settings_overrides.processDefaultSearchSetting("disable", id);
+ await chrome_settings_overrides.removeEngine(id);
+ }
+
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+ let { manifest } = extension;
+ let homepageUrl = manifest.chrome_settings_overrides.homepage;
+
+ // If this is a page we ignore, just skip the homepage setting completely.
+ if (homepageUrl) {
+ const ignoreHomePageUrl = await HomePage.shouldIgnore(homepageUrl);
+
+ if (ignoreHomePageUrl) {
+ Services.telemetry.recordEvent(
+ "homepage",
+ "preference",
+ "ignore",
+ "set_blocked_extension",
+ {
+ webExtensionId: extension.id,
+ }
+ );
+ } else {
+ await handleHomepageUrl(extension, homepageUrl);
+ }
+ }
+ if (manifest.chrome_settings_overrides.search_provider) {
+ // Registering a search engine can potentially take a long while,
+ // or not complete at all (when searchInitialized is never resolved),
+ // so we are deliberately not awaiting the returned promise here.
+ let searchStartupPromise = this.processSearchProviderManifestEntry().finally(
+ () => {
+ if (
+ pendingSearchSetupTasks.get(extension.id) === searchStartupPromise
+ ) {
+ pendingSearchSetupTasks.delete(extension.id);
+ // This is primarily for tests so that we know when an extension
+ // has finished initialising.
+ ExtensionParent.apiManager.emit("searchEngineProcessed", extension);
+ }
+ }
+ );
+
+ // Save the promise so we can await at onUninstall.
+ pendingSearchSetupTasks.set(extension.id, searchStartupPromise);
+ }
+ }
+
+ async processSearchProviderManifestEntry() {
+ let { extension } = this;
+ let { manifest } = extension;
+ let searchProvider = manifest.chrome_settings_overrides.search_provider;
+
+ // If we're not being requested to be set as default, then all we need
+ // to do is to add the engine to the service. The search service can cope
+ // with receiving added engines before it is initialised, so we don't have
+ // to wait for it.
+ if (!searchProvider.is_default) {
+ await this.addSearchEngine();
+ return;
+ }
+
+ await searchInitialized;
+ if (!this.extension) {
+ Cu.reportError(
+ `Extension shut down before search provider was registered`
+ );
+ return;
+ }
+
+ let engineName = searchProvider.name.trim();
+ let result = await Services.search.maybeSetAndOverrideDefault(extension);
+ if (result.canChangeToAppProvided) {
+ await this.setDefault(engineName);
+ }
+ if (!result.canInstallEngine) {
+ // This extension is overriding an app-provided one, so we don't
+ // add its engine as well.
+ return;
+ }
+ await this.addSearchEngine();
+ if (extension.startupReason === "ADDON_INSTALL") {
+ // Don't ask if it already the current engine
+ let engine = Services.search.getEngineByName(engineName);
+ let defaultEngine = await Services.search.getDefault();
+ if (defaultEngine.name != engine.name) {
+ let subject = {
+ wrappedJSObject: {
+ // This is a hack because we don't have the browser of
+ // the actual install. This means the popup might show
+ // in a different window. Will be addressed in a followup bug.
+ browser: windowTracker.topWindow.gBrowser.selectedBrowser,
+ name: this.extension.name,
+ icon: this.extension.iconURL,
+ currentEngine: defaultEngine.name,
+ newEngine: engineName,
+ async respond(allow) {
+ if (allow) {
+ await ExtensionSettingsStore.initialize();
+ ExtensionSettingsStore.addSetting(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ engineName,
+ () => defaultEngine.name
+ );
+ Services.search.defaultEngine = Services.search.getEngineByName(
+ engineName
+ );
+ }
+ },
+ },
+ };
+ Services.obs.notifyObservers(
+ subject,
+ "webextension-defaultsearch-prompt"
+ );
+ }
+ } else {
+ // Needs to be called every time to handle reenabling, but
+ // only sets default for install or enable.
+ await this.setDefault(engineName);
+ }
+ }
+
+ async setDefault(engineName) {
+ let { extension } = this;
+ if (extension.startupReason === "ADDON_INSTALL") {
+ let defaultEngine = await Services.search.getDefault();
+ await ExtensionSettingsStore.initialize();
+ // We should only get here if an extension is setting an app-provided
+ // engine to default and we are ignoring the addons other engine settings.
+ // In this case we do not show the prompt to the user.
+ let item = await ExtensionSettingsStore.addSetting(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ engineName,
+ () => defaultEngine.name
+ );
+ await Services.search.setDefault(
+ Services.search.getEngineByName(item.value)
+ );
+ } else if (extension.startupReason === "ADDON_ENABLE") {
+ // We would be called for every extension being enabled, we should verify
+ // that it has control and only then set it as default
+ let control = await ExtensionSettingsStore.getLevelOfControl(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ if (control === "controlled_by_this_extension") {
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName)
+ );
+ }
+ }
+ }
+
+ async addSearchEngine() {
+ let { extension } = this;
+ try {
+ let engines = await Services.search.addEnginesFromExtension(extension);
+ if (engines.length) {
+ await ExtensionSettingsStore.initialize();
+ await ExtensionSettingsStore.addSetting(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ ENGINE_ADDED_SETTING_NAME,
+ engines[0].name
+ );
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ return false;
+ }
+ return true;
+ }
+};
+
+ExtensionPreferencesManager.addSetting("homepage_override", {
+ prefNames: [
+ HOMEPAGE_PREF,
+ HOMEPAGE_EXTENSION_CONTROLLED,
+ HOMEPAGE_PRIVATE_ALLOWED,
+ ],
+ // ExtensionPreferencesManager will call onPrefsChanged when control changes
+ // and it updates the preferences. We are passed the item from
+ // ExtensionSettingsStore that details what is in control. If there is an id
+ // then control has changed to an extension, if there is no id then control
+ // has been returned to the user.
+ async onPrefsChanged(item) {
+ if (item.id) {
+ homepagePopup.addObserver(item.id);
+
+ let policy = ExtensionParent.WebExtensionPolicy.getByID(item.id);
+ let allowed = policy && policy.privateBrowsingAllowed;
+ if (!policy) {
+ // We'll generally hit this path during safe mode changes.
+ let perms = await ExtensionPermissions.get(item.id);
+ allowed = perms.permissions.includes("internal:privateBrowsingAllowed");
+ }
+ Services.prefs.setBoolPref(HOMEPAGE_PRIVATE_ALLOWED, allowed);
+ Services.prefs.setBoolPref(HOMEPAGE_EXTENSION_CONTROLLED, true);
+ } else {
+ homepagePopup.removeObserver();
+
+ Services.prefs.clearUserPref(HOMEPAGE_PRIVATE_ALLOWED);
+ Services.prefs.clearUserPref(HOMEPAGE_EXTENSION_CONTROLLED);
+ }
+ },
+ setCallback(value) {
+ // Setting the pref will result in onPrefsChanged being called, which
+ // will then set HOMEPAGE_PRIVATE_ALLOWED. We want to ensure that this
+ // pref will be set/unset as apropriate.
+ return {
+ [HOMEPAGE_PREF]: value,
+ [HOMEPAGE_EXTENSION_CONTROLLED]: !!value,
+ [HOMEPAGE_PRIVATE_ALLOWED]: false,
+ };
+ },
+});
diff --git a/browser/components/extensions/parent/ext-commands.js b/browser/components/extensions/parent/ext-commands.js
new file mode 100644
index 0000000000..11d32c81c8
--- /dev/null
+++ b/browser/components/extensions/parent/ext-commands.js
@@ -0,0 +1,57 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionShortcuts",
+ "resource://gre/modules/ExtensionShortcuts.jsm"
+);
+
+this.commands = class extends ExtensionAPI {
+ static onUninstall(extensionId) {
+ return ExtensionShortcuts.removeCommandsFromStorage(extensionId);
+ }
+
+ async onManifestEntry(entryName) {
+ let shortcuts = new ExtensionShortcuts({
+ extension: this.extension,
+ onCommand: name => this.emit("command", name),
+ });
+ this.extension.shortcuts = shortcuts;
+ await shortcuts.loadCommands();
+ await shortcuts.register();
+ }
+
+ onShutdown() {
+ this.extension.shortcuts.unregister();
+ }
+
+ getAPI(context) {
+ return {
+ commands: {
+ getAll: () => this.extension.shortcuts.allCommands(),
+ update: args => this.extension.shortcuts.updateCommand(args),
+ reset: name => this.extension.shortcuts.resetCommand(name),
+ onCommand: new EventManager({
+ context,
+ name: "commands.onCommand",
+ inputHandling: true,
+ register: fire => {
+ let listener = (eventName, commandName) => {
+ fire.async(commandName);
+ };
+ this.on("command", listener);
+ return () => {
+ this.off("command", listener);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-devtools-inspectedWindow.js b/browser/components/extensions/parent/ext-devtools-inspectedWindow.js
new file mode 100644
index 0000000000..1d5cf18f12
--- /dev/null
+++ b/browser/components/extensions/parent/ext-devtools-inspectedWindow.js
@@ -0,0 +1,53 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { SpreadArgs } = ExtensionCommon;
+
+this.devtools_inspectedWindow = class extends ExtensionAPI {
+ getAPI(context) {
+ // TODO - Bug 1448878: retrieve a more detailed callerInfo object,
+ // like the filename and lineNumber of the actual extension called
+ // in the child process.
+ const callerInfo = {
+ addonId: context.extension.id,
+ url: context.extension.baseURI.spec,
+ };
+
+ return {
+ devtools: {
+ inspectedWindow: {
+ async eval(expression, options) {
+ const front = await getInspectedWindowFront(context);
+ const toolboxEvalOptions = await getToolboxEvalOptions(context);
+ const evalOptions = Object.assign({}, options, toolboxEvalOptions);
+
+ const evalResult = await front.eval(
+ callerInfo,
+ expression,
+ evalOptions
+ );
+
+ // TODO(rpl): check for additional undocumented behaviors on chrome
+ // (e.g. if we should also print error to the console or set lastError?).
+ return new SpreadArgs([evalResult.value, evalResult.exceptionInfo]);
+ },
+ async reload(options) {
+ const { ignoreCache, userAgent, injectedScript } = options || {};
+
+ const front = await getInspectedWindowFront(context);
+ front.reload(callerInfo, {
+ ignoreCache,
+ userAgent,
+ injectedScript,
+ });
+ },
+ },
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-devtools-network.js b/browser/components/extensions/parent/ext-devtools-network.js
new file mode 100644
index 0000000000..20d609a549
--- /dev/null
+++ b/browser/components/extensions/parent/ext-devtools-network.js
@@ -0,0 +1,82 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { SpreadArgs } = ExtensionCommon;
+
+var { ExtensionError } = ExtensionUtils;
+
+this.devtools_network = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ devtools: {
+ network: {
+ onNavigated: new EventManager({
+ context,
+ name: "devtools.onNavigated",
+ register: fire => {
+ const listener = url => {
+ fire.async(url);
+ };
+
+ const promise = context.addOnNavigatedListener(listener);
+ return () => {
+ promise.then(() => {
+ context.removeOnNavigatedListener(listener);
+ });
+ };
+ },
+ }).api(),
+
+ getHAR: function() {
+ return context.devToolsToolbox.getHARFromNetMonitor();
+ },
+
+ onRequestFinished: new EventManager({
+ context,
+ name: "devtools.network.onRequestFinished",
+ register: fire => {
+ const listener = data => {
+ fire.async(data);
+ };
+
+ const toolbox = context.devToolsToolbox;
+ toolbox.addRequestFinishedListener(listener);
+
+ return () => {
+ toolbox.removeRequestFinishedListener(listener);
+ };
+ },
+ }).api(),
+
+ // The following method is used internally to allow the request API
+ // piece that is running in the child process to ask the parent process
+ // to fetch response content from the back-end.
+ Request: {
+ async getContent(requestId) {
+ return context.devToolsToolbox
+ .fetchResponseContent(requestId)
+ .then(
+ ({ content }) =>
+ new SpreadArgs([content.text, content.mimeType])
+ )
+ .catch(err => {
+ const debugName = context.extension.policy.debugName;
+ const errorMsg =
+ "Unexpected error while fetching response content";
+ Cu.reportError(
+ `${debugName}: ${errorMsg} for ${requestId}: ${err}`
+ );
+ throw new ExtensionError(errorMsg);
+ });
+ },
+ },
+ },
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-devtools-panels.js b/browser/components/extensions/parent/ext-devtools-panels.js
new file mode 100644
index 0000000000..5a1773335a
--- /dev/null
+++ b/browser/components/extensions/parent/ext-devtools-panels.js
@@ -0,0 +1,710 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "BroadcastConduit",
+ "resource://gre/modules/ConduitsParent.jsm"
+);
+
+var { IconDetails, watchExtensionProxyContextLoad } = ExtensionParent;
+
+var { promiseDocumentLoaded } = ExtensionUtils;
+
+const WEBEXT_PANELS_URL = "chrome://browser/content/webext-panels.xhtml";
+
+class BaseDevToolsPanel {
+ constructor(context, panelOptions) {
+ const toolbox = context.devToolsToolbox;
+ if (!toolbox) {
+ // This should never happen when this constructor is called with a valid
+ // devtools extension context.
+ throw Error("Missing mandatory toolbox");
+ }
+
+ this.context = context;
+ this.extension = context.extension;
+ this.toolbox = toolbox;
+ this.viewType = "devtools_panel";
+ this.panelOptions = panelOptions;
+ this.id = panelOptions.id;
+
+ this.unwatchExtensionProxyContextLoad = null;
+
+ // References to the panel browser XUL element and the toolbox window global which
+ // contains the devtools panel UI.
+ this.browser = null;
+ this.browserContainerWindow = null;
+ }
+
+ async createBrowserElement(window) {
+ const { toolbox } = this;
+ const { extension } = this.context;
+ const { url } = this.panelOptions || { url: "about:blank" };
+
+ this.browser = await window.getBrowser({
+ extension,
+ extensionUrl: url,
+ browserStyle: false,
+ viewType: "devtools_panel",
+ browserInsertedData: {
+ devtoolsToolboxInfo: {
+ toolboxPanelId: this.id,
+ inspectedWindowTabId: getTargetTabIdForToolbox(toolbox),
+ },
+ },
+ });
+
+ let hasTopLevelContext = false;
+
+ // Listening to new proxy contexts.
+ this.unwatchExtensionProxyContextLoad = watchExtensionProxyContextLoad(
+ this,
+ context => {
+ // Keep track of the toolbox and target associated to the context, which is
+ // needed by the API methods implementation.
+ context.devToolsToolbox = toolbox;
+
+ if (!hasTopLevelContext) {
+ hasTopLevelContext = true;
+
+ // Resolve the promise when the root devtools_panel context has been created.
+ if (this._resolveTopLevelContext) {
+ this._resolveTopLevelContext(context);
+ }
+ }
+ }
+ );
+
+ this.browser.loadURI(url, { triggeringPrincipal: this.context.principal });
+ }
+
+ destroyBrowserElement() {
+ const { browser, unwatchExtensionProxyContextLoad } = this;
+ if (unwatchExtensionProxyContextLoad) {
+ this.unwatchExtensionProxyContextLoad = null;
+ unwatchExtensionProxyContextLoad();
+ }
+
+ if (browser) {
+ browser.remove();
+ this.browser = null;
+ }
+ }
+}
+
+/**
+ * Represents an addon devtools panel in the main process.
+ *
+ * @param {ExtensionChildProxyContext} context
+ * A devtools extension proxy context running in a main process.
+ * @param {object} options
+ * @param {string} options.id
+ * The id of the addon devtools panel.
+ * @param {string} options.icon
+ * The icon of the addon devtools panel.
+ * @param {string} options.title
+ * The title of the addon devtools panel.
+ * @param {string} options.url
+ * The url of the addon devtools panel, relative to the extension base URL.
+ */
+class ParentDevToolsPanel extends BaseDevToolsPanel {
+ constructor(context, panelOptions) {
+ super(context, panelOptions);
+
+ this.visible = false;
+ this.destroyed = false;
+
+ this.context.callOnClose(this);
+
+ this.conduit = new BroadcastConduit(this, {
+ id: `${this.id}-parent`,
+ send: ["PanelHidden", "PanelShown"],
+ });
+
+ this.onToolboxPanelSelect = this.onToolboxPanelSelect.bind(this);
+ this.onToolboxHostWillChange = this.onToolboxHostWillChange.bind(this);
+ this.onToolboxHostChanged = this.onToolboxHostChanged.bind(this);
+
+ this.waitTopLevelContext = new Promise(resolve => {
+ this._resolveTopLevelContext = resolve;
+ });
+
+ this.panelAdded = false;
+ this.addPanel();
+ }
+
+ addPanel() {
+ const { icon, title } = this.panelOptions;
+ const extensionName = this.context.extension.name;
+
+ this.toolbox.addAdditionalTool({
+ id: this.id,
+ extensionId: this.context.extension.id,
+ url: WEBEXT_PANELS_URL,
+ icon: icon,
+ label: title,
+ // panelLabel is used to set the aria-label attribute (See Bug 1570645).
+ panelLabel: title,
+ tooltip: `DevTools Panel added by "${extensionName}" add-on.`,
+ isTargetSupported: target => target.isLocalTab,
+ build: (window, toolbox) => {
+ if (toolbox !== this.toolbox) {
+ throw new Error(
+ "Unexpected toolbox received on addAdditionalTool build property"
+ );
+ }
+
+ const destroy = this.buildPanel(window);
+
+ return { toolbox, destroy };
+ },
+ });
+
+ this.panelAdded = true;
+ }
+
+ buildPanel(window) {
+ const { toolbox } = this;
+
+ this.createBrowserElement(window);
+
+ // Store the last panel's container element (used to restore it when the toolbox
+ // host is switched between docked and undocked).
+ this.browserContainerWindow = window;
+
+ toolbox.on("select", this.onToolboxPanelSelect);
+ toolbox.on("host-will-change", this.onToolboxHostWillChange);
+ toolbox.on("host-changed", this.onToolboxHostChanged);
+
+ // Return a cleanup method that is when the panel is destroyed, e.g.
+ // - when addon devtool panel has been disabled by the user from the toolbox preferences,
+ // its ParentDevToolsPanel instance is still valid, but the built devtools panel is removed from
+ // the toolbox (and re-built again if the user re-enables it from the toolbox preferences panel)
+ // - when the creator context has been destroyed, the ParentDevToolsPanel close method is called,
+ // it removes the tool definition from the toolbox, which will call this destroy method.
+ return () => {
+ this.destroyBrowserElement();
+ this.browserContainerWindow = null;
+ toolbox.off("select", this.onToolboxPanelSelect);
+ toolbox.off("host-will-change", this.onToolboxHostWillChange);
+ toolbox.off("host-changed", this.onToolboxHostChanged);
+ };
+ }
+
+ onToolboxHostWillChange() {
+ // NOTE: Using a content iframe here breaks the devtools panel
+ // switching between docked and undocked mode,
+ // because of a swapFrameLoader exception (see bug 1075490),
+ // destroy the browser and recreate it after the toolbox host has been
+ // switched is a reasonable workaround to fix the issue on release and beta
+ // Firefox versions (at least until the underlying bug can be fixed).
+ if (this.browser) {
+ // Fires a panel.onHidden event before destroying the browser element because
+ // the toolbox hosts is changing.
+ if (this.visible) {
+ this.conduit.sendPanelHidden(this.id);
+ }
+
+ this.destroyBrowserElement();
+ }
+ }
+
+ async onToolboxHostChanged() {
+ if (this.browserContainerWindow) {
+ this.createBrowserElement(this.browserContainerWindow);
+
+ // Fires a panel.onShown event once the browser element has been recreated
+ // after the toolbox hosts has been changed (needed to provide the new window
+ // object to the extension page that has created the devtools panel).
+ if (this.visible) {
+ await this.waitTopLevelContext;
+ this.conduit.sendPanelShown(this.id);
+ }
+ }
+ }
+
+ async onToolboxPanelSelect(id) {
+ if (!this.waitTopLevelContext || !this.panelAdded) {
+ return;
+ }
+
+ // Wait that the panel is fully loaded and emit show.
+ await this.waitTopLevelContext;
+
+ if (!this.visible && id === this.id) {
+ this.visible = true;
+ this.conduit.sendPanelShown(this.id);
+ } else if (this.visible && id !== this.id) {
+ this.visible = false;
+ this.conduit.sendPanelHidden(this.id);
+ }
+ }
+
+ close() {
+ const { toolbox } = this;
+
+ if (!toolbox) {
+ throw new Error("Unable to destroy a closed devtools panel");
+ }
+
+ this.conduit.close();
+
+ // Explicitly remove the panel if it is registered and the toolbox is not
+ // closing itself.
+ if (this.panelAdded && toolbox.isToolRegistered(this.id)) {
+ this.destroyBrowserElement();
+ toolbox.removeAdditionalTool(this.id);
+ }
+
+ this.waitTopLevelContext = null;
+ this._resolveTopLevelContext = null;
+ this.context = null;
+ this.toolbox = null;
+ this.browser = null;
+ this.browserContainerWindow = null;
+ }
+
+ destroyBrowserElement() {
+ super.destroyBrowserElement();
+
+ // If the panel has been removed or disabled (e.g. from the toolbox preferences
+ // or during the toolbox switching between docked and undocked),
+ // we need to re-initialize the waitTopLevelContext Promise.
+ this.waitTopLevelContext = new Promise(resolve => {
+ this._resolveTopLevelContext = resolve;
+ });
+ }
+}
+
+class DevToolsSelectionObserver extends EventEmitter {
+ constructor(context) {
+ if (!context.devToolsToolbox) {
+ // This should never happen when this constructor is called with a valid
+ // devtools extension context.
+ throw Error("Missing mandatory toolbox");
+ }
+
+ super();
+ context.callOnClose(this);
+
+ this.toolbox = context.devToolsToolbox;
+ this.onSelected = this.onSelected.bind(this);
+ this.initialized = false;
+ }
+
+ on(...args) {
+ this.lazyInit();
+ super.on.apply(this, args);
+ }
+
+ once(...args) {
+ this.lazyInit();
+ super.once.apply(this, args);
+ }
+
+ async lazyInit() {
+ if (!this.initialized) {
+ this.initialized = true;
+ this.toolbox.on("selection-changed", this.onSelected);
+ }
+ }
+
+ close() {
+ if (this.destroyed) {
+ throw new Error("Unable to close a destroyed DevToolsSelectionObserver");
+ }
+
+ if (this.initialized) {
+ this.toolbox.off("selection-changed", this.onSelected);
+ }
+
+ this.toolbox = null;
+ this.destroyed = true;
+ }
+
+ onSelected() {
+ this.emit("selectionChanged");
+ }
+}
+
+/**
+ * Represents an addon devtools inspector sidebar in the main process.
+ *
+ * @param {ExtensionChildProxyContext} context
+ * A devtools extension proxy context running in a main process.
+ * @param {object} options
+ * @param {string} options.id
+ * The id of the addon devtools sidebar.
+ * @param {string} options.title
+ * The title of the addon devtools sidebar.
+ */
+class ParentDevToolsInspectorSidebar extends BaseDevToolsPanel {
+ constructor(context, panelOptions) {
+ super(context, panelOptions);
+
+ this.visible = false;
+ this.destroyed = false;
+
+ this.context.callOnClose(this);
+
+ this.conduit = new BroadcastConduit(this, {
+ id: `${this.id}-parent`,
+ send: ["InspectorSidebarHidden", "InspectorSidebarShown"],
+ });
+
+ this.onSidebarSelect = this.onSidebarSelect.bind(this);
+ this.onSidebarCreated = this.onSidebarCreated.bind(this);
+ this.onExtensionPageMount = this.onExtensionPageMount.bind(this);
+ this.onExtensionPageUnmount = this.onExtensionPageUnmount.bind(this);
+ this.onToolboxHostWillChange = this.onToolboxHostWillChange.bind(this);
+ this.onToolboxHostChanged = this.onToolboxHostChanged.bind(this);
+
+ this.toolbox.once(
+ `extension-sidebar-created-${this.id}`,
+ this.onSidebarCreated
+ );
+ this.toolbox.on("inspector-sidebar-select", this.onSidebarSelect);
+ this.toolbox.on("host-will-change", this.onToolboxHostWillChange);
+ this.toolbox.on("host-changed", this.onToolboxHostChanged);
+
+ // Set by setObject if the sidebar has not been created yet.
+ this._initializeSidebar = null;
+
+ // Set by _updateLastExpressionResult to keep track of the last
+ // object value grip (to release the previous selected actor
+ // on the remote debugging server when the actor changes).
+ this._lastExpressionResult = null;
+
+ this.toolbox.registerInspectorExtensionSidebar(this.id, {
+ title: panelOptions.title,
+ });
+ }
+
+ close() {
+ if (this.destroyed) {
+ throw new Error("Unable to close a destroyed DevToolsSelectionObserver");
+ }
+
+ this.conduit.close();
+
+ if (this.extensionSidebar) {
+ this.extensionSidebar.off(
+ "extension-page-mount",
+ this.onExtensionPageMount
+ );
+ this.extensionSidebar.off(
+ "extension-page-unmount",
+ this.onExtensionPageUnmount
+ );
+ }
+
+ if (this.browser) {
+ this.destroyBrowserElement();
+ this.browser = null;
+ this.containerEl = null;
+ }
+
+ // Release the last selected actor on the remote debugging server.
+ this._updateLastExpressionResult(null);
+
+ this.toolbox.off(
+ `extension-sidebar-created-${this.id}`,
+ this.onSidebarCreated
+ );
+ this.toolbox.off("inspector-sidebar-select", this.onSidebarSelect);
+ this.toolbox.off("host-changed", this.onToolboxHostChanged);
+ this.toolbox.off("host-will-change", this.onToolboxHostWillChange);
+
+ this.toolbox.unregisterInspectorExtensionSidebar(this.id);
+ this.extensionSidebar = null;
+ this._lazySidebarInit = null;
+
+ this.destroyed = true;
+ }
+
+ onToolboxHostWillChange() {
+ if (this.browser) {
+ this.destroyBrowserElement();
+ }
+ }
+
+ onToolboxHostChanged() {
+ if (this.containerEl && this.panelOptions.url) {
+ this.createBrowserElement(this.containerEl.contentWindow);
+ }
+ }
+
+ onExtensionPageMount(containerEl) {
+ this.containerEl = containerEl;
+
+ // Wait the webext-panel.xhtml page to have been loaded in the
+ // inspector sidebar panel.
+ promiseDocumentLoaded(containerEl.contentDocument).then(() => {
+ this.createBrowserElement(containerEl.contentWindow);
+ });
+ }
+
+ onExtensionPageUnmount() {
+ this.containerEl = null;
+ this.destroyBrowserElement();
+ }
+
+ onSidebarCreated(sidebar) {
+ this.extensionSidebar = sidebar;
+
+ sidebar.on("extension-page-mount", this.onExtensionPageMount);
+ sidebar.on("extension-page-unmount", this.onExtensionPageUnmount);
+
+ const { _lazySidebarInit } = this;
+ this._lazySidebarInit = null;
+
+ if (typeof _lazySidebarInit === "function") {
+ _lazySidebarInit();
+ }
+ }
+
+ onSidebarSelect(id) {
+ if (!this.extensionSidebar) {
+ return;
+ }
+
+ if (!this.visible && id === this.id) {
+ this.visible = true;
+ this.conduit.sendInspectorSidebarShown(this.id);
+ } else if (this.visible && id !== this.id) {
+ this.visible = false;
+ this.conduit.sendInspectorSidebarHidden(this.id);
+ }
+ }
+
+ setPage(extensionPageURL) {
+ this.panelOptions.url = extensionPageURL;
+
+ if (this.extensionSidebar) {
+ if (this.browser) {
+ // Just load the new extension page url in the existing browser, if
+ // it already exists.
+ this.browser.loadURI(this.panelOptions.url, {
+ triggeringPrincipal: this.context.extension.principal,
+ });
+ } else {
+ // The browser element doesn't exist yet, but the sidebar has been
+ // already created (e.g. because the inspector was already selected
+ // in a open toolbox and the extension has been installed/reloaded/updated).
+ this.extensionSidebar.setExtensionPage(WEBEXT_PANELS_URL);
+ }
+ } else {
+ // Defer the sidebar.setExtensionPage call.
+ this._setLazySidebarInit(() =>
+ this.extensionSidebar.setExtensionPage(WEBEXT_PANELS_URL)
+ );
+ }
+ }
+
+ setObject(object, rootTitle) {
+ delete this.panelOptions.url;
+
+ this._updateLastExpressionResult(null);
+
+ // Nest the object inside an object, as the value of the `rootTitle` property.
+ if (rootTitle) {
+ object = { [rootTitle]: object };
+ }
+
+ if (this.extensionSidebar) {
+ this.extensionSidebar.setObject(object);
+ } else {
+ // Defer the sidebar.setObject call.
+ this._setLazySidebarInit(() => this.extensionSidebar.setObject(object));
+ }
+ }
+
+ _setLazySidebarInit(cb) {
+ this._lazySidebarInit = cb;
+ }
+
+ setExpressionResult(expressionResult, rootTitle) {
+ delete this.panelOptions.url;
+
+ this._updateLastExpressionResult(expressionResult);
+
+ if (this.extensionSidebar) {
+ this.extensionSidebar.setExpressionResult(expressionResult, rootTitle);
+ } else {
+ // Defer the sidebar.setExpressionResult call.
+ this._setLazySidebarInit(() => {
+ this.extensionSidebar.setExpressionResult(expressionResult, rootTitle);
+ });
+ }
+ }
+
+ _updateLastExpressionResult(newExpressionResult = null) {
+ const { _lastExpressionResult } = this;
+
+ this._lastExpressionResult = newExpressionResult;
+
+ const oldActor = _lastExpressionResult && _lastExpressionResult.actorID;
+ const newActor = newExpressionResult && newExpressionResult.actorID;
+
+ // Release the previously active actor on the remote debugging server.
+ if (
+ oldActor &&
+ oldActor !== newActor &&
+ typeof _lastExpressionResult.release === "function"
+ ) {
+ _lastExpressionResult.release();
+ }
+ }
+}
+
+const sidebarsById = new Map();
+
+this.devtools_panels = class extends ExtensionAPI {
+ getAPI(context) {
+ // Lazily retrieved inspectedWindow actor front per child context
+ // (used by Sidebar.setExpression).
+ let waitForInspectedWindowFront;
+
+ // TODO - Bug 1448878: retrieve a more detailed callerInfo object,
+ // like the filename and lineNumber of the actual extension called
+ // in the child process.
+ const callerInfo = {
+ addonId: context.extension.id,
+ url: context.extension.baseURI.spec,
+ };
+
+ // An incremental "per context" id used in the generated devtools panel id.
+ let nextPanelId = 0;
+
+ const toolboxSelectionObserver = new DevToolsSelectionObserver(context);
+
+ function newBasePanelId() {
+ return `${context.extension.id}-${context.contextId}-${nextPanelId++}`;
+ }
+
+ return {
+ devtools: {
+ panels: {
+ elements: {
+ onSelectionChanged: new EventManager({
+ context,
+ name: "devtools.panels.elements.onSelectionChanged",
+ register: fire => {
+ const listener = eventName => {
+ fire.async();
+ };
+ toolboxSelectionObserver.on("selectionChanged", listener);
+ return () => {
+ toolboxSelectionObserver.off("selectionChanged", listener);
+ };
+ },
+ }).api(),
+ createSidebarPane(title) {
+ const id = `devtools-inspector-sidebar-${makeWidgetId(
+ newBasePanelId()
+ )}`;
+
+ const parentSidebar = new ParentDevToolsInspectorSidebar(
+ context,
+ { title, id }
+ );
+ sidebarsById.set(id, parentSidebar);
+
+ context.callOnClose({
+ close() {
+ sidebarsById.delete(id);
+ },
+ });
+
+ // Resolved to the devtools sidebar id into the child addon process,
+ // where it will be used to identify the messages related
+ // to the panel API onShown/onHidden events.
+ return Promise.resolve(id);
+ },
+ // The following methods are used internally to allow the sidebar API
+ // piece that is running in the child process to asks the parent process
+ // to execute the sidebar methods.
+ Sidebar: {
+ setPage(sidebarId, extensionPageURL) {
+ const sidebar = sidebarsById.get(sidebarId);
+ return sidebar.setPage(extensionPageURL);
+ },
+ setObject(sidebarId, jsonObject, rootTitle) {
+ const sidebar = sidebarsById.get(sidebarId);
+ return sidebar.setObject(jsonObject, rootTitle);
+ },
+ async setExpression(sidebarId, evalExpression, rootTitle) {
+ const sidebar = sidebarsById.get(sidebarId);
+
+ if (!waitForInspectedWindowFront) {
+ waitForInspectedWindowFront = getInspectedWindowFront(
+ context
+ );
+ }
+
+ const front = await waitForInspectedWindowFront;
+ const toolboxEvalOptions = await getToolboxEvalOptions(context);
+
+ const consoleFront = await context.devToolsToolbox.target.getFront(
+ "console"
+ );
+ toolboxEvalOptions.consoleFront = consoleFront;
+
+ const evalResult = await front.eval(
+ callerInfo,
+ evalExpression,
+ toolboxEvalOptions
+ );
+
+ let jsonObject;
+
+ if (evalResult.exceptionInfo) {
+ jsonObject = evalResult.exceptionInfo;
+
+ return sidebar.setObject(jsonObject, rootTitle);
+ }
+
+ return sidebar.setExpressionResult(evalResult, rootTitle);
+ },
+ },
+ },
+ create(title, icon, url) {
+ // Get a fallback icon from the manifest data.
+ if (icon === "" && context.extension.manifest.icons) {
+ const iconInfo = IconDetails.getPreferredIcon(
+ context.extension.manifest.icons,
+ context.extension,
+ 128
+ );
+ icon = iconInfo ? iconInfo.icon : "";
+ }
+
+ icon = context.extension.baseURI.resolve(icon);
+ url = context.extension.baseURI.resolve(url);
+
+ const id = `webext-devtools-panel-${makeWidgetId(
+ newBasePanelId()
+ )}`;
+
+ new ParentDevToolsPanel(context, { title, icon, url, id });
+
+ // Resolved to the devtools panel id into the child addon process,
+ // where it will be used to identify the messages related
+ // to the panel API onShown/onHidden events.
+ return Promise.resolve(id);
+ },
+ },
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-devtools.js b/browser/components/extensions/parent/ext-devtools.js
new file mode 100644
index 0000000000..1b9db9d7dd
--- /dev/null
+++ b/browser/components/extensions/parent/ext-devtools.js
@@ -0,0 +1,509 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * This module provides helpers used by the other specialized `ext-devtools-*.js` modules
+ * and the implementation of the `devtools_page`.
+ */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "DevToolsShim",
+ "chrome://devtools-startup/content/DevToolsShim.jsm"
+);
+
+var { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+var { HiddenExtensionPage, watchExtensionProxyContextLoad } = ExtensionParent;
+
+// Get the devtools preference given the extension id.
+function getDevToolsPrefBranchName(extensionId) {
+ return `devtools.webextensions.${extensionId}`;
+}
+
+/**
+ * Retrieve the tabId for the given devtools toolbox.
+ *
+ * @param {Toolbox} toolbox
+ * A devtools toolbox instance.
+ *
+ * @returns {number}
+ * The corresponding WebExtensions tabId.
+ */
+global.getTargetTabIdForToolbox = toolbox => {
+ let { target } = toolbox;
+
+ if (!target.isLocalTab) {
+ throw new Error(
+ "Unexpected target type: only local tabs are currently supported."
+ );
+ }
+
+ let parentWindow = target.localTab.linkedBrowser.ownerGlobal;
+ let tab = parentWindow.gBrowser.getTabForBrowser(
+ target.localTab.linkedBrowser
+ );
+
+ return tabTracker.getId(tab);
+};
+
+// Create an InspectedWindowFront instance for a given context (used in devtoools.inspectedWindow.eval
+// and in sidebar.setExpression API methods).
+global.getInspectedWindowFront = async function(context) {
+ const target = await context.getCurrentDevToolsTarget();
+ return DevToolsShim.createWebExtensionInspectedWindowFront(target);
+};
+
+// Get the WebExtensionInspectedWindowActor eval options (needed to provide the $0 and inspect
+// binding provided to the evaluated js code).
+global.getToolboxEvalOptions = async function(context) {
+ const options = {};
+ const toolbox = context.devToolsToolbox;
+ const selectedNode = toolbox.selection;
+
+ if (selectedNode && selectedNode.nodeFront) {
+ // If there is a selected node in the inspector, we hand over
+ // its actor id to the eval request in order to provide the "$0" binding.
+ options.toolboxSelectedNodeActorID = selectedNode.nodeFront.actorID;
+ }
+
+ // Provide the console actor ID to implement the "inspect" binding.
+ const consoleFront = await toolbox.target.getFront("console");
+ options.toolboxConsoleActorID = consoleFront.actor;
+
+ return options;
+};
+
+/**
+ * The DevToolsPage represents the "devtools_page" related to a particular
+ * Toolbox and WebExtension.
+ *
+ * The devtools_page contexts are invisible WebExtensions contexts, similar to the
+ * background page, associated to a single developer toolbox (e.g. If an add-on
+ * registers a devtools_page and the user opens 3 developer toolbox in 3 webpages,
+ * 3 devtools_page contexts will be created for that add-on).
+ *
+ * @param {Extension} extension
+ * The extension that owns the devtools_page.
+ * @param {Object} options
+ * @param {Toolbox} options.toolbox
+ * The developer toolbox instance related to this devtools_page.
+ * @param {string} options.url
+ * The path to the devtools page html page relative to the extension base URL.
+ * @param {DevToolsPageDefinition} options.devToolsPageDefinition
+ * The instance of the devToolsPageDefinition class related to this DevToolsPage.
+ */
+class DevToolsPage extends HiddenExtensionPage {
+ constructor(extension, options) {
+ super(extension, "devtools_page");
+
+ this.url = extension.baseURI.resolve(options.url);
+ this.toolbox = options.toolbox;
+ this.devToolsPageDefinition = options.devToolsPageDefinition;
+
+ this.unwatchExtensionProxyContextLoad = null;
+
+ this.waitForTopLevelContext = new Promise(resolve => {
+ this.resolveTopLevelContext = resolve;
+ });
+ }
+
+ async build() {
+ await this.createBrowserElement();
+
+ // Listening to new proxy contexts.
+ this.unwatchExtensionProxyContextLoad = watchExtensionProxyContextLoad(
+ this,
+ context => {
+ // Keep track of the toolbox and target associated to the context, which is
+ // needed by the API methods implementation.
+ context.devToolsToolbox = this.toolbox;
+
+ if (!this.topLevelContext) {
+ this.topLevelContext = context;
+
+ // Ensure this devtools page is destroyed, when the top level context proxy is
+ // closed.
+ this.topLevelContext.callOnClose(this);
+
+ this.resolveTopLevelContext(context);
+ }
+ }
+ );
+
+ extensions.emit("extension-browser-inserted", this.browser, {
+ devtoolsToolboxInfo: {
+ inspectedWindowTabId: getTargetTabIdForToolbox(this.toolbox),
+ themeName: DevToolsShim.getTheme(),
+ },
+ });
+
+ this.browser.loadURI(this.url, {
+ triggeringPrincipal: this.extension.principal,
+ });
+
+ await this.waitForTopLevelContext;
+ }
+
+ close() {
+ if (this.closed) {
+ throw new Error("Unable to shutdown a closed DevToolsPage instance");
+ }
+
+ this.closed = true;
+
+ // Unregister the devtools page instance from the devtools page definition.
+ this.devToolsPageDefinition.forgetForToolbox(this.toolbox);
+
+ // Unregister it from the resources to cleanup when the context has been closed.
+ if (this.topLevelContext) {
+ this.topLevelContext.forgetOnClose(this);
+ }
+
+ // Stop watching for any new proxy contexts from the devtools page.
+ if (this.unwatchExtensionProxyContextLoad) {
+ this.unwatchExtensionProxyContextLoad();
+ this.unwatchExtensionProxyContextLoad = null;
+ }
+
+ super.shutdown();
+ }
+}
+
+/**
+ * The DevToolsPageDefinitions class represents the "devtools_page" manifest property
+ * of a WebExtension.
+ *
+ * A DevToolsPageDefinition instance is created automatically when a WebExtension
+ * which contains the "devtools_page" manifest property has been loaded, and it is
+ * automatically destroyed when the related WebExtension has been unloaded,
+ * and so there will be at most one DevtoolsPageDefinition per add-on.
+ *
+ * Every time a developer tools toolbox is opened, the DevToolsPageDefinition creates
+ * and keep track of a DevToolsPage instance (which represents the actual devtools_page
+ * instance related to that particular toolbox).
+ *
+ * @param {Extension} extension
+ * The extension that owns the devtools_page.
+ * @param {string} url
+ * The path to the devtools page html page relative to the extension base URL.
+ */
+class DevToolsPageDefinition {
+ constructor(extension, url) {
+ this.url = url;
+ this.extension = extension;
+
+ // Map[Toolbox -> DevToolsPage]
+ this.devtoolsPageForToolbox = new Map();
+ }
+
+ onThemeChanged(themeName) {
+ Services.ppmm.broadcastAsyncMessage("Extension:DevToolsThemeChanged", {
+ themeName,
+ });
+ }
+
+ buildForToolbox(toolbox) {
+ if (!this.extension.canAccessWindow(toolbox.target.localTab.ownerGlobal)) {
+ // We should never create a devtools page for a toolbox related to a private browsing window
+ // if the extension is not allowed to access it.
+ return;
+ }
+
+ if (this.devtoolsPageForToolbox.has(toolbox)) {
+ return Promise.reject(
+ new Error("DevtoolsPage has been already created for this toolbox")
+ );
+ }
+
+ const devtoolsPage = new DevToolsPage(this.extension, {
+ toolbox,
+ url: this.url,
+ devToolsPageDefinition: this,
+ });
+
+ // If this is the first DevToolsPage, subscribe to the theme-changed event
+ if (this.devtoolsPageForToolbox.size === 0) {
+ DevToolsShim.on("theme-changed", this.onThemeChanged);
+ }
+ this.devtoolsPageForToolbox.set(toolbox, devtoolsPage);
+
+ return devtoolsPage.build();
+ }
+
+ shutdownForToolbox(toolbox) {
+ if (this.devtoolsPageForToolbox.has(toolbox)) {
+ const devtoolsPage = this.devtoolsPageForToolbox.get(toolbox);
+ devtoolsPage.close();
+
+ // `devtoolsPage.close()` should remove the instance from the map,
+ // raise an exception if it is still there.
+ if (this.devtoolsPageForToolbox.has(toolbox)) {
+ throw new Error(
+ `Leaked DevToolsPage instance for target "${toolbox.target.descriptorFront.url}", extension "${this.extension.policy.debugName}"`
+ );
+ }
+
+ // If this was the last DevToolsPage, unsubscribe from the theme-changed event
+ if (this.devtoolsPageForToolbox.size === 0) {
+ DevToolsShim.off("theme-changed", this.onThemeChanged);
+ }
+ this.extension.emit("devtools-page-shutdown", toolbox);
+ }
+ }
+
+ forgetForToolbox(toolbox) {
+ this.devtoolsPageForToolbox.delete(toolbox);
+ }
+
+ /**
+ * Build the devtools_page instances for all the existing toolboxes
+ * (if the toolbox target is supported).
+ */
+ build() {
+ // Iterate over the existing toolboxes and create the devtools page for them
+ // (if the toolbox target is supported).
+ for (let toolbox of DevToolsShim.getToolboxes()) {
+ if (
+ !toolbox.target.isLocalTab ||
+ !this.extension.canAccessWindow(toolbox.target.localTab.ownerGlobal)
+ ) {
+ // Skip any non-local tab and private browsing windows if the extension
+ // is not allowed to access them.
+ continue;
+ }
+
+ // Ensure that the WebExtension is listed in the toolbox options.
+ toolbox.registerWebExtension(this.extension.uuid, {
+ name: this.extension.name,
+ pref: `${getDevToolsPrefBranchName(this.extension.id)}.enabled`,
+ });
+
+ this.buildForToolbox(toolbox);
+ }
+ }
+
+ /**
+ * Shutdown all the devtools_page instances.
+ */
+ shutdown() {
+ for (let toolbox of this.devtoolsPageForToolbox.keys()) {
+ this.shutdownForToolbox(toolbox);
+ }
+
+ if (this.devtoolsPageForToolbox.size > 0) {
+ throw new Error(
+ `Leaked ${this.devtoolsPageForToolbox.size} DevToolsPage instances in devtoolsPageForToolbox Map`
+ );
+ }
+ }
+}
+
+this.devtools = class extends ExtensionAPI {
+ constructor(extension) {
+ super(extension);
+
+ this._initialized = false;
+
+ // DevToolsPageDefinition instance (created in onManifestEntry).
+ this.pageDefinition = null;
+
+ this.onToolboxCreated = this.onToolboxCreated.bind(this);
+ this.onToolboxDestroy = this.onToolboxDestroy.bind(this);
+
+ /* eslint-disable mozilla/balanced-listeners */
+ extension.on("add-permissions", (ignoreEvent, permissions) => {
+ Services.prefs.setBoolPref(
+ `${getDevToolsPrefBranchName(extension.id)}.enabled`,
+ true
+ );
+ if (permissions.permissions.includes("devtools")) {
+ this._initialize();
+ }
+ });
+
+ extension.on("remove-permissions", (ignoreEvent, permissions) => {
+ Services.prefs.setBoolPref(
+ `${getDevToolsPrefBranchName(extension.id)}.enabled`,
+ false
+ );
+ if (permissions.permissions.includes("devtools")) {
+ this._uninitialize();
+ }
+ });
+ }
+
+ onManifestEntry() {
+ this._initialize();
+ }
+
+ static onUninstall(extensionId) {
+ // Remove the preference branch on uninstall.
+ const prefBranch = Services.prefs.getBranch(
+ `${getDevToolsPrefBranchName(extensionId)}.`
+ );
+
+ prefBranch.deleteBranch("");
+ }
+
+ _initialize() {
+ const { extension } = this;
+
+ if (!extension.hasPermission("devtools") || this._initialized) {
+ return;
+ }
+
+ this.initDevToolsPref();
+
+ // Create the devtools_page definition.
+ this.pageDefinition = new DevToolsPageDefinition(
+ extension,
+ extension.manifest.devtools_page
+ );
+
+ // Build the extension devtools_page on all existing toolboxes (if the extension
+ // devtools_page is not disabled by the related preference).
+ if (!this.isDevToolsPageDisabled()) {
+ this.pageDefinition.build();
+ }
+
+ DevToolsShim.on("toolbox-created", this.onToolboxCreated);
+ DevToolsShim.on("toolbox-destroy", this.onToolboxDestroy);
+ this._initialized = true;
+ }
+
+ _uninitialize() {
+ // devtoolsPrefBranch is set in onManifestEntry, and nullified
+ // later in onShutdown. If it isn't set, then onManifestEntry
+ // did not initialize devtools for the extension.
+ if (!this._initialized) {
+ return;
+ }
+
+ DevToolsShim.off("toolbox-created", this.onToolboxCreated);
+ DevToolsShim.off("toolbox-destroy", this.onToolboxDestroy);
+
+ // Shutdown the extension devtools_page from all existing toolboxes.
+ this.pageDefinition.shutdown();
+ this.pageDefinition = null;
+
+ // Iterate over the existing toolboxes and unlist the devtools webextension from them.
+ for (let toolbox of DevToolsShim.getToolboxes()) {
+ toolbox.unregisterWebExtension(this.extension.uuid);
+ }
+
+ this.uninitDevToolsPref();
+ this._initialized = false;
+ }
+
+ onShutdown() {
+ this._uninitialize();
+ }
+
+ getAPI(context) {
+ return {
+ devtools: {},
+ };
+ }
+
+ onToolboxCreated(toolbox) {
+ if (
+ !toolbox.target.isLocalTab ||
+ !this.extension.canAccessWindow(toolbox.target.localTab.ownerGlobal)
+ ) {
+ // Skip any non-local (as remote tabs are not yet supported, see Bug 1304378 for additional details
+ // related to remote targets support), and private browsing windows if the extension
+ // is not allowed to access them.
+ return;
+ }
+
+ // Ensure that the WebExtension is listed in the toolbox options.
+ toolbox.registerWebExtension(this.extension.uuid, {
+ name: this.extension.name,
+ pref: `${getDevToolsPrefBranchName(this.extension.id)}.enabled`,
+ });
+
+ // Do not build the devtools page if the extension has been disabled
+ // (e.g. based on the devtools preference).
+ if (toolbox.isWebExtensionEnabled(this.extension.uuid)) {
+ this.pageDefinition.buildForToolbox(toolbox);
+ }
+ }
+
+ onToolboxDestroy(toolbox) {
+ if (!toolbox.target.isLocalTab) {
+ // Only local tabs are currently supported (See Bug 1304378 for additional details
+ // related to remote targets support).
+ return;
+ }
+
+ this.pageDefinition.shutdownForToolbox(toolbox);
+ }
+
+ /**
+ * Initialize the DevTools preferences branch for the extension and
+ * start to observe it for changes on the "enabled" preference.
+ */
+ initDevToolsPref() {
+ const prefBranch = Services.prefs.getBranch(
+ `${getDevToolsPrefBranchName(this.extension.id)}.`
+ );
+
+ // Initialize the devtools extension preference if it doesn't exist yet.
+ if (prefBranch.getPrefType("enabled") === prefBranch.PREF_INVALID) {
+ prefBranch.setBoolPref("enabled", true);
+ }
+
+ this.devtoolsPrefBranch = prefBranch;
+ this.devtoolsPrefBranch.addObserver("enabled", this);
+ }
+
+ /**
+ * Stop from observing the DevTools preferences branch for the extension.
+ */
+ uninitDevToolsPref() {
+ this.devtoolsPrefBranch.removeObserver("enabled", this);
+ this.devtoolsPrefBranch = null;
+ }
+
+ /**
+ * Test if the extension's devtools_page has been disabled using the
+ * DevTools preference.
+ *
+ * @returns {boolean}
+ * true if the devtools_page for this extension is disabled.
+ */
+ isDevToolsPageDisabled() {
+ return !this.devtoolsPrefBranch.getBoolPref("enabled", false);
+ }
+
+ /**
+ * Observes the changed preferences on the DevTools preferences branch
+ * related to the extension.
+ *
+ * @param {nsIPrefBranch} subject The observed preferences branch.
+ * @param {string} topic The notified topic.
+ * @param {string} prefName The changed preference name.
+ */
+ observe(subject, topic, prefName) {
+ // We are currently interested only in the "enabled" preference from the
+ // WebExtension devtools preferences branch.
+ if (subject !== this.devtoolsPrefBranch || prefName !== "enabled") {
+ return;
+ }
+
+ // Shutdown or build the devtools_page on any existing toolbox.
+ if (this.isDevToolsPageDisabled()) {
+ this.pageDefinition.shutdown();
+ } else {
+ this.pageDefinition.build();
+ }
+ }
+};
diff --git a/browser/components/extensions/parent/ext-find.js b/browser/components/extensions/parent/ext-find.js
new file mode 100644
index 0000000000..33d5667a7e
--- /dev/null
+++ b/browser/components/extensions/parent/ext-find.js
@@ -0,0 +1,274 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global tabTracker */
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+// A mapping of top-level ExtFind actors to arrays of results in each subframe.
+let findResults = new WeakMap();
+
+function getActorForBrowsingContext(browsingContext) {
+ let windowGlobal = browsingContext.currentWindowGlobal;
+ return windowGlobal ? windowGlobal.getActor("ExtFind") : null;
+}
+
+function getTopLevelActor(browser) {
+ return getActorForBrowsingContext(browser.browsingContext);
+}
+
+function gatherActors(browsingContext) {
+ let list = [];
+
+ let actor = getActorForBrowsingContext(browsingContext);
+ if (actor) {
+ list.push({ actor, result: null });
+ }
+
+ let children = browsingContext.children;
+ for (let child of children) {
+ list.push(...gatherActors(child));
+ }
+
+ return list;
+}
+
+function mergeFindResults(params, list) {
+ let finalResult = {
+ count: 0,
+ };
+
+ if (params.includeRangeData) {
+ finalResult.rangeData = [];
+ }
+ if (params.includeRectData) {
+ finalResult.rectData = [];
+ }
+
+ let currentFramePos = -1;
+ for (let item of list) {
+ if (item.result.count == 0) {
+ continue;
+ }
+
+ // The framePos is incremented for each different document that has matches.
+ currentFramePos++;
+
+ finalResult.count += item.result.count;
+ if (params.includeRangeData && item.result.rangeData) {
+ for (let range of item.result.rangeData) {
+ range.framePos = currentFramePos;
+ }
+
+ finalResult.rangeData.push(...item.result.rangeData);
+ }
+
+ if (params.includeRectData && item.result.rectData) {
+ finalResult.rectData.push(...item.result.rectData);
+ }
+ }
+
+ return finalResult;
+}
+
+function sendMessageToAllActors(browser, message, params) {
+ for (let { actor } of gatherActors(browser.browsingContext)) {
+ actor.sendAsyncMessage("ext-Finder:" + message, params);
+ }
+}
+
+async function getFindResultsForActor(findContext, message, params) {
+ findContext.result = await findContext.actor.sendQuery(
+ "ext-Finder:" + message,
+ params
+ );
+ return findContext;
+}
+
+function queryAllActors(browser, message, params) {
+ let promises = [];
+ for (let findContext of gatherActors(browser.browsingContext)) {
+ promises.push(getFindResultsForActor(findContext, message, params));
+ }
+ return Promise.all(promises);
+}
+
+async function collectFindResults(browser, findResults, params) {
+ let results = await queryAllActors(browser, "CollectResults", params);
+ findResults.set(getTopLevelActor(browser), results);
+ return mergeFindResults(params, results);
+}
+
+async function runHighlight(browser, params) {
+ let hasResults = false;
+ let foundResults = false;
+ let list = findResults.get(getTopLevelActor(browser));
+ if (!list) {
+ return Promise.reject({ message: "no search results to highlight" });
+ }
+
+ let highlightPromises = [];
+
+ let index = params.rangeIndex;
+ const highlightAll = typeof index != "number";
+
+ for (let c = 0; c < list.length; c++) {
+ if (list[c].result.count) {
+ hasResults = true;
+ }
+
+ let actor = list[c].actor;
+ if (highlightAll) {
+ // Highlight all ranges.
+ highlightPromises.push(
+ actor.sendQuery("ext-Finder:HighlightResults", params)
+ );
+ } else if (!foundResults && index < list[c].result.count) {
+ foundResults = true;
+ params.rangeIndex = index;
+ highlightPromises.push(
+ actor.sendQuery("ext-Finder:HighlightResults", params)
+ );
+ } else {
+ highlightPromises.push(
+ actor.sendQuery("ext-Finder:ClearHighlighting", params)
+ );
+ }
+
+ index -= list[c].result.count;
+ }
+
+ let responses = await Promise.all(highlightPromises);
+ if (hasResults) {
+ if (responses.includes("OutOfRange") || index >= 0) {
+ return Promise.reject({ message: "index supplied was out of range" });
+ } else if (responses.includes("Success")) {
+ return;
+ }
+ }
+
+ return Promise.reject({ message: "no search results to highlight" });
+}
+
+/**
+ * runFindOperation
+ * Utility for `find` and `highlightResults`.
+ *
+ * @param {BaseContext} context - context the find operation runs in.
+ * @param {object} params - params to pass to message sender.
+ * @param {string} message - identifying component of message name.
+ *
+ * @returns {Promise} a promise that will be resolved or rejected based on the
+ * data received by the message listener.
+ */
+function runFindOperation(context, params, message) {
+ let { tabId } = params;
+ let tab = tabId ? tabTracker.getTab(tabId) : tabTracker.activeTab;
+ let browser = tab.linkedBrowser;
+ tabId = tabId || tabTracker.getId(tab);
+ if (
+ !context.privateBrowsingAllowed &&
+ PrivateBrowsingUtils.isBrowserPrivate(browser)
+ ) {
+ return Promise.reject({ message: `Unable to search: ${tabId}` });
+ }
+ // We disallow find in about: urls.
+ if (
+ tab.linkedBrowser.contentPrincipal.isSystemPrincipal ||
+ (["about", "chrome", "resource"].includes(
+ tab.linkedBrowser.currentURI.scheme
+ ) &&
+ tab.linkedBrowser.currentURI.spec != "about:blank")
+ ) {
+ return Promise.reject({ message: `Unable to search: ${tabId}` });
+ }
+
+ if (message == "HighlightResults") {
+ return runHighlight(browser, params);
+ } else if (message == "CollectResults") {
+ // Remove prior highlights before starting a new find operation.
+ findResults.delete(getTopLevelActor(browser));
+ return collectFindResults(browser, findResults, params);
+ }
+}
+
+this.find = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ find: {
+ /**
+ * browser.find.find
+ * Searches document and its frames for a given queryphrase and stores all found
+ * Range objects in an array accessible by other browser.find methods.
+ *
+ * @param {string} queryphrase - The string to search for.
+ * @param {object} params optional - may contain any of the following properties,
+ * all of which are optional:
+ * {number} tabId - Tab to query. Defaults to the active tab.
+ * {boolean} caseSensitive - Highlight only ranges with case sensitive match.
+ * {boolean} entireWord - Highlight only ranges that match entire word.
+ * {boolean} includeRangeData - Whether to return range data.
+ * {boolean} includeRectData - Whether to return rectangle data.
+ *
+ * @returns {object} data received by the message listener that includes:
+ * {number} count - number of results found.
+ * {array} rangeData (if opted) - serialized representation of ranges found.
+ * {array} rectData (if opted) - rect data of ranges found.
+ */
+ find(queryphrase, params) {
+ params = params || {};
+ params.queryphrase = queryphrase;
+ return runFindOperation(context, params, "CollectResults");
+ },
+
+ /**
+ * browser.find.highlightResults
+ * Highlights range(s) found in previous browser.find.find.
+ *
+ * @param {object} params optional - may contain any of the following properties,
+ * all of which are optional:
+ * {number} rangeIndex - Found range to be highlighted. Default highlights all ranges.
+ * {number} tabId - Tab to highlight. Defaults to the active tab.
+ * {boolean} noScroll - Don't scroll to highlighted item.
+ *
+ * @returns {string} - data received by the message listener that may be:
+ * "Success" - Highlighting succeeded.
+ * "OutOfRange" - The index supplied was out of range.
+ * "NoResults" - There were no search results to highlight.
+ */
+ highlightResults(params) {
+ params = params || {};
+ return runFindOperation(context, params, "HighlightResults");
+ },
+
+ /**
+ * browser.find.removeHighlighting
+ * Removes all highlighting from previous search.
+ *
+ * @param {number} tabId optional
+ * Tab to clear highlighting in. Defaults to the active tab.
+ */
+ removeHighlighting(tabId) {
+ let tab = tabId ? tabTracker.getTab(tabId) : tabTracker.activeTab;
+ if (
+ !context.privateBrowsingAllowed &&
+ PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser)
+ ) {
+ throw new ExtensionError(`Invalid tab ID: ${tabId}`);
+ }
+ sendMessageToAllActors(tab.linkedBrowser, "ClearHighlighting", {});
+ },
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-history.js b/browser/components/extensions/parent/ext-history.js
new file mode 100644
index 0000000000..fe9afdf3c4
--- /dev/null
+++ b/browser/components/extensions/parent/ext-history.js
@@ -0,0 +1,309 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
+ Services: "resource://gre/modules/Services.jsm",
+});
+
+var { normalizeTime } = ExtensionCommon;
+
+let nsINavHistoryService = Ci.nsINavHistoryService;
+const TRANSITION_TO_TRANSITION_TYPES_MAP = new Map([
+ ["link", nsINavHistoryService.TRANSITION_LINK],
+ ["typed", nsINavHistoryService.TRANSITION_TYPED],
+ ["auto_bookmark", nsINavHistoryService.TRANSITION_BOOKMARK],
+ ["auto_subframe", nsINavHistoryService.TRANSITION_EMBED],
+ ["manual_subframe", nsINavHistoryService.TRANSITION_FRAMED_LINK],
+ ["reload", nsINavHistoryService.TRANSITION_RELOAD],
+]);
+
+let TRANSITION_TYPE_TO_TRANSITIONS_MAP = new Map();
+for (let [transition, transitionType] of TRANSITION_TO_TRANSITION_TYPES_MAP) {
+ TRANSITION_TYPE_TO_TRANSITIONS_MAP.set(transitionType, transition);
+}
+
+const getTransitionType = transition => {
+ // cannot set a default value for the transition argument as the framework sets it to null
+ transition = transition || "link";
+ let transitionType = TRANSITION_TO_TRANSITION_TYPES_MAP.get(transition);
+ if (!transitionType) {
+ throw new Error(
+ `|${transition}| is not a supported transition for history`
+ );
+ }
+ return transitionType;
+};
+
+const getTransition = transitionType => {
+ return TRANSITION_TYPE_TO_TRANSITIONS_MAP.get(transitionType) || "link";
+};
+
+/*
+ * Converts a nsINavHistoryResultNode into a HistoryItem
+ *
+ * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryResultNode
+ */
+const convertNodeToHistoryItem = node => {
+ return {
+ id: node.pageGuid,
+ url: node.uri,
+ title: node.title,
+ lastVisitTime: PlacesUtils.toDate(node.time).getTime(),
+ visitCount: node.accessCount,
+ };
+};
+
+/*
+ * Converts a nsINavHistoryResultNode into a VisitItem
+ *
+ * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryResultNode
+ */
+const convertNodeToVisitItem = node => {
+ return {
+ id: node.pageGuid,
+ visitId: String(node.visitId),
+ visitTime: PlacesUtils.toDate(node.time).getTime(),
+ referringVisitId: String(node.fromVisitId),
+ transition: getTransition(node.visitType),
+ };
+};
+
+/*
+ * Converts a nsINavHistoryContainerResultNode into an array of objects
+ *
+ * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryContainerResultNode
+ */
+const convertNavHistoryContainerResultNode = (container, converter) => {
+ let results = [];
+ container.containerOpen = true;
+ for (let i = 0; i < container.childCount; i++) {
+ let node = container.getChild(i);
+ results.push(converter(node));
+ }
+ container.containerOpen = false;
+ return results;
+};
+
+var _observer;
+
+const getHistoryObserver = () => {
+ if (!_observer) {
+ _observer = new (class extends EventEmitter {
+ onDeleteURI(uri, guid, reason) {
+ this.emit("visitRemoved", { allHistory: false, urls: [uri.spec] });
+ }
+ onBeginUpdateBatch() {}
+ onEndUpdateBatch() {}
+ onDeleteVisits(uri, partialRemoval, guid, reason) {
+ if (!partialRemoval) {
+ this.emit("visitRemoved", { allHistory: false, urls: [uri.spec] });
+ }
+ }
+ })();
+ PlacesUtils.history.addObserver(_observer);
+ }
+ return _observer;
+};
+
+this.history = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ history: {
+ addUrl: function(details) {
+ let transition, date;
+ try {
+ transition = getTransitionType(details.transition);
+ } catch (error) {
+ return Promise.reject({ message: error.message });
+ }
+ if (details.visitTime) {
+ date = normalizeTime(details.visitTime);
+ }
+ let pageInfo = {
+ title: details.title,
+ url: details.url,
+ visits: [
+ {
+ transition,
+ date,
+ },
+ ],
+ };
+ try {
+ return PlacesUtils.history.insert(pageInfo).then(() => undefined);
+ } catch (error) {
+ return Promise.reject({ message: error.message });
+ }
+ },
+
+ deleteAll: function() {
+ return PlacesUtils.history.clear();
+ },
+
+ deleteRange: function(filter) {
+ let newFilter = {
+ beginDate: normalizeTime(filter.startTime),
+ endDate: normalizeTime(filter.endTime),
+ };
+ // History.removeVisitsByFilter returns a boolean, but our API should return nothing
+ return PlacesUtils.history
+ .removeVisitsByFilter(newFilter)
+ .then(() => undefined);
+ },
+
+ deleteUrl: function(details) {
+ let url = details.url;
+ // History.remove returns a boolean, but our API should return nothing
+ return PlacesUtils.history.remove(url).then(() => undefined);
+ },
+
+ search: function(query) {
+ let beginTime =
+ query.startTime == null
+ ? PlacesUtils.toPRTime(Date.now() - 24 * 60 * 60 * 1000)
+ : PlacesUtils.toPRTime(normalizeTime(query.startTime));
+ let endTime =
+ query.endTime == null
+ ? Number.MAX_VALUE
+ : PlacesUtils.toPRTime(normalizeTime(query.endTime));
+ if (beginTime > endTime) {
+ return Promise.reject({
+ message: "The startTime cannot be after the endTime",
+ });
+ }
+
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.includeHidden = true;
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+ options.maxResults = query.maxResults || 100;
+
+ let historyQuery = PlacesUtils.history.getNewQuery();
+ historyQuery.searchTerms = query.text;
+ historyQuery.beginTime = beginTime;
+ historyQuery.endTime = endTime;
+ let queryResult = PlacesUtils.history.executeQuery(
+ historyQuery,
+ options
+ ).root;
+ let results = convertNavHistoryContainerResultNode(
+ queryResult,
+ convertNodeToHistoryItem
+ );
+ return Promise.resolve(results);
+ },
+
+ getVisits: function(details) {
+ let url = details.url;
+ if (!url) {
+ return Promise.reject({
+ message: "A URL must be provided for getVisits",
+ });
+ }
+
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.includeHidden = true;
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+ options.resultType = options.RESULTS_AS_VISIT;
+
+ let historyQuery = PlacesUtils.history.getNewQuery();
+ historyQuery.uri = Services.io.newURI(url);
+ let queryResult = PlacesUtils.history.executeQuery(
+ historyQuery,
+ options
+ ).root;
+ let results = convertNavHistoryContainerResultNode(
+ queryResult,
+ convertNodeToVisitItem
+ );
+ return Promise.resolve(results);
+ },
+
+ onVisited: new EventManager({
+ context,
+ name: "history.onVisited",
+ register: fire => {
+ const listener = events => {
+ for (const event of events) {
+ const visit = {
+ id: event.pageGuid,
+ url: event.url,
+ title: event.lastKnownTitle || "",
+ lastVisitTime: event.visitTime,
+ visitCount: event.visitCount,
+ typedCount: event.typedCount,
+ };
+ fire.sync(visit);
+ }
+ };
+
+ PlacesUtils.observers.addListener(["page-visited"], listener);
+ return () => {
+ PlacesUtils.observers.removeListener(["page-visited"], listener);
+ };
+ },
+ }).api(),
+
+ onVisitRemoved: new EventManager({
+ context,
+ name: "history.onVisitRemoved",
+ register: fire => {
+ let listener = (event, data) => {
+ fire.sync(data);
+ };
+ const historyClearedListener = events => {
+ fire.sync({ allHistory: true, urls: [] });
+ };
+
+ getHistoryObserver().on("visitRemoved", listener);
+ PlacesUtils.observers.addListener(
+ ["history-cleared"],
+ historyClearedListener
+ );
+ return () => {
+ getHistoryObserver().off("visitRemoved", listener);
+ PlacesUtils.observers.removeListener(
+ ["history-cleared"],
+ historyClearedListener
+ );
+ };
+ },
+ }).api(),
+
+ onTitleChanged: new EventManager({
+ context,
+ name: "history.onTitleChanged",
+ register: fire => {
+ const listener = events => {
+ for (const event of events) {
+ const titleChanged = {
+ id: event.pageGuid,
+ url: event.url,
+ title: event.title,
+ };
+ fire.sync(titleChanged);
+ }
+ };
+
+ PlacesUtils.observers.addListener(["page-title-changed"], listener);
+ return () => {
+ PlacesUtils.observers.removeListener(
+ ["page-title-changed"],
+ listener
+ );
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-menus.js b/browser/components/extensions/parent/ext-menus.js
new file mode 100644
index 0000000000..f168b53f96
--- /dev/null
+++ b/browser/components/extensions/parent/ext-menus.js
@@ -0,0 +1,1354 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(
+ this,
+ "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Bookmarks",
+ "resource://gre/modules/Bookmarks.jsm"
+);
+
+var { DefaultMap, ExtensionError, parseMatchPatterns } = ExtensionUtils;
+
+var { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+var { IconDetails } = ExtensionParent;
+
+const ACTION_MENU_TOP_LEVEL_LIMIT = 6;
+
+// Map[Extension -> Map[ID -> MenuItem]]
+// Note: we want to enumerate all the menu items so
+// this cannot be a weak map.
+var gMenuMap = new Map();
+
+// Map[Extension -> MenuItem]
+var gRootItems = new Map();
+
+// Map[Extension -> ID[]]
+// Menu IDs that were eligible for being shown in the current menu.
+var gShownMenuItems = new DefaultMap(() => []);
+
+// Map[Extension -> Set[Contexts]]
+// A DefaultMap (keyed by extension) which keeps track of the
+// contexts with a subscribed onShown event listener.
+var gOnShownSubscribers = new DefaultMap(() => new Set());
+
+// If id is not specified for an item we use an integer.
+var gNextMenuItemID = 0;
+
+// Used to assign unique names to radio groups.
+var gNextRadioGroupID = 0;
+
+// The max length of a menu item's label.
+var gMaxLabelLength = 64;
+
+var gMenuBuilder = {
+ // When a new menu is opened, this function is called and
+ // we populate the |xulMenu| with all the items from extensions
+ // to be displayed. We always clear all the items again when
+ // popuphidden fires.
+ build(contextData) {
+ contextData = this.maybeOverrideContextData(contextData);
+ let xulMenu = contextData.menu;
+ xulMenu.addEventListener("popuphidden", this);
+ this.xulMenu = xulMenu;
+ for (let [, root] of gRootItems) {
+ this.createAndInsertTopLevelElements(root, contextData, null);
+ }
+ this.afterBuildingMenu(contextData);
+
+ if (
+ contextData.webExtContextData &&
+ !contextData.webExtContextData.showDefaults
+ ) {
+ // Wait until nsContextMenu.js has toggled the visibility of the default
+ // menu items before hiding the default items.
+ Promise.resolve().then(() => this.hideDefaultMenuItems());
+ }
+ },
+
+ maybeOverrideContextData(contextData) {
+ let { webExtContextData } = contextData;
+ if (!webExtContextData || !webExtContextData.overrideContext) {
+ return contextData;
+ }
+ let contextDataBase = {
+ menu: contextData.menu,
+ // eslint-disable-next-line no-use-before-define
+ originalViewType: getContextViewType(contextData),
+ originalViewUrl: contextData.inFrame
+ ? contextData.frameUrl
+ : contextData.pageUrl,
+ webExtContextData,
+ };
+ if (webExtContextData.overrideContext === "bookmark") {
+ return {
+ ...contextDataBase,
+ bookmarkId: webExtContextData.bookmarkId,
+ onBookmark: true,
+ };
+ }
+ if (webExtContextData.overrideContext === "tab") {
+ // TODO: Handle invalid tabs more gracefully (instead of throwing).
+ let tab = tabTracker.getTab(webExtContextData.tabId);
+ return {
+ ...contextDataBase,
+ tab,
+ pageUrl: tab.linkedBrowser.currentURI.spec,
+ onTab: true,
+ };
+ }
+ throw new Error(
+ `Unexpected overrideContext: ${webExtContextData.overrideContext}`
+ );
+ },
+
+ canAccessContext(extension, contextData) {
+ if (!extension.privateBrowsingAllowed) {
+ let nativeTab = contextData.tab;
+ if (
+ nativeTab &&
+ PrivateBrowsingUtils.isBrowserPrivate(nativeTab.linkedBrowser)
+ ) {
+ return false;
+ } else if (
+ PrivateBrowsingUtils.isWindowPrivate(contextData.menu.ownerGlobal)
+ ) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ createAndInsertTopLevelElements(root, contextData, nextSibling) {
+ let rootElements;
+ if (!this.canAccessContext(root.extension, contextData)) {
+ return;
+ }
+ if (contextData.onBrowserAction || contextData.onPageAction) {
+ if (contextData.extension.id !== root.extension.id) {
+ return;
+ }
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ ACTION_MENU_TOP_LEVEL_LIMIT,
+ false
+ );
+
+ // Action menu items are prepended to the menu, followed by a separator.
+ nextSibling = nextSibling || this.xulMenu.firstElementChild;
+ if (rootElements.length && !this.itemsToCleanUp.has(nextSibling)) {
+ rootElements.push(
+ this.xulMenu.ownerDocument.createXULElement("menuseparator")
+ );
+ }
+ } else if (contextData.webExtContextData) {
+ let {
+ extensionId,
+ showDefaults,
+ overrideContext,
+ } = contextData.webExtContextData;
+ if (extensionId === root.extension.id) {
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ Infinity,
+ false
+ );
+ // The extension menu should be rendered at the top, but after the navigation buttons.
+ nextSibling =
+ nextSibling ||
+ this.xulMenu.querySelector(":scope > #context-sep-navigation + *");
+ if (
+ rootElements.length &&
+ showDefaults &&
+ !this.itemsToCleanUp.has(nextSibling)
+ ) {
+ rootElements.push(
+ this.xulMenu.ownerDocument.createXULElement("menuseparator")
+ );
+ }
+ } else if (!showDefaults && !overrideContext) {
+ // When the default menu items should be hidden, menu items from other
+ // extensions should be hidden too.
+ return;
+ }
+ // Fall through to show default extension menu items.
+ }
+ if (!rootElements) {
+ rootElements = this.buildTopLevelElements(root, contextData, 1, true);
+ if (
+ rootElements.length &&
+ !this.itemsToCleanUp.has(this.xulMenu.lastElementChild)
+ ) {
+ // All extension menu items are appended at the end.
+ // Prepend separator if this is the first extension menu item.
+ rootElements.unshift(
+ this.xulMenu.ownerDocument.createXULElement("menuseparator")
+ );
+ }
+ }
+
+ if (!rootElements.length) {
+ return;
+ }
+
+ if (nextSibling) {
+ nextSibling.before(...rootElements);
+ } else {
+ this.xulMenu.append(...rootElements);
+ }
+ for (let item of rootElements) {
+ this.itemsToCleanUp.add(item);
+ }
+ },
+
+ buildElementWithChildren(item, contextData) {
+ const element = this.buildSingleElement(item, contextData);
+ const children = this.buildChildren(item, contextData);
+ if (children.length) {
+ element.firstElementChild.append(...children);
+ }
+ return element;
+ },
+
+ buildChildren(item, contextData) {
+ let groupName;
+ let children = [];
+ for (let child of item.children) {
+ if (child.type == "radio" && !child.groupName) {
+ if (!groupName) {
+ groupName = `webext-radio-group-${gNextRadioGroupID++}`;
+ }
+ child.groupName = groupName;
+ } else {
+ groupName = null;
+ }
+
+ if (child.enabledForContext(contextData)) {
+ children.push(this.buildElementWithChildren(child, contextData));
+ }
+ }
+ return children;
+ },
+
+ buildTopLevelElements(root, contextData, maxCount, forceManifestIcons) {
+ let children = this.buildChildren(root, contextData);
+
+ // TODO: Fix bug 1492969 and remove this whole if block.
+ if (
+ children.length === 1 &&
+ maxCount === 1 &&
+ forceManifestIcons &&
+ AppConstants.platform === "linux" &&
+ children[0].getAttribute("type") === "checkbox"
+ ) {
+ // Keep single checkbox items in the submenu on Linux since
+ // the extension icon overlaps the checkbox otherwise.
+ maxCount = 0;
+ }
+
+ if (children.length > maxCount) {
+ // Move excess items into submenu.
+ let rootElement = this.buildSingleElement(root, contextData);
+ rootElement.setAttribute("ext-type", "top-level-menu");
+ rootElement.firstElementChild.append(...children.splice(maxCount - 1));
+ children.push(rootElement);
+ }
+
+ if (forceManifestIcons) {
+ for (let rootElement of children) {
+ // Display the extension icon on the root element.
+ if (root.extension.manifest.icons) {
+ this.setMenuItemIcon(
+ rootElement,
+ root.extension,
+ contextData,
+ root.extension.manifest.icons
+ );
+ } else {
+ this.removeMenuItemIcon(rootElement);
+ }
+ }
+ }
+ return children;
+ },
+
+ removeSeparatorIfNoTopLevelItems() {
+ // Extension menu items always have have a non-empty ID.
+ let isNonExtensionSeparator = item =>
+ item.nodeName === "menuseparator" && !item.id;
+
+ // itemsToCleanUp contains all top-level menu items. A separator should
+ // only be kept if it is next to an extension menu item.
+ let isExtensionMenuItemSibling = item =>
+ item && this.itemsToCleanUp.has(item) && !isNonExtensionSeparator(item);
+
+ for (let item of this.itemsToCleanUp) {
+ if (isNonExtensionSeparator(item)) {
+ if (
+ !isExtensionMenuItemSibling(item.previousElementSibling) &&
+ !isExtensionMenuItemSibling(item.nextElementSibling)
+ ) {
+ item.remove();
+ this.itemsToCleanUp.delete(item);
+ }
+ }
+ }
+ },
+
+ buildSingleElement(item, contextData) {
+ let doc = contextData.menu.ownerDocument;
+ let element;
+ if (item.children.length) {
+ element = this.createMenuElement(doc, item);
+ } else if (item.type == "separator") {
+ element = doc.createXULElement("menuseparator");
+ } else {
+ element = doc.createXULElement("menuitem");
+ }
+
+ return this.customizeElement(element, item, contextData);
+ },
+
+ createMenuElement(doc, item) {
+ let element = doc.createXULElement("menu");
+ // Menu elements need to have a menupopup child for its menu items.
+ let menupopup = doc.createXULElement("menupopup");
+ element.appendChild(menupopup);
+ return element;
+ },
+
+ customizeElement(element, item, contextData) {
+ let label = item.title;
+ if (label) {
+ let accessKey;
+ label = label.replace(/&([\S\s]|$)/g, (_, nextChar, i) => {
+ if (nextChar === "&") {
+ return "&";
+ }
+ if (accessKey === undefined) {
+ if (nextChar === "%" && label.charAt(i + 2) === "s") {
+ accessKey = "";
+ } else {
+ accessKey = nextChar;
+ }
+ }
+ return nextChar;
+ });
+ element.setAttribute("accesskey", accessKey || "");
+
+ if (contextData.isTextSelected && label.indexOf("%s") > -1) {
+ let selection = contextData.selectionText.trim();
+ // The rendering engine will truncate the title if it's longer than 64 characters.
+ // But if it makes sense let's try truncate selection text only, to handle cases like
+ // 'look up "%s" in MyDictionary' more elegantly.
+
+ let codePointsToRemove = 0;
+
+ let selectionArray = Array.from(selection);
+
+ let completeLabelLength = label.length - 2 + selectionArray.length;
+ if (completeLabelLength > gMaxLabelLength) {
+ codePointsToRemove = completeLabelLength - gMaxLabelLength;
+ }
+
+ if (codePointsToRemove) {
+ let ellipsis = "\u2026";
+ try {
+ ellipsis = Services.prefs.getComplexValue(
+ "intl.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+ codePointsToRemove += 1;
+ selection =
+ selectionArray.slice(0, -codePointsToRemove).join("") + ellipsis;
+ }
+
+ label = label.replace(/%s/g, selection);
+ }
+
+ element.setAttribute("label", label);
+ }
+
+ element.setAttribute("id", item.elementId);
+
+ if ("icons" in item) {
+ if (item.icons) {
+ this.setMenuItemIcon(element, item.extension, contextData, item.icons);
+ } else {
+ this.removeMenuItemIcon(element);
+ }
+ }
+
+ if (item.type == "checkbox") {
+ element.setAttribute("type", "checkbox");
+ if (item.checked) {
+ element.setAttribute("checked", "true");
+ }
+ } else if (item.type == "radio") {
+ element.setAttribute("type", "radio");
+ element.setAttribute("name", item.groupName);
+ if (item.checked) {
+ element.setAttribute("checked", "true");
+ }
+ }
+
+ if (!item.enabled) {
+ element.setAttribute("disabled", "true");
+ }
+
+ let button;
+
+ element.addEventListener(
+ "command",
+ event => {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+ const wasChecked = item.checked;
+ if (item.type == "checkbox") {
+ item.checked = !item.checked;
+ } else if (item.type == "radio") {
+ // Deselect all radio items in the current radio group.
+ for (let child of item.parent.children) {
+ if (child.type == "radio" && child.groupName == item.groupName) {
+ child.checked = false;
+ }
+ }
+ // Select the clicked radio item.
+ item.checked = true;
+ }
+
+ let { webExtContextData } = contextData;
+ if (
+ contextData.tab &&
+ // If the menu context was overridden by the extension, do not grant
+ // activeTab since the extension also controls the tabId.
+ (!webExtContextData ||
+ webExtContextData.extensionId !== item.extension.id)
+ ) {
+ item.tabManager.addActiveTabPermission(contextData.tab);
+ }
+
+ let info = item.getClickInfo(contextData, wasChecked);
+ info.modifiers = clickModifiersFromEvent(event);
+
+ info.button = button;
+
+ // Allow menus to open various actions supported in webext prior
+ // to notifying onclicked.
+ let actionFor = {
+ _execute_page_action: global.pageActionFor,
+ _execute_browser_action: global.browserActionFor,
+ _execute_sidebar_action: global.sidebarActionFor,
+ }[item.command];
+ if (actionFor) {
+ let win = event.target.ownerGlobal;
+ actionFor(item.extension).triggerAction(win);
+ }
+
+ item.extension.emit(
+ "webext-menu-menuitem-click",
+ info,
+ contextData.tab
+ );
+ },
+ { once: true }
+ );
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ element.addEventListener("click", event => {
+ if (
+ event.target !== event.currentTarget ||
+ // Ignore menu items that are usually not clickeable,
+ // such as separators and parents of submenus and disabled items.
+ element.localName !== "menuitem" ||
+ element.disabled
+ ) {
+ return;
+ }
+
+ button = event.button;
+ if (event.button) {
+ element.doCommand();
+ contextData.menu.hidePopup();
+ }
+ });
+
+ // Don't publish the ID of the root because the root element is
+ // auto-generated.
+ if (item.parent) {
+ gShownMenuItems.get(item.extension).push(item.id);
+ }
+
+ return element;
+ },
+
+ setMenuItemIcon(element, extension, contextData, icons) {
+ let parentWindow = contextData.menu.ownerGlobal;
+
+ let { icon } = IconDetails.getPreferredIcon(
+ icons,
+ extension,
+ 16 * parentWindow.devicePixelRatio
+ );
+
+ // The extension icons in the manifest are not pre-resolved, since
+ // they're sometimes used by the add-on manager when the extension is
+ // not enabled, and its URLs are not resolvable.
+ let resolvedURL = extension.baseURI.resolve(icon);
+
+ if (element.localName == "menu") {
+ element.setAttribute("class", "menu-iconic");
+ } else if (element.localName == "menuitem") {
+ element.setAttribute("class", "menuitem-iconic");
+ }
+
+ element.setAttribute("image", resolvedURL);
+ },
+
+ // Undo changes from setMenuItemIcon.
+ removeMenuItemIcon(element) {
+ element.removeAttribute("class");
+ element.removeAttribute("image");
+ },
+
+ rebuildMenu(extension) {
+ let { contextData } = this;
+ if (!contextData) {
+ // This happens if the menu is not visible.
+ return;
+ }
+
+ // Find the group of existing top-level items (usually 0 or 1 items)
+ // and remember its position for when the new items are inserted.
+ let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`;
+ let nextSibling = null;
+ for (let item of this.itemsToCleanUp) {
+ if (item.id && item.id.startsWith(elementIdPrefix)) {
+ nextSibling = item.nextSibling;
+ item.remove();
+ this.itemsToCleanUp.delete(item);
+ }
+ }
+
+ let root = gRootItems.get(extension);
+ if (root) {
+ this.createAndInsertTopLevelElements(root, contextData, nextSibling);
+ }
+ this.removeSeparatorIfNoTopLevelItems();
+ },
+
+ // This should be called once, after constructing the top-level menus, if any.
+ afterBuildingMenu(contextData) {
+ let dispatchOnShownEvent = extension => {
+ if (!this.canAccessContext(extension, contextData)) {
+ return;
+ }
+
+ // Note: gShownMenuItems is a DefaultMap, so .get(extension) causes the
+ // extension to be stored in the map even if there are currently no
+ // shown menu items. This ensures that the onHidden event can be fired
+ // when the menu is closed.
+ let menuIds = gShownMenuItems.get(extension);
+ extension.emit("webext-menu-shown", menuIds, contextData);
+ };
+
+ if (contextData.onBrowserAction || contextData.onPageAction) {
+ dispatchOnShownEvent(contextData.extension);
+ } else {
+ for (const extension of gOnShownSubscribers.keys()) {
+ dispatchOnShownEvent(extension);
+ }
+ }
+
+ this.contextData = contextData;
+ },
+
+ hideDefaultMenuItems() {
+ for (let item of this.xulMenu.children) {
+ if (!this.itemsToCleanUp.has(item)) {
+ item.hidden = true;
+ }
+ }
+ },
+
+ handleEvent(event) {
+ if (this.xulMenu != event.target || event.type != "popuphidden") {
+ return;
+ }
+
+ delete this.xulMenu;
+ delete this.contextData;
+
+ let target = event.target;
+ target.removeEventListener("popuphidden", this);
+ for (let item of this.itemsToCleanUp) {
+ item.remove();
+ }
+ this.itemsToCleanUp.clear();
+ for (let extension of gShownMenuItems.keys()) {
+ extension.emit("webext-menu-hidden");
+ }
+ gShownMenuItems.clear();
+ },
+
+ itemsToCleanUp: new Set(),
+};
+
+// Called from pageAction or browserAction popup.
+global.actionContextMenu = function(contextData) {
+ contextData.tab = tabTracker.activeTab;
+ contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec;
+ gMenuBuilder.build(contextData);
+};
+
+const contextsMap = {
+ onAudio: "audio",
+ onEditable: "editable",
+ inFrame: "frame",
+ onImage: "image",
+ onLink: "link",
+ onPassword: "password",
+ isTextSelected: "selection",
+ onVideo: "video",
+
+ onBookmark: "bookmark",
+ onBrowserAction: "browser_action",
+ onPageAction: "page_action",
+ onTab: "tab",
+ inToolsMenu: "tools_menu",
+};
+
+const getMenuContexts = contextData => {
+ let contexts = new Set();
+
+ for (const [key, value] of Object.entries(contextsMap)) {
+ if (contextData[key]) {
+ contexts.add(value);
+ }
+ }
+
+ if (contexts.size === 0) {
+ contexts.add("page");
+ }
+
+ // New non-content contexts supported in Firefox are not part of "all".
+ if (
+ !contextData.onBookmark &&
+ !contextData.onTab &&
+ !contextData.inToolsMenu
+ ) {
+ contexts.add("all");
+ }
+
+ return contexts;
+};
+
+function getContextViewType(contextData) {
+ if ("originalViewType" in contextData) {
+ return contextData.originalViewType;
+ }
+ if (
+ contextData.webExtBrowserType === "popup" ||
+ contextData.webExtBrowserType === "sidebar"
+ ) {
+ return contextData.webExtBrowserType;
+ }
+ if (contextData.tab && contextData.menu.id === "contentAreaContextMenu") {
+ return "tab";
+ }
+ return undefined;
+}
+
+function addMenuEventInfo(info, contextData, extension, includeSensitiveData) {
+ info.viewType = getContextViewType(contextData);
+ if (contextData.onVideo) {
+ info.mediaType = "video";
+ } else if (contextData.onAudio) {
+ info.mediaType = "audio";
+ } else if (contextData.onImage) {
+ info.mediaType = "image";
+ }
+ if (contextData.frameId !== undefined) {
+ info.frameId = contextData.frameId;
+ }
+ if (contextData.onBookmark) {
+ info.bookmarkId = contextData.bookmarkId;
+ }
+ info.editable = contextData.onEditable || false;
+ if (includeSensitiveData) {
+ // menus.getTargetElement requires the "menus" permission, so do not set
+ // targetElementId for extensions with only the "contextMenus" permission.
+ if (contextData.timeStamp && extension.hasPermission("menus")) {
+ // Convert to integer, in case the DOMHighResTimeStamp has a fractional part.
+ info.targetElementId = Math.floor(contextData.timeStamp);
+ }
+ if (contextData.onLink) {
+ info.linkText = contextData.linkText;
+ info.linkUrl = contextData.linkUrl;
+ }
+ if (contextData.onAudio || contextData.onImage || contextData.onVideo) {
+ info.srcUrl = contextData.srcUrl;
+ }
+ if (!contextData.onBookmark) {
+ info.pageUrl = contextData.pageUrl;
+ }
+ if (contextData.inFrame) {
+ info.frameUrl = contextData.frameUrl;
+ }
+ if (contextData.isTextSelected) {
+ info.selectionText = contextData.selectionText;
+ }
+ }
+ // If the context was overridden, then frameUrl should be the URL of the
+ // document in which the menu was opened (instead of undefined, even if that
+ // document is not in a frame).
+ if (contextData.originalViewUrl) {
+ info.frameUrl = contextData.originalViewUrl;
+ }
+}
+
+function MenuItem(extension, createProperties, isRoot = false) {
+ this.extension = extension;
+ this.children = [];
+ this.parent = null;
+ this.tabManager = extension.tabManager;
+
+ this.setDefaults();
+ this.setProps(createProperties);
+
+ if (!this.hasOwnProperty("_id")) {
+ this.id = gNextMenuItemID++;
+ }
+ // If the item is not the root and has no parent
+ // it must be a child of the root.
+ if (!isRoot && !this.parent) {
+ this.root.addChild(this);
+ }
+}
+
+MenuItem.prototype = {
+ setProps(createProperties) {
+ for (let propName in createProperties) {
+ if (createProperties[propName] === null) {
+ // Omitted optional argument.
+ continue;
+ }
+ this[propName] = createProperties[propName];
+ }
+
+ if ("icons" in createProperties && createProperties.icons === null) {
+ this.icons = null;
+ }
+
+ if (createProperties.documentUrlPatterns != null) {
+ this.documentUrlMatchPattern = parseMatchPatterns(
+ this.documentUrlPatterns,
+ {
+ restrictSchemes: this.extension.restrictSchemes,
+ }
+ );
+ }
+
+ if (createProperties.targetUrlPatterns != null) {
+ this.targetUrlMatchPattern = parseMatchPatterns(this.targetUrlPatterns, {
+ // restrictSchemes default to false when matching links instead of pages
+ // (see Bug 1280370 for a rationale).
+ restrictSchemes: false,
+ });
+ }
+
+ // If a child MenuItem does not specify any contexts, then it should
+ // inherit the contexts specified from its parent.
+ if (createProperties.parentId && !createProperties.contexts) {
+ this.contexts = this.parent.contexts;
+ }
+ },
+
+ setDefaults() {
+ this.setProps({
+ type: "normal",
+ checked: false,
+ contexts: ["all"],
+ enabled: true,
+ visible: true,
+ });
+ },
+
+ set id(id) {
+ if (this.hasOwnProperty("_id")) {
+ throw new ExtensionError("ID of a MenuItem cannot be changed");
+ }
+ let isIdUsed = gMenuMap.get(this.extension).has(id);
+ if (isIdUsed) {
+ throw new ExtensionError(`ID already exists: ${id}`);
+ }
+ this._id = id;
+ },
+
+ get id() {
+ return this._id;
+ },
+
+ get elementId() {
+ let id = this.id;
+ // If the ID is an integer, it is auto-generated and globally unique.
+ // If the ID is a string, it is only unique within one extension and the
+ // ID needs to be concatenated with the extension ID.
+ if (typeof id !== "number") {
+ // To avoid collisions with numeric IDs, add a prefix to string IDs.
+ id = `_${id}`;
+ }
+ return `${makeWidgetId(this.extension.id)}-menuitem-${id}`;
+ },
+
+ ensureValidParentId(parentId) {
+ if (parentId === undefined) {
+ return;
+ }
+ let menuMap = gMenuMap.get(this.extension);
+ if (!menuMap.has(parentId)) {
+ throw new ExtensionError(
+ `Could not find any MenuItem with id: ${parentId}`
+ );
+ }
+ for (let item = menuMap.get(parentId); item; item = item.parent) {
+ if (item === this) {
+ throw new ExtensionError(
+ "MenuItem cannot be an ancestor (or self) of its new parent."
+ );
+ }
+ }
+ },
+
+ set parentId(parentId) {
+ this.ensureValidParentId(parentId);
+
+ if (this.parent) {
+ this.parent.detachChild(this);
+ }
+
+ if (parentId === undefined) {
+ this.root.addChild(this);
+ } else {
+ let menuMap = gMenuMap.get(this.extension);
+ menuMap.get(parentId).addChild(this);
+ }
+ },
+
+ get parentId() {
+ return this.parent ? this.parent.id : undefined;
+ },
+
+ addChild(child) {
+ if (child.parent) {
+ throw new Error("Child MenuItem already has a parent.");
+ }
+ this.children.push(child);
+ child.parent = this;
+ },
+
+ detachChild(child) {
+ let idx = this.children.indexOf(child);
+ if (idx < 0) {
+ throw new Error("Child MenuItem not found, it cannot be removed.");
+ }
+ this.children.splice(idx, 1);
+ child.parent = null;
+ },
+
+ get root() {
+ let extension = this.extension;
+ if (!gRootItems.has(extension)) {
+ let root = new MenuItem(
+ extension,
+ { title: extension.name },
+ /* isRoot = */ true
+ );
+ gRootItems.set(extension, root);
+ }
+
+ return gRootItems.get(extension);
+ },
+
+ remove() {
+ if (this.parent) {
+ this.parent.detachChild(this);
+ }
+ let children = this.children.slice(0);
+ for (let child of children) {
+ child.remove();
+ }
+
+ let menuMap = gMenuMap.get(this.extension);
+ menuMap.delete(this.id);
+ if (this.root == this) {
+ gRootItems.delete(this.extension);
+ }
+ },
+
+ getClickInfo(contextData, wasChecked) {
+ let info = {
+ menuItemId: this.id,
+ };
+ if (this.parent) {
+ info.parentMenuItemId = this.parentId;
+ }
+
+ addMenuEventInfo(info, contextData, this.extension, true);
+
+ if (this.type === "checkbox" || this.type === "radio") {
+ info.checked = this.checked;
+ info.wasChecked = wasChecked;
+ }
+
+ return info;
+ },
+
+ enabledForContext(contextData) {
+ if (!this.visible) {
+ return false;
+ }
+ let contexts = getMenuContexts(contextData);
+ if (!this.contexts.some(n => contexts.has(n))) {
+ return false;
+ }
+
+ if (
+ this.viewTypes &&
+ !this.viewTypes.includes(getContextViewType(contextData))
+ ) {
+ return false;
+ }
+
+ let docPattern = this.documentUrlMatchPattern;
+ // When viewTypes is specified, the menu item is expected to be restricted
+ // to documents. So let documentUrlPatterns always apply to the URL of the
+ // document in which the menu was opened. When maybeOverrideContextData
+ // changes the context, contextData.pageUrl does not reflect that URL any
+ // more, so use contextData.originalViewUrl instead.
+ if (docPattern && this.viewTypes && contextData.originalViewUrl) {
+ if (
+ !docPattern.matches(Services.io.newURI(contextData.originalViewUrl))
+ ) {
+ return false;
+ }
+ docPattern = null; // Null it so that it won't be used with pageURI below.
+ }
+
+ if (contextData.onBookmark) {
+ return this.extension.hasPermission("bookmarks");
+ }
+
+ let pageURI = Services.io.newURI(
+ contextData[contextData.inFrame ? "frameUrl" : "pageUrl"]
+ );
+ if (docPattern && !docPattern.matches(pageURI)) {
+ return false;
+ }
+
+ let targetPattern = this.targetUrlMatchPattern;
+ if (targetPattern) {
+ let targetUrls = [];
+ if (contextData.onImage || contextData.onAudio || contextData.onVideo) {
+ // TODO: double check if srcUrl is always set when we need it
+ targetUrls.push(contextData.srcUrl);
+ }
+ if (contextData.onLink) {
+ targetUrls.push(contextData.linkUrl);
+ }
+ if (
+ !targetUrls.some(targetUrl =>
+ targetPattern.matches(Services.io.newURI(targetUrl))
+ )
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+};
+
+// windowTracker only looks as browser windows, but we're also interested in
+// the Library window. Helper for menuTracker below.
+const libraryTracker = {
+ libraryWindowType: "Places:Organizer",
+
+ isLibraryWindow(window) {
+ let winType = window.document.documentElement.getAttribute("windowtype");
+ return winType === this.libraryWindowType;
+ },
+
+ init(listener) {
+ this._listener = listener;
+ Services.ww.registerNotification(this);
+
+ // See WindowTrackerBase#*browserWindows in ext-tabs-base.js for why we
+ // can't use the enumerator's windowtype filter.
+ for (let window of Services.wm.getEnumerator("")) {
+ if (window.document.readyState === "complete") {
+ if (this.isLibraryWindow(window)) {
+ this.notify(window);
+ }
+ } else {
+ window.addEventListener("load", this, { once: true });
+ }
+ }
+ },
+
+ // cleanupWindow is called on any library window that's open.
+ uninit(cleanupWindow) {
+ Services.ww.unregisterNotification(this);
+
+ for (let window of Services.wm.getEnumerator("")) {
+ window.removeEventListener("load", this);
+ try {
+ if (this.isLibraryWindow(window)) {
+ cleanupWindow(window);
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ },
+
+ // Gets notifications from Services.ww.registerNotification.
+ // Defer actually doing anything until the window's loaded, though.
+ observe(window, topic) {
+ if (topic === "domwindowopened") {
+ window.addEventListener("load", this, { once: true });
+ }
+ },
+
+ // Gets the load event for new windows(registered in observe()).
+ handleEvent(event) {
+ let window = event.target.defaultView;
+ if (this.isLibraryWindow(window)) {
+ this.notify(window);
+ }
+ },
+
+ notify(window) {
+ try {
+ this._listener.call(null, window);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ },
+};
+
+// While any extensions are active, this Tracker registers to observe/listen
+// for menu events from both Tools and context menus, both content and chrome.
+const menuTracker = {
+ menuIds: ["placesContext", "menu_ToolsPopup", "tabContextMenu"],
+
+ register() {
+ Services.obs.addObserver(this, "on-build-contextmenu");
+ for (const window of windowTracker.browserWindows()) {
+ this.onWindowOpen(window);
+ }
+ windowTracker.addOpenListener(this.onWindowOpen);
+ libraryTracker.init(this.onLibraryOpen);
+ },
+
+ unregister() {
+ Services.obs.removeObserver(this, "on-build-contextmenu");
+ for (const window of windowTracker.browserWindows()) {
+ this.cleanupWindow(window);
+ }
+ windowTracker.removeOpenListener(this.onWindowOpen);
+ libraryTracker.uninit(this.cleanupLibrary);
+ },
+
+ observe(subject, topic, data) {
+ subject = subject.wrappedJSObject;
+ gMenuBuilder.build(subject);
+ },
+
+ async onWindowOpen(window) {
+ for (const id of menuTracker.menuIds) {
+ const menu = window.document.getElementById(id);
+ menu.addEventListener("popupshowing", menuTracker);
+ }
+
+ const sidebarHeader = window.document.getElementById(
+ "sidebar-switcher-target"
+ );
+ sidebarHeader.addEventListener("SidebarShown", menuTracker.onSidebarShown);
+
+ await window.SidebarUI.promiseInitialized;
+
+ if (
+ !window.closed &&
+ window.SidebarUI.currentID === "viewBookmarksSidebar"
+ ) {
+ menuTracker.onSidebarShown({ currentTarget: sidebarHeader });
+ }
+ },
+
+ cleanupWindow(window) {
+ for (const id of this.menuIds) {
+ const menu = window.document.getElementById(id);
+ menu.removeEventListener("popupshowing", this);
+ }
+
+ const sidebarHeader = window.document.getElementById(
+ "sidebar-switcher-target"
+ );
+ sidebarHeader.removeEventListener("SidebarShown", this.onSidebarShown);
+
+ if (window.SidebarUI.currentID === "viewBookmarksSidebar") {
+ let sidebarBrowser = window.SidebarUI.browser;
+ sidebarBrowser.removeEventListener("load", this.onSidebarShown);
+ const menu = sidebarBrowser.contentDocument.getElementById(
+ "placesContext"
+ );
+ menu.removeEventListener("popupshowing", this.onBookmarksContextMenu);
+ }
+ },
+
+ onSidebarShown(event) {
+ // The event target is an element in a browser window, so |window| will be
+ // the browser window that contains the sidebar.
+ const window = event.currentTarget.ownerGlobal;
+ if (window.SidebarUI.currentID === "viewBookmarksSidebar") {
+ let sidebarBrowser = window.SidebarUI.browser;
+ if (sidebarBrowser.contentDocument.readyState !== "complete") {
+ // SidebarUI.currentID may be updated before the bookmark sidebar's
+ // document has finished loading. This sometimes happens when the
+ // sidebar is automatically shown when a new window is opened.
+ sidebarBrowser.addEventListener("load", menuTracker.onSidebarShown, {
+ once: true,
+ });
+ return;
+ }
+ const menu = sidebarBrowser.contentDocument.getElementById(
+ "placesContext"
+ );
+ menu.addEventListener("popupshowing", menuTracker.onBookmarksContextMenu);
+ }
+ },
+
+ onLibraryOpen(window) {
+ const menu = window.document.getElementById("placesContext");
+ menu.addEventListener("popupshowing", menuTracker.onBookmarksContextMenu);
+ },
+
+ cleanupLibrary(window) {
+ const menu = window.document.getElementById("placesContext");
+ menu.removeEventListener(
+ "popupshowing",
+ menuTracker.onBookmarksContextMenu
+ );
+ },
+
+ handleEvent(event) {
+ const menu = event.target;
+
+ if (menu.id === "placesContext") {
+ const trigger = menu.triggerNode;
+ if (!trigger._placesNode?.bookmarkGuid) {
+ return;
+ }
+
+ gMenuBuilder.build({
+ menu,
+ bookmarkId: trigger._placesNode.bookmarkGuid,
+ onBookmark: true,
+ });
+ }
+ if (menu.id === "menu_ToolsPopup") {
+ const tab = tabTracker.activeTab;
+ const pageUrl = tab.linkedBrowser.currentURI.spec;
+ gMenuBuilder.build({ menu, tab, pageUrl, inToolsMenu: true });
+ }
+ if (menu.id === "tabContextMenu") {
+ const tab = menu.ownerGlobal.TabContextMenu.contextTab;
+ const pageUrl = tab.linkedBrowser.currentURI.spec;
+ gMenuBuilder.build({ menu, tab, pageUrl, onTab: true });
+ }
+ },
+
+ onBookmarksContextMenu(event) {
+ const menu = event.target;
+ const tree = menu.triggerNode.parentElement;
+ const cell = tree.getCellAt(event.x, event.y);
+ const node = tree.view.nodeForTreeIndex(cell.row);
+
+ if (!node.bookmarkGuid || Bookmarks.isVirtualRootItem(node.bookmarkGuid)) {
+ return;
+ }
+
+ gMenuBuilder.build({
+ menu,
+ bookmarkId: node.bookmarkGuid,
+ onBookmark: true,
+ });
+ },
+};
+
+this.menusInternal = class extends ExtensionAPI {
+ constructor(extension) {
+ super(extension);
+
+ if (!gMenuMap.size) {
+ menuTracker.register();
+ }
+ gMenuMap.set(extension, new Map());
+ }
+
+ onShutdown() {
+ let { extension } = this;
+
+ if (gMenuMap.has(extension)) {
+ gMenuMap.delete(extension);
+ gRootItems.delete(extension);
+ gShownMenuItems.delete(extension);
+ gOnShownSubscribers.delete(extension);
+ if (!gMenuMap.size) {
+ menuTracker.unregister();
+ }
+ }
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+
+ const menus = {
+ refresh() {
+ gMenuBuilder.rebuildMenu(extension);
+ },
+
+ onShown: new EventManager({
+ context,
+ name: "menus.onShown",
+ register: fire => {
+ let listener = (event, menuIds, contextData) => {
+ let info = {
+ menuIds,
+ contexts: Array.from(getMenuContexts(contextData)),
+ };
+
+ let nativeTab = contextData.tab;
+
+ // The menus.onShown event is fired before the user has consciously
+ // interacted with an extension, so we require permissions before
+ // exposing sensitive contextual data.
+ let contextUrl = contextData.inFrame
+ ? contextData.frameUrl
+ : contextData.pageUrl;
+ let includeSensitiveData =
+ (nativeTab &&
+ extension.tabManager.hasActiveTabPermission(nativeTab)) ||
+ (contextUrl && extension.allowedOrigins.matches(contextUrl));
+
+ addMenuEventInfo(
+ info,
+ contextData,
+ extension,
+ includeSensitiveData
+ );
+
+ let tab = nativeTab && extension.tabManager.convert(nativeTab);
+ fire.sync(info, tab);
+ };
+ gOnShownSubscribers.get(extension).add(context);
+ extension.on("webext-menu-shown", listener);
+ return () => {
+ const contexts = gOnShownSubscribers.get(extension);
+ contexts.delete(context);
+ if (contexts.size === 0) {
+ gOnShownSubscribers.delete(extension);
+ }
+ extension.off("webext-menu-shown", listener);
+ };
+ },
+ }).api(),
+ onHidden: new EventManager({
+ context,
+ name: "menus.onHidden",
+ register: fire => {
+ let listener = () => {
+ fire.sync();
+ };
+ extension.on("webext-menu-hidden", listener);
+ return () => {
+ extension.off("webext-menu-hidden", listener);
+ };
+ },
+ }).api(),
+ };
+
+ return {
+ contextMenus: menus,
+ menus,
+ menusInternal: {
+ create: function(createProperties) {
+ // Note that the id is required by the schema. If the addon did not set
+ // it, the implementation of menus.create in the child should
+ // have added it.
+ let menuItem = new MenuItem(extension, createProperties);
+ gMenuMap.get(extension).set(menuItem.id, menuItem);
+ },
+
+ update: function(id, updateProperties) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (menuItem) {
+ menuItem.setProps(updateProperties);
+ }
+ },
+
+ remove: function(id) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (menuItem) {
+ menuItem.remove();
+ }
+ },
+
+ removeAll: function() {
+ let root = gRootItems.get(extension);
+ if (root) {
+ root.remove();
+ }
+ },
+
+ onClicked: new EventManager({
+ context,
+ name: "menusInternal.onClicked",
+ register: fire => {
+ let listener = (event, info, nativeTab) => {
+ let { linkedBrowser } = nativeTab || tabTracker.activeTab;
+ let tab = nativeTab && extension.tabManager.convert(nativeTab);
+ context.withPendingBrowser(linkedBrowser, () =>
+ fire.sync(info, tab)
+ );
+ };
+
+ extension.on("webext-menu-menuitem-click", listener);
+ return () => {
+ extension.off("webext-menu-menuitem-click", listener);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-normandyAddonStudy.js b/browser/components/extensions/parent/ext-normandyAddonStudy.js
new file mode 100644
index 0000000000..df0db9754a
--- /dev/null
+++ b/browser/components/extensions/parent/ext-normandyAddonStudy.js
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If 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 { AddonStudies } = ChromeUtils.import(
+ "resource://normandy/lib/AddonStudies.jsm"
+);
+const { ClientID } = ChromeUtils.import("resource://gre/modules/ClientID.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManager",
+ "resource://gre/modules/AddonManager.jsm"
+);
+
+this.normandyAddonStudy = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ normandyAddonStudy: {
+ /**
+ * Returns a study object for the current study.
+ *
+ * @returns {Study}
+ */
+ async getStudy() {
+ const studies = await AddonStudies.getAll();
+ return studies.find(study => study.addonId === extension.id);
+ },
+
+ /**
+ * Marks the study as ended and then uninstalls the addon.
+ *
+ * @param {string} reason Why the study is ending
+ */
+ async endStudy(reason) {
+ const study = await this.getStudy();
+
+ // Mark the study as ended
+ await AddonStudies.markAsEnded(study, reason);
+
+ // Uninstall the addon
+ const addon = await AddonManager.getAddonByID(study.addonId);
+ if (addon) {
+ await addon.uninstall();
+ }
+ },
+
+ /**
+ * Returns an object with metadata about the client which may
+ * be required for constructing survey URLs.
+ *
+ * @returns {Object}
+ */
+ async getClientMetadata() {
+ return {
+ updateChannel: Services.appinfo.defaultUpdateChannel,
+ fxVersion: Services.appinfo.version,
+ clientID: await ClientID.getClientID(),
+ };
+ },
+
+ onUnenroll: new EventManager({
+ context,
+ name: "normandyAddonStudy.onUnenroll",
+ register: fire => {
+ const listener = async reason => {
+ await fire.async(reason);
+ };
+
+ AddonStudies.addUnenrollListener(extension.id, listener);
+
+ return () => {
+ AddonStudies.removeUnenrollListener(extension.id, listener);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-omnibox.js b/browser/components/extensions/parent/ext-omnibox.js
new file mode 100644
index 0000000000..7348af6439
--- /dev/null
+++ b/browser/components/extensions/parent/ext-omnibox.js
@@ -0,0 +1,125 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionSearchHandler",
+ "resource://gre/modules/ExtensionSearchHandler.jsm"
+);
+
+this.omnibox = class extends ExtensionAPI {
+ onManifestEntry(entryName) {
+ let { extension } = this;
+ let { manifest } = extension;
+
+ let keyword = manifest.omnibox.keyword;
+ try {
+ // This will throw if the keyword is already registered.
+ ExtensionSearchHandler.registerKeyword(keyword, extension);
+ this.keyword = keyword;
+ } catch (e) {
+ extension.manifestError(e.message);
+ }
+ }
+
+ onShutdown() {
+ ExtensionSearchHandler.unregisterKeyword(this.keyword);
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+ return {
+ omnibox: {
+ setDefaultSuggestion: suggestion => {
+ try {
+ // This will throw if the keyword failed to register.
+ ExtensionSearchHandler.setDefaultSuggestion(
+ this.keyword,
+ suggestion
+ );
+ } catch (e) {
+ return Promise.reject(e.message);
+ }
+ },
+
+ onInputStarted: new EventManager({
+ context,
+ name: "omnibox.onInputStarted",
+ register: fire => {
+ let listener = eventName => {
+ fire.sync();
+ };
+ extension.on(ExtensionSearchHandler.MSG_INPUT_STARTED, listener);
+ return () => {
+ extension.off(ExtensionSearchHandler.MSG_INPUT_STARTED, listener);
+ };
+ },
+ }).api(),
+
+ onInputCancelled: new EventManager({
+ context,
+ name: "omnibox.onInputCancelled",
+ register: fire => {
+ let listener = eventName => {
+ fire.sync();
+ };
+ extension.on(ExtensionSearchHandler.MSG_INPUT_CANCELLED, listener);
+ return () => {
+ extension.off(
+ ExtensionSearchHandler.MSG_INPUT_CANCELLED,
+ listener
+ );
+ };
+ },
+ }).api(),
+
+ onInputEntered: new EventManager({
+ context,
+ name: "omnibox.onInputEntered",
+ register: fire => {
+ let listener = (eventName, text, disposition) => {
+ fire.sync(text, disposition);
+ };
+ extension.on(ExtensionSearchHandler.MSG_INPUT_ENTERED, listener);
+ return () => {
+ extension.off(ExtensionSearchHandler.MSG_INPUT_ENTERED, listener);
+ };
+ },
+ }).api(),
+
+ // Internal APIs.
+ addSuggestions: (id, suggestions) => {
+ try {
+ ExtensionSearchHandler.addSuggestions(
+ this.keyword,
+ id,
+ suggestions
+ );
+ } catch (e) {
+ // Silently fail because the extension developer can not know for sure if the user
+ // has already invalidated the callback when asynchronously providing suggestions.
+ }
+ },
+
+ onInputChanged: new EventManager({
+ context,
+ name: "omnibox.onInputChanged",
+ register: fire => {
+ let listener = (eventName, text, id) => {
+ fire.sync(text, id);
+ };
+ extension.on(ExtensionSearchHandler.MSG_INPUT_CHANGED, listener);
+ return () => {
+ extension.off(ExtensionSearchHandler.MSG_INPUT_CHANGED, listener);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-pageAction.js b/browser/components/extensions/parent/ext-pageAction.js
new file mode 100644
index 0000000000..0027c48b5a
--- /dev/null
+++ b/browser/components/extensions/parent/ext-pageAction.js
@@ -0,0 +1,365 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionTelemetry",
+ "resource://gre/modules/ExtensionTelemetry.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PageActions",
+ "resource:///modules/PageActions.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PanelPopup",
+ "resource:///modules/ExtensionPopups.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserUsageTelemetry",
+ "resource:///modules/BrowserUsageTelemetry.jsm"
+);
+
+var { DefaultWeakMap } = ExtensionUtils;
+
+var { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+var { PageActionBase } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionActions.jsm"
+);
+
+// WeakMap[Extension -> PageAction]
+let pageActionMap = new WeakMap();
+
+class PageAction extends PageActionBase {
+ constructor(extension, buttonDelegate) {
+ let tabContext = new TabContext(tab => this.getContextData(null));
+ super(tabContext, extension);
+ this.buttonDelegate = buttonDelegate;
+ }
+
+ updateOnChange(target) {
+ this.buttonDelegate.updateButton(target.ownerGlobal);
+ }
+
+ getTab(tabId) {
+ if (tabId !== null) {
+ return tabTracker.getTab(tabId);
+ }
+ return null;
+ }
+}
+
+this.pageAction = class extends ExtensionAPI {
+ static for(extension) {
+ return pageActionMap.get(extension);
+ }
+
+ static onUpdate(id, manifest) {
+ if (!("page_action" in manifest)) {
+ // If the new version has no page action then mark this widget as hidden
+ // in the telemetry. If it is already marked hidden then this will do
+ // nothing.
+ BrowserUsageTelemetry.recordWidgetChange(makeWidgetId(id), null, "addon");
+ }
+ }
+
+ static onDisable(id) {
+ BrowserUsageTelemetry.recordWidgetChange(makeWidgetId(id), null, "addon");
+ }
+
+ static onUninstall(id) {
+ // If the telemetry already has this widget as hidden then this will not
+ // record anything.
+ BrowserUsageTelemetry.recordWidgetChange(makeWidgetId(id), null, "addon");
+ }
+
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+ let options = extension.manifest.page_action;
+
+ this.action = new PageAction(extension, this);
+ await this.action.loadIconData();
+
+ let widgetId = makeWidgetId(extension.id);
+ this.id = widgetId + "-page-action";
+
+ this.tabManager = extension.tabManager;
+
+ this.browserStyle = options.browser_style;
+
+ pageActionMap.set(extension, this);
+
+ this.lastValues = new DefaultWeakMap(() => ({}));
+
+ if (!this.browserPageAction) {
+ let onPlacedHandler = (buttonNode, isPanel) => {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ buttonNode.addEventListener("auxclick", event => {
+ if (event.button !== 1 || event.target.disabled) {
+ return;
+ }
+
+ this.lastClickInfo = {
+ button: event.button,
+ modifiers: clickModifiersFromEvent(event),
+ };
+
+ // The panel is not automatically closed when middle-clicked.
+ if (isPanel) {
+ buttonNode.closest("#pageActionPanel").hidePopup();
+ }
+ let window = event.target.ownerGlobal;
+ let tab = window.gBrowser.selectedTab;
+ this.emit("click", tab);
+ });
+ };
+
+ this.browserPageAction = PageActions.addAction(
+ new PageActions.Action({
+ id: widgetId,
+ extensionID: extension.id,
+ title: this.action.getProperty(null, "title"),
+ iconURL: this.action.getProperty(null, "title"),
+ pinnedToUrlbar: this.action.getPinned(),
+ disabled: !this.action.getProperty(null, "enabled"),
+ onCommand: (event, buttonNode) => {
+ this.lastClickInfo = {
+ button: event.button || 0,
+ modifiers: clickModifiersFromEvent(event),
+ };
+ this.handleClick(event.target.ownerGlobal);
+ },
+ onBeforePlacedInWindow: browserWindow => {
+ if (
+ this.extension.hasPermission("menus") ||
+ this.extension.hasPermission("contextMenus")
+ ) {
+ browserWindow.document.addEventListener("popupshowing", this);
+ }
+ },
+ onPlacedInPanel: buttonNode => onPlacedHandler(buttonNode, true),
+ onPlacedInUrlbar: buttonNode => onPlacedHandler(buttonNode, false),
+ onRemovedFromWindow: browserWindow => {
+ browserWindow.document.removeEventListener("popupshowing", this);
+ },
+ })
+ );
+
+ if (this.extension.startupReason != "APP_STARTUP") {
+ // Make sure the browser telemetry has the correct state for this widget.
+ // Defer loading BrowserUsageTelemetry until after startup is complete.
+ ExtensionParent.browserStartupPromise.then(() => {
+ BrowserUsageTelemetry.recordWidgetChange(
+ widgetId,
+ this.browserPageAction.pinnedToUrlbar
+ ? "page-action-buttons"
+ : null,
+ "addon"
+ );
+ });
+ }
+
+ // If the page action is only enabled in some URLs, do pattern matching in
+ // the active tabs and update the button if necessary.
+ if (this.action.getProperty(null, "enabled") === undefined) {
+ for (let window of windowTracker.browserWindows()) {
+ let tab = window.gBrowser.selectedTab;
+ if (this.action.isShownForTab(tab)) {
+ this.updateButton(window);
+ }
+ }
+ }
+ }
+ }
+
+ onShutdown(isAppShutdown) {
+ pageActionMap.delete(this.extension);
+ this.action.onShutdown();
+
+ // Removing the browser page action causes PageActions to forget about it
+ // across app restarts, so don't remove it on app shutdown, but do remove
+ // it on all other shutdowns since there's no guarantee the action will be
+ // coming back.
+ if (!isAppShutdown && this.browserPageAction) {
+ this.browserPageAction.remove();
+ this.browserPageAction = null;
+ }
+ }
+
+ // Updates the page action button in the given window to reflect the
+ // properties of the currently selected tab:
+ //
+ // Updates "tooltiptext" and "aria-label" to match "title" property.
+ // Updates "image" to match the "icon" property.
+ // Enables or disables the icon, based on the "enabled" and "patternMatching" properties.
+ updateButton(window) {
+ let tab = window.gBrowser.selectedTab;
+ let tabData = this.action.getContextData(tab);
+ let last = this.lastValues.get(window);
+
+ window.requestAnimationFrame(() => {
+ // If we get called just before shutdown, we might have been destroyed by
+ // this point.
+ if (!this.browserPageAction) {
+ return;
+ }
+
+ let title = tabData.title || this.extension.name;
+ if (last.title !== title) {
+ this.browserPageAction.setTitle(title, window);
+ last.title = title;
+ }
+
+ let enabled =
+ tabData.enabled != null ? tabData.enabled : tabData.patternMatching;
+ if (last.enabled !== enabled) {
+ this.browserPageAction.setDisabled(!enabled, window);
+ last.enabled = enabled;
+ }
+
+ let icon = tabData.icon;
+ if (last.icon !== icon) {
+ this.browserPageAction.setIconURL(icon, window);
+ last.icon = icon;
+ }
+ });
+ }
+
+ /**
+ * Triggers this page action for the given window, with the same effects as
+ * if it were clicked by a user.
+ *
+ * This has no effect if the page action is hidden for the selected tab.
+ *
+ * @param {Window} window
+ */
+ triggerAction(window) {
+ if (this.action.isShownForTab(window.gBrowser.selectedTab)) {
+ this.lastClickInfo = { button: 0, modifiers: [] };
+ this.handleClick(window);
+ }
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "popupshowing":
+ const menu = event.target;
+ const trigger = menu.triggerNode;
+
+ if (
+ menu.id === "pageActionContextMenu" &&
+ trigger &&
+ trigger.getAttribute("actionid") === this.browserPageAction.id &&
+ !this.browserPageAction.getDisabled(trigger.ownerGlobal)
+ ) {
+ global.actionContextMenu({
+ extension: this.extension,
+ onPageAction: true,
+ menu: menu,
+ });
+ }
+ break;
+ }
+ }
+
+ // Handles a click event on the page action button for the given
+ // window.
+ // If the page action has a |popup| property, a panel is opened to
+ // that URL. Otherwise, a "click" event is emitted, and dispatched to
+ // the any click listeners in the add-on.
+ async handleClick(window) {
+ const { extension } = this;
+
+ ExtensionTelemetry.pageActionPopupOpen.stopwatchStart(extension, this);
+ let tab = window.gBrowser.selectedTab;
+ let popupURL = this.action.getProperty(tab, "popup");
+
+ this.tabManager.addActiveTabPermission(tab);
+
+ // If the widget has a popup URL defined, we open a popup, but do not
+ // dispatch a click event to the extension.
+ // If it has no popup URL defined, we dispatch a click event, but do not
+ // open a popup.
+ if (popupURL) {
+ if (this.popupNode && this.popupNode.panel.state !== "closed") {
+ // The panel is being toggled closed.
+ ExtensionTelemetry.pageActionPopupOpen.stopwatchCancel(extension, this);
+ window.BrowserPageActions.togglePanelForAction(
+ this.browserPageAction,
+ this.popupNode.panel
+ );
+ return;
+ }
+
+ this.popupNode = new PanelPopup(
+ extension,
+ window.document,
+ popupURL,
+ this.browserStyle
+ );
+ // Remove popupNode when it is closed.
+ this.popupNode.panel.addEventListener(
+ "popuphiding",
+ () => {
+ this.popupNode = undefined;
+ },
+ { once: true }
+ );
+ await this.popupNode.contentReady;
+ window.BrowserPageActions.togglePanelForAction(
+ this.browserPageAction,
+ this.popupNode.panel
+ );
+ ExtensionTelemetry.pageActionPopupOpen.stopwatchFinish(extension, this);
+ } else {
+ ExtensionTelemetry.pageActionPopupOpen.stopwatchCancel(extension, this);
+ this.emit("click", tab);
+ }
+ }
+
+ getAPI(context) {
+ const { extension } = context;
+ const { tabManager } = extension;
+ const { action } = this;
+
+ return {
+ pageAction: {
+ ...action.api(context),
+
+ onClicked: new EventManager({
+ context,
+ name: "pageAction.onClicked",
+ inputHandling: true,
+ register: fire => {
+ let listener = (evt, tab) => {
+ context.withPendingBrowser(tab.linkedBrowser, () =>
+ fire.sync(tabManager.convert(tab), this.lastClickInfo)
+ );
+ };
+
+ this.on("click", listener);
+ return () => {
+ this.off("click", listener);
+ };
+ },
+ }).api(),
+
+ openPopup: () => {
+ let window = windowTracker.topWindow;
+ this.triggerAction(window);
+ },
+ },
+ };
+ }
+};
+
+global.pageActionFor = this.pageAction.for;
diff --git a/browser/components/extensions/parent/ext-pkcs11.js b/browser/components/extensions/parent/ext-pkcs11.js
new file mode 100644
index 0000000000..697cd85d27
--- /dev/null
+++ b/browser/components/extensions/parent/ext-pkcs11.js
@@ -0,0 +1,166 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ctypes: "resource://gre/modules/ctypes.jsm",
+ NativeManifests: "resource://gre/modules/NativeManifests.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "pkcs11db",
+ "@mozilla.org/security/pkcs11moduledb;1",
+ "nsIPKCS11ModuleDB"
+);
+
+var { DefaultMap } = ExtensionUtils;
+
+const findModuleByPath = function(path) {
+ for (let module of pkcs11db.listModules()) {
+ if (module && module.libName === path) {
+ return module;
+ }
+ }
+ return null;
+};
+
+this.pkcs11 = class extends ExtensionAPI {
+ getAPI(context) {
+ let manifestCache = new DefaultMap(async name => {
+ let hostInfo = await NativeManifests.lookupManifest(
+ "pkcs11",
+ name,
+ context
+ );
+ if (hostInfo) {
+ if (AppConstants.platform === "win") {
+ hostInfo.manifest.path = OS.Path.join(
+ OS.Path.dirname(hostInfo.path),
+ hostInfo.manifest.path
+ );
+ }
+ let manifestLib = OS.Path.basename(hostInfo.manifest.path);
+ if (AppConstants.platform !== "linux") {
+ manifestLib = manifestLib.toLowerCase(manifestLib);
+ }
+ if (
+ manifestLib !== ctypes.libraryName("nssckbi") &&
+ manifestLib !== ctypes.libraryName("osclientcerts")
+ ) {
+ return hostInfo.manifest;
+ }
+ }
+ return Promise.reject({ message: `No such PKCS#11 module ${name}` });
+ });
+ return {
+ pkcs11: {
+ /**
+ * Verify whether a given PKCS#11 module is installed.
+ *
+ * @param {string} name The name of the module, as specified in
+ * the manifest file.
+ * @returns {Promise} A Promise that resolves to true if the package
+ * is installed, or false if it is not. May be
+ * rejected if the module could not be found.
+ */
+ async isModuleInstalled(name) {
+ let manifest = await manifestCache.get(name);
+ return findModuleByPath(manifest.path) !== null;
+ },
+ /**
+ * Install a PKCS#11 module
+ *
+ * @param {string} name The name of the module, as specified in
+ * the manifest file.
+ * @param {integer} [flags = 0] Any flags to be passed on to the
+ * nsIPKCS11ModuleDB.addModule method
+ * @returns {Promise} When the Promise resolves, the module will have
+ * been installed. When it is rejected, the module
+ * either is already installed or could not be
+ * installed for some reason.
+ */
+ async installModule(name, flags = 0) {
+ let manifest = await manifestCache.get(name);
+ if (!manifest.description) {
+ return Promise.reject({
+ message: `The description field in the manifest for PKCS#11 module ${name} must have a value`,
+ });
+ }
+ pkcs11db.addModule(manifest.description, manifest.path, flags, 0);
+ },
+ /**
+ * Uninstall a PKCS#11 module
+ *
+ * @param {string} name The name of the module, as specified in
+ * the manifest file.
+ * @returns {Promise}. When the Promise resolves, the module will have
+ * been uninstalled. When it is rejected, the
+ * module either was not installed or could not be
+ * uninstalled for some reason.
+ */
+ async uninstallModule(name) {
+ let manifest = await manifestCache.get(name);
+ let module = findModuleByPath(manifest.path);
+ if (!module) {
+ return Promise.reject({
+ message: `The PKCS#11 module ${name} is not loaded`,
+ });
+ }
+ pkcs11db.deleteModule(module.name);
+ },
+ /**
+ * Get a list of slots for a given PKCS#11 module, with
+ * information on the token (if any) in the slot.
+ *
+ * The PKCS#11 standard defines slots as an abstract concept
+ * that may or may not have at most one token. In practice, when
+ * using PKCS#11 for smartcards (the most likely use case of
+ * PKCS#11 for Firefox), a slot corresponds to a cardreader, and
+ * a token corresponds to a card.
+ *
+ * @param {string} name The name of the PKCS#11 module, as
+ * specified in the manifest file.
+ * @returns {Promise} A promise that resolves to an array of objects
+ * with two properties. The `name` object contains
+ * the name of the slot; the `token` object is null
+ * if there is no token in the slot, or is an object
+ * describing various properties of the token if
+ * there is.
+ */
+ async getModuleSlots(name) {
+ let manifest = await manifestCache.get(name);
+ let module = findModuleByPath(manifest.path);
+ if (!module) {
+ return Promise.reject({
+ message: `The module ${name} is not installed`,
+ });
+ }
+ let rv = [];
+ for (let slot of module.listSlots()) {
+ let token = slot.getToken();
+ let slotobj = {
+ name: slot.name,
+ token: null,
+ };
+ if (slot.status != 1 /* SLOT_NOT_PRESENT */) {
+ slotobj.token = {
+ name: token.tokenName,
+ manufacturer: token.tokenManID,
+ HWVersion: token.tokenHWVersion,
+ FWVersion: token.tokenFWVersion,
+ serial: token.tokenSerialNumber,
+ isLoggedIn: token.isLoggedIn(),
+ };
+ }
+ rv.push(slotobj);
+ }
+ return rv;
+ },
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-search.js b/browser/components/extensions/parent/ext-search.js
new file mode 100644
index 0000000000..85a1c84635
--- /dev/null
+++ b/browser/components/extensions/parent/ext-search.js
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+this.search = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ search: {
+ async get() {
+ await searchInitialized;
+ let visibleEngines = await Services.search.getVisibleEngines();
+ let defaultEngine = await Services.search.getDefault();
+ return Promise.all(
+ visibleEngines.map(async engine => {
+ let favIconUrl;
+ if (engine.iconURI) {
+ // Convert moz-extension:-URLs to data:-URLs to make sure that
+ // extensions can see icons from other extensions, even if they
+ // are not web-accessible.
+ // Also prevents leakage of extension UUIDs to other extensions..
+ if (
+ engine.iconURI.schemeIs("moz-extension") &&
+ engine.iconURI.host !== context.extension.uuid
+ ) {
+ favIconUrl = await ExtensionUtils.makeDataURI(
+ engine.iconURI.spec
+ );
+ } else {
+ favIconUrl = engine.iconURI.spec;
+ }
+ }
+
+ return {
+ name: engine.name,
+ isDefault: engine.name === defaultEngine.name,
+ alias: engine.alias || undefined,
+ favIconUrl,
+ };
+ })
+ );
+ },
+
+ async search(searchProperties) {
+ await searchInitialized;
+ let engine;
+ if (searchProperties.engine) {
+ engine = Services.search.getEngineByName(searchProperties.engine);
+ if (!engine) {
+ throw new ExtensionError(
+ `${searchProperties.engine} was not found`
+ );
+ }
+ }
+
+ const tab = searchProperties.tabId
+ ? tabTracker.getTab(searchProperties.tabId)
+ : null;
+
+ await windowTracker.topWindow.BrowserSearch.loadSearchFromExtension(
+ searchProperties.query,
+ engine,
+ tab,
+ context.principal
+ );
+ },
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-sessions.js b/browser/components/extensions/parent/ext-sessions.js
new file mode 100644
index 0000000000..7a399076c0
--- /dev/null
+++ b/browser/components/extensions/parent/ext-sessions.js
@@ -0,0 +1,272 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionError, promiseObserved } = ExtensionUtils;
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManagerPrivate",
+ "resource://gre/modules/AddonManager.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "SessionStore",
+ "resource:///modules/sessionstore/SessionStore.jsm"
+);
+
+const SS_ON_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed";
+
+const getRecentlyClosed = (maxResults, extension) => {
+ let recentlyClosed = [];
+
+ // Get closed windows
+ // Closed private windows are not stored in sessionstore, we do
+ // not need to check access for that.
+ let closedWindowData = SessionStore.getClosedWindowData(false);
+ for (let window of closedWindowData) {
+ recentlyClosed.push({
+ lastModified: window.closedAt,
+ window: Window.convertFromSessionStoreClosedData(extension, window),
+ });
+ }
+
+ // Get closed tabs
+ // Private closed tabs are in sessionstore if the owning window is still open .
+ for (let window of windowTracker.browserWindows()) {
+ if (!extension.canAccessWindow(window)) {
+ continue;
+ }
+ let closedTabData = SessionStore.getClosedTabData(window, false);
+ for (let tab of closedTabData) {
+ recentlyClosed.push({
+ lastModified: tab.closedAt,
+ tab: Tab.convertFromSessionStoreClosedData(extension, tab, window),
+ });
+ }
+ }
+
+ // Sort windows and tabs
+ recentlyClosed.sort((a, b) => b.lastModified - a.lastModified);
+ return recentlyClosed.slice(0, maxResults);
+};
+
+const createSession = async function createSession(
+ restored,
+ extension,
+ sessionId
+) {
+ if (!restored) {
+ throw new ExtensionError(
+ `Could not restore object using sessionId ${sessionId}.`
+ );
+ }
+ let sessionObj = { lastModified: Date.now() };
+ if (restored instanceof Ci.nsIDOMChromeWindow) {
+ await promiseObserved(
+ "sessionstore-single-window-restored",
+ subject => subject == restored
+ );
+ sessionObj.window = extension.windowManager.convert(restored, {
+ populate: true,
+ });
+ return sessionObj;
+ }
+ sessionObj.tab = extension.tabManager.convert(restored);
+ return sessionObj;
+};
+
+const getEncodedKey = function getEncodedKey(extensionId, key) {
+ // Throw if using a temporary extension id.
+ if (AddonManagerPrivate.isTemporaryInstallID(extensionId)) {
+ let message =
+ "Sessions API storage methods will not work with a temporary addon ID. " +
+ "Please add an explicit addon ID to your manifest.";
+ throw new ExtensionError(message);
+ }
+
+ return `extension:${extensionId}:${key}`;
+};
+
+this.sessions = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+
+ function getTabParams(key, id) {
+ let encodedKey = getEncodedKey(extension.id, key);
+ let tab = tabTracker.getTab(id);
+ if (!context.canAccessWindow(tab.ownerGlobal)) {
+ throw new ExtensionError(`Invalid tab ID: ${id}`);
+ }
+ return { encodedKey, tab };
+ }
+
+ function getWindowParams(key, id) {
+ let encodedKey = getEncodedKey(extension.id, key);
+ let win = windowTracker.getWindow(id, context);
+ return { encodedKey, win };
+ }
+
+ return {
+ sessions: {
+ async getRecentlyClosed(filter) {
+ await SessionStore.promiseInitialized;
+ let maxResults =
+ filter.maxResults == undefined
+ ? this.MAX_SESSION_RESULTS
+ : filter.maxResults;
+ return getRecentlyClosed(maxResults, extension);
+ },
+
+ async forgetClosedTab(windowId, sessionId) {
+ await SessionStore.promiseInitialized;
+ let window = windowTracker.getWindow(windowId, context);
+ let closedTabData = SessionStore.getClosedTabData(window, false);
+
+ let closedTabIndex = closedTabData.findIndex(closedTab => {
+ return closedTab.closedId === parseInt(sessionId, 10);
+ });
+
+ if (closedTabIndex < 0) {
+ throw new ExtensionError(
+ `Could not find closed tab using sessionId ${sessionId}.`
+ );
+ }
+
+ SessionStore.forgetClosedTab(window, closedTabIndex);
+ },
+
+ async forgetClosedWindow(sessionId) {
+ await SessionStore.promiseInitialized;
+ let closedWindowData = SessionStore.getClosedWindowData(false);
+
+ let closedWindowIndex = closedWindowData.findIndex(closedWindow => {
+ return closedWindow.closedId === parseInt(sessionId, 10);
+ });
+
+ if (closedWindowIndex < 0) {
+ throw new ExtensionError(
+ `Could not find closed window using sessionId ${sessionId}.`
+ );
+ }
+
+ SessionStore.forgetClosedWindow(closedWindowIndex);
+ },
+
+ async restore(sessionId) {
+ await SessionStore.promiseInitialized;
+ let session, closedId;
+ if (sessionId) {
+ closedId = sessionId;
+ session = SessionStore.undoCloseById(
+ closedId,
+ extension.privateBrowsingAllowed
+ );
+ } else if (SessionStore.lastClosedObjectType == "window") {
+ // If the most recently closed object is a window, just undo closing the most recent window.
+ session = SessionStore.undoCloseWindow(0);
+ } else {
+ // It is a tab, and we cannot call SessionStore.undoCloseTab without a window,
+ // so we must find the tab in which case we can just use its closedId.
+ let recentlyClosedTabs = [];
+ for (let window of windowTracker.browserWindows()) {
+ let closedTabData = SessionStore.getClosedTabData(window, false);
+ for (let tab of closedTabData) {
+ recentlyClosedTabs.push(tab);
+ }
+ }
+
+ if (recentlyClosedTabs.length) {
+ // Sort the tabs.
+ recentlyClosedTabs.sort((a, b) => b.closedAt - a.closedAt);
+
+ // Use the closedId of the most recently closed tab to restore it.
+ closedId = recentlyClosedTabs[0].closedId;
+ session = SessionStore.undoCloseById(
+ closedId,
+ extension.privateBrowsingAllowed
+ );
+ }
+ }
+ return createSession(session, extension, closedId);
+ },
+
+ setTabValue(tabId, key, value) {
+ let { tab, encodedKey } = getTabParams(key, tabId);
+
+ SessionStore.setCustomTabValue(
+ tab,
+ encodedKey,
+ JSON.stringify(value)
+ );
+ },
+
+ async getTabValue(tabId, key) {
+ let { tab, encodedKey } = getTabParams(key, tabId);
+
+ let value = SessionStore.getCustomTabValue(tab, encodedKey);
+ if (value) {
+ return JSON.parse(value);
+ }
+
+ return undefined;
+ },
+
+ removeTabValue(tabId, key) {
+ let { tab, encodedKey } = getTabParams(key, tabId);
+
+ SessionStore.deleteCustomTabValue(tab, encodedKey);
+ },
+
+ setWindowValue(windowId, key, value) {
+ let { win, encodedKey } = getWindowParams(key, windowId);
+
+ SessionStore.setCustomWindowValue(
+ win,
+ encodedKey,
+ JSON.stringify(value)
+ );
+ },
+
+ async getWindowValue(windowId, key) {
+ let { win, encodedKey } = getWindowParams(key, windowId);
+
+ let value = SessionStore.getCustomWindowValue(win, encodedKey);
+ if (value) {
+ return JSON.parse(value);
+ }
+
+ return undefined;
+ },
+
+ removeWindowValue(windowId, key) {
+ let { win, encodedKey } = getWindowParams(key, windowId);
+
+ SessionStore.deleteCustomWindowValue(win, encodedKey);
+ },
+
+ onChanged: new EventManager({
+ context,
+ name: "sessions.onChanged",
+ register: fire => {
+ let observer = () => {
+ fire.async();
+ };
+
+ Services.obs.addObserver(observer, SS_ON_CLOSED_OBJECTS_CHANGED);
+ return () => {
+ Services.obs.removeObserver(
+ observer,
+ SS_ON_CLOSED_OBJECTS_CHANGED
+ );
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-sidebarAction.js b/browser/components/extensions/parent/ext-sidebarAction.js
new file mode 100644
index 0000000000..b30128661e
--- /dev/null
+++ b/browser/components/extensions/parent/ext-sidebarAction.js
@@ -0,0 +1,529 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+var { IconDetails } = ExtensionParent;
+
+// WeakMap[Extension -> SidebarAction]
+let sidebarActionMap = new WeakMap();
+
+const sidebarURL = "chrome://browser/content/webext-panels.xhtml";
+
+/**
+ * Responsible for the sidebar_action section of the manifest as well
+ * as the associated sidebar browser.
+ */
+this.sidebarAction = class extends ExtensionAPI {
+ static for(extension) {
+ return sidebarActionMap.get(extension);
+ }
+
+ onManifestEntry(entryName) {
+ let { extension } = this;
+
+ extension.once("ready", this.onReady.bind(this));
+
+ let options = extension.manifest.sidebar_action;
+
+ // Add the extension to the sidebar menu. The sidebar widget will copy
+ // from that when it is viewed, so we shouldn't need to update that.
+ let widgetId = makeWidgetId(extension.id);
+ this.id = `${widgetId}-sidebar-action`;
+ this.menuId = `menu_${this.id}`;
+ this.buttonId = `button_${this.id}`;
+
+ this.browserStyle = options.browser_style;
+
+ this.defaults = {
+ enabled: true,
+ title: options.default_title || extension.name,
+ icon: IconDetails.normalize({ path: options.default_icon }, extension),
+ panel: options.default_panel || "",
+ };
+ this.globals = Object.create(this.defaults);
+
+ this.tabContext = new TabContext(target => {
+ let window = target.ownerGlobal;
+ if (target === window) {
+ return this.globals;
+ }
+ return this.tabContext.get(window);
+ });
+
+ // We need to ensure our elements are available before session restore.
+ this.windowOpenListener = window => {
+ this.createMenuItem(window, this.globals);
+ };
+ windowTracker.addOpenListener(this.windowOpenListener);
+
+ this.updateHeader = event => {
+ let window = event.target.ownerGlobal;
+ let details = this.tabContext.get(window.gBrowser.selectedTab);
+ let header = window.document.getElementById("sidebar-switcher-target");
+ if (window.SidebarUI.currentID === this.id) {
+ this.setMenuIcon(header, details);
+ }
+ };
+
+ this.windowCloseListener = window => {
+ let header = window.document.getElementById("sidebar-switcher-target");
+ if (header) {
+ header.removeEventListener("SidebarShown", this.updateHeader);
+ }
+ };
+ windowTracker.addCloseListener(this.windowCloseListener);
+
+ sidebarActionMap.set(extension, this);
+ }
+
+ onReady() {
+ this.build();
+ }
+
+ onShutdown(isAppShutdown) {
+ sidebarActionMap.delete(this.this);
+
+ this.tabContext.shutdown();
+
+ // Don't remove everything on app shutdown so session restore can handle
+ // restoring open sidebars.
+ if (isAppShutdown) {
+ return;
+ }
+
+ for (let window of windowTracker.browserWindows()) {
+ let { document, SidebarUI } = window;
+ if (SidebarUI.currentID === this.id) {
+ SidebarUI.hide();
+ }
+ let menu = document.getElementById(this.menuId);
+ if (menu) {
+ menu.remove();
+ }
+ let button = document.getElementById(this.buttonId);
+ if (button) {
+ button.remove();
+ }
+ let header = document.getElementById("sidebar-switcher-target");
+ header.removeEventListener("SidebarShown", this.updateHeader);
+ SidebarUI.sidebars.delete(this.id);
+ }
+ windowTracker.removeOpenListener(this.windowOpenListener);
+ windowTracker.removeCloseListener(this.windowCloseListener);
+ }
+
+ static onUninstall(id) {
+ const sidebarId = `${makeWidgetId(id)}-sidebar-action`;
+ for (let window of windowTracker.browserWindows()) {
+ let { SidebarUI } = window;
+ if (SidebarUI.lastOpenedId === sidebarId) {
+ SidebarUI.lastOpenedId = null;
+ }
+ }
+ }
+
+ build() {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ this.tabContext.on("tab-select", (evt, tab) => {
+ this.updateWindow(tab.ownerGlobal);
+ });
+
+ let install = this.extension.startupReason === "ADDON_INSTALL";
+ for (let window of windowTracker.browserWindows()) {
+ this.updateWindow(window);
+ let { SidebarUI } = window;
+ if (
+ (install && this.extension.manifest.sidebar_action.open_at_install) ||
+ SidebarUI.lastOpenedId == this.id
+ ) {
+ SidebarUI.show(this.id);
+ }
+ }
+ }
+
+ createMenuItem(window, details) {
+ if (!this.extension.canAccessWindow(window)) {
+ return;
+ }
+ let { document, SidebarUI } = window;
+ let keyId = `ext-key-id-${this.id}`;
+
+ SidebarUI.sidebars.set(this.id, {
+ title: details.title,
+ url: sidebarURL,
+ menuId: this.menuId,
+ buttonId: this.buttonId,
+ // The following properties are specific to extensions
+ extensionId: this.extension.id,
+ panel: details.panel,
+ browserStyle: this.browserStyle,
+ });
+
+ let header = document.getElementById("sidebar-switcher-target");
+ header.addEventListener("SidebarShown", this.updateHeader);
+
+ // Insert a menuitem for View->Show Sidebars.
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("id", this.menuId);
+ menuitem.setAttribute("type", "checkbox");
+ menuitem.setAttribute("label", details.title);
+ menuitem.setAttribute("oncommand", `SidebarUI.toggle("${this.id}");`);
+ menuitem.setAttribute("class", "menuitem-iconic webextension-menuitem");
+ menuitem.setAttribute("key", keyId);
+ this.setMenuIcon(menuitem, details);
+
+ // Insert a toolbarbutton for the sidebar dropdown selector.
+ let toolbarbutton = document.createXULElement("toolbarbutton");
+ toolbarbutton.setAttribute("id", this.buttonId);
+ toolbarbutton.setAttribute("type", "checkbox");
+ toolbarbutton.setAttribute("label", details.title);
+ toolbarbutton.setAttribute("oncommand", `SidebarUI.show("${this.id}");`);
+ toolbarbutton.setAttribute(
+ "class",
+ "subviewbutton subviewbutton-iconic webextension-menuitem"
+ );
+ toolbarbutton.setAttribute("key", keyId);
+ this.setMenuIcon(toolbarbutton, details);
+
+ document.getElementById("viewSidebarMenu").appendChild(menuitem);
+ let separator = document.getElementById("sidebar-extensions-separator");
+ separator.parentNode.insertBefore(toolbarbutton, separator);
+ SidebarUI.updateShortcut({ button: toolbarbutton });
+
+ return menuitem;
+ }
+
+ setMenuIcon(menuitem, details) {
+ let getIcon = size =>
+ IconDetails.escapeUrl(
+ IconDetails.getPreferredIcon(details.icon, this.extension, size).icon
+ );
+
+ menuitem.setAttribute(
+ "style",
+ `
+ --webextension-menuitem-image: url("${getIcon(16)}");
+ --webextension-menuitem-image-2x: url("${getIcon(32)}");
+ `
+ );
+ }
+
+ /**
+ * Update the menu items with the tab context data in `tabData`.
+ *
+ * @param {ChromeWindow} window
+ * Browser chrome window.
+ * @param {object} tabData
+ * Tab specific sidebar configuration.
+ */
+ updateButton(window, tabData) {
+ let { document, SidebarUI } = window;
+ let title = tabData.title || this.extension.name;
+ let menu = document.getElementById(this.menuId);
+ if (!menu) {
+ menu = this.createMenuItem(window, tabData);
+ }
+
+ let urlChanged = tabData.panel !== SidebarUI.sidebars.get(this.id).panel;
+ if (urlChanged) {
+ SidebarUI.sidebars.get(this.id).panel = tabData.panel;
+ }
+
+ menu.setAttribute("label", title);
+ this.setMenuIcon(menu, tabData);
+
+ let button = document.getElementById(this.buttonId);
+ button.setAttribute("label", title);
+ this.setMenuIcon(button, tabData);
+
+ // Update the sidebar if this extension is the current sidebar.
+ if (SidebarUI.currentID === this.id) {
+ SidebarUI.title = title;
+ let header = document.getElementById("sidebar-switcher-target");
+ this.setMenuIcon(header, tabData);
+ if (SidebarUI.isOpen && urlChanged) {
+ SidebarUI.show(this.id);
+ }
+ }
+ }
+
+ /**
+ * Update the menu items for a given window.
+ *
+ * @param {ChromeWindow} window
+ * Browser chrome window.
+ */
+ updateWindow(window) {
+ if (!this.extension.canAccessWindow(window)) {
+ return;
+ }
+ let nativeTab = window.gBrowser.selectedTab;
+ this.updateButton(window, this.tabContext.get(nativeTab));
+ }
+
+ /**
+ * Update the menu items when the extension changes the icon,
+ * title, url, etc. If it only changes a parameter for a single tab, `target`
+ * will be that tab. If it only changes a parameter for a single window,
+ * `target` will be that window. Otherwise `target` will be null.
+ *
+ * @param {XULElement|ChromeWindow|null} target
+ * Browser tab or browser chrome window, may be null.
+ */
+ updateOnChange(target) {
+ if (target) {
+ let window = target.ownerGlobal;
+ if (target === window || target.selected) {
+ this.updateWindow(window);
+ }
+ } else {
+ for (let window of windowTracker.browserWindows()) {
+ this.updateWindow(window);
+ }
+ }
+ }
+
+ /**
+ * Gets the target object corresponding to the `details` parameter of the various
+ * get* and set* API methods.
+ *
+ * @param {Object} details
+ * An object with optional `tabId` or `windowId` properties.
+ * @throws if both `tabId` and `windowId` are specified, or if they are invalid.
+ * @returns {XULElement|ChromeWindow|null}
+ * If a `tabId` was specified, the corresponding XULElement tab.
+ * If a `windowId` was specified, the corresponding ChromeWindow.
+ * Otherwise, `null`.
+ */
+ getTargetFromDetails({ tabId, windowId }) {
+ if (tabId != null && windowId != null) {
+ throw new ExtensionError(
+ "Only one of tabId and windowId can be specified."
+ );
+ }
+ let target = null;
+ if (tabId != null) {
+ target = tabTracker.getTab(tabId);
+ if (!this.extension.canAccessWindow(target.ownerGlobal)) {
+ throw new ExtensionError(`Invalid tab ID: ${tabId}`);
+ }
+ } else if (windowId != null) {
+ target = windowTracker.getWindow(windowId);
+ if (!this.extension.canAccessWindow(target)) {
+ throw new ExtensionError(`Invalid window ID: ${windowId}`);
+ }
+ }
+ return target;
+ }
+
+ /**
+ * Gets the data associated with a tab, window, or the global one.
+ *
+ * @param {XULElement|ChromeWindow|null} target
+ * A XULElement tab, a ChromeWindow, or null for the global data.
+ * @returns {Object}
+ * The icon, title, panel, etc. associated with the target.
+ */
+ getContextData(target) {
+ if (target) {
+ return this.tabContext.get(target);
+ }
+ return this.globals;
+ }
+
+ /**
+ * Set a global, window specific or tab specific property.
+ *
+ * @param {XULElement|ChromeWindow|null} target
+ * A XULElement tab, a ChromeWindow, or null for the global data.
+ * @param {string} prop
+ * String property to set ["icon", "title", or "panel"].
+ * @param {string} value
+ * Value for property.
+ */
+ setProperty(target, prop, value) {
+ let values = this.getContextData(target);
+ if (value === null) {
+ delete values[prop];
+ } else {
+ values[prop] = value;
+ }
+
+ this.updateOnChange(target);
+ }
+
+ /**
+ * Retrieve the value of a global, window specific or tab specific property.
+ *
+ * @param {XULElement|ChromeWindow|null} target
+ * A XULElement tab, a ChromeWindow, or null for the global data.
+ * @param {string} prop
+ * String property to retrieve ["icon", "title", or "panel"]
+ * @returns {string} value
+ * Value of prop.
+ */
+ getProperty(target, prop) {
+ return this.getContextData(target)[prop];
+ }
+
+ setPropertyFromDetails(details, prop, value) {
+ return this.setProperty(this.getTargetFromDetails(details), prop, value);
+ }
+
+ getPropertyFromDetails(details, prop) {
+ return this.getProperty(this.getTargetFromDetails(details), prop);
+ }
+
+ /**
+ * Triggers this sidebar action for the given window, with the same effects as
+ * if it were toggled via menu or toolbarbutton by a user.
+ *
+ * @param {ChromeWindow} window
+ */
+ triggerAction(window) {
+ let { SidebarUI } = window;
+ if (SidebarUI && this.extension.canAccessWindow(window)) {
+ SidebarUI.toggle(this.id);
+ }
+ }
+
+ /**
+ * Opens this sidebar action for the given window.
+ *
+ * @param {ChromeWindow} window
+ */
+ open(window) {
+ let { SidebarUI } = window;
+ if (SidebarUI && this.extension.canAccessWindow(window)) {
+ SidebarUI.show(this.id);
+ }
+ }
+
+ /**
+ * Closes this sidebar action for the given window if this sidebar action is open.
+ *
+ * @param {ChromeWindow} window
+ */
+ close(window) {
+ if (this.isOpen(window)) {
+ window.SidebarUI.hide();
+ }
+ }
+
+ /**
+ * Toogles this sidebar action for the given window
+ *
+ * @param {ChromeWindow} window
+ */
+ toggle(window) {
+ let { SidebarUI } = window;
+ if (!SidebarUI || !this.extension.canAccessWindow(window)) {
+ return;
+ }
+
+ if (!this.isOpen(window)) {
+ SidebarUI.show(this.id);
+ } else {
+ SidebarUI.hide();
+ }
+ }
+
+ /**
+ * Checks whether this sidebar action is open in the given window.
+ *
+ * @param {ChromeWindow} window
+ * @returns {boolean}
+ */
+ isOpen(window) {
+ let { SidebarUI } = window;
+ return SidebarUI.isOpen && this.id == SidebarUI.currentID;
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+ const sidebarAction = this;
+
+ return {
+ sidebarAction: {
+ async setTitle(details) {
+ sidebarAction.setPropertyFromDetails(details, "title", details.title);
+ },
+
+ getTitle(details) {
+ return sidebarAction.getPropertyFromDetails(details, "title");
+ },
+
+ async setIcon(details) {
+ let icon = IconDetails.normalize(details, extension, context);
+ if (!Object.keys(icon).length) {
+ icon = null;
+ }
+ sidebarAction.setPropertyFromDetails(details, "icon", icon);
+ },
+
+ async setPanel(details) {
+ let url;
+ // Clear the url when given null or empty string.
+ if (!details.panel) {
+ url = null;
+ } else {
+ url = context.uri.resolve(details.panel);
+ if (!context.checkLoadURL(url)) {
+ return Promise.reject({
+ message: `Access denied for URL ${url}`,
+ });
+ }
+ }
+
+ sidebarAction.setPropertyFromDetails(details, "panel", url);
+ },
+
+ getPanel(details) {
+ return sidebarAction.getPropertyFromDetails(details, "panel");
+ },
+
+ open() {
+ let window = windowTracker.topWindow;
+ if (context.canAccessWindow(window)) {
+ sidebarAction.open(window);
+ }
+ },
+
+ close() {
+ let window = windowTracker.topWindow;
+ if (context.canAccessWindow(window)) {
+ sidebarAction.close(window);
+ }
+ },
+
+ toggle() {
+ let window = windowTracker.topWindow;
+ if (context.canAccessWindow(window)) {
+ sidebarAction.toggle(window);
+ }
+ },
+
+ isOpen(details) {
+ let { windowId } = details;
+ if (windowId == null) {
+ windowId = Window.WINDOW_ID_CURRENT;
+ }
+ let window = windowTracker.getWindow(windowId, context);
+ return sidebarAction.isOpen(window);
+ },
+ },
+ };
+ }
+};
+
+global.sidebarActionFor = this.sidebarAction.for;
diff --git a/browser/components/extensions/parent/ext-tabs.js b/browser/components/extensions/parent/ext-tabs.js
new file mode 100644
index 0000000000..685c9c1764
--- /dev/null
+++ b/browser/components/extensions/parent/ext-tabs.js
@@ -0,0 +1,1574 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserUtils",
+ "resource://gre/modules/BrowserUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "DownloadPaths",
+ "resource://gre/modules/DownloadPaths.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionControlledPopup",
+ "resource:///modules/ExtensionControlledPopup.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PromiseUtils",
+ "resource://gre/modules/PromiseUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "SessionStore",
+ "resource:///modules/sessionstore/SessionStore.jsm"
+);
+
+XPCOMUtils.defineLazyGetter(this, "strBundle", function() {
+ return Services.strings.createBundle(
+ "chrome://global/locale/extensions.properties"
+ );
+});
+
+var { DefaultMap, ExtensionError } = ExtensionUtils;
+
+const TAB_HIDE_CONFIRMED_TYPE = "tabHideNotification";
+
+const TAB_ID_NONE = -1;
+
+XPCOMUtils.defineLazyGetter(this, "tabHidePopup", () => {
+ return new ExtensionControlledPopup({
+ confirmedType: TAB_HIDE_CONFIRMED_TYPE,
+ anchorId: "alltabs-button",
+ popupnotificationId: "extension-tab-hide-notification",
+ descriptionId: "extension-tab-hide-notification-description",
+ descriptionMessageId: "tabHideControlled.message",
+ getLocalizedDescription: (doc, message, addonDetails) => {
+ let image = doc.createXULElement("image");
+ image.setAttribute("class", "extension-controlled-icon alltabs-icon");
+ return BrowserUtils.getLocalizedFragment(
+ doc,
+ message,
+ addonDetails,
+ image
+ );
+ },
+ learnMoreMessageId: "tabHideControlled.learnMore",
+ learnMoreLink: "extension-hiding-tabs",
+ });
+});
+
+function showHiddenTabs(id) {
+ for (let win of Services.wm.getEnumerator("navigator:browser")) {
+ if (win.closed || !win.gBrowser) {
+ continue;
+ }
+
+ for (let tab of win.gBrowser.tabs) {
+ if (
+ tab.hidden &&
+ tab.ownerGlobal &&
+ SessionStore.getCustomTabValue(tab, "hiddenBy") === id
+ ) {
+ win.gBrowser.showTab(tab);
+ }
+ }
+ }
+}
+
+let tabListener = {
+ tabReadyInitialized: false,
+ // Map[tab -> Promise]
+ tabBlockedPromises: new WeakMap(),
+ // Map[tab -> Deferred]
+ tabReadyPromises: new WeakMap(),
+ initializingTabs: new WeakSet(),
+
+ initTabReady() {
+ if (!this.tabReadyInitialized) {
+ windowTracker.addListener("progress", this);
+
+ this.tabReadyInitialized = true;
+ }
+ },
+
+ onLocationChange(browser, webProgress, request, locationURI, flags) {
+ if (webProgress.isTopLevel) {
+ let { gBrowser } = browser.ownerGlobal;
+ let nativeTab = gBrowser.getTabForBrowser(browser);
+
+ // Now we are certain that the first page in the tab was loaded.
+ this.initializingTabs.delete(nativeTab);
+
+ // browser.innerWindowID is now set, resolve the promises if any.
+ let deferred = this.tabReadyPromises.get(nativeTab);
+ if (deferred) {
+ deferred.resolve(nativeTab);
+ this.tabReadyPromises.delete(nativeTab);
+ }
+ }
+ },
+
+ blockTabUntilRestored(nativeTab) {
+ let promise = ExtensionUtils.promiseEvent(nativeTab, "SSTabRestored").then(
+ ({ target }) => {
+ this.tabBlockedPromises.delete(target);
+ return target;
+ }
+ );
+
+ this.tabBlockedPromises.set(nativeTab, promise);
+ },
+
+ /**
+ * Returns a promise that resolves when the tab is ready.
+ * Tabs created via the `tabs.create` method are "ready" once the location
+ * changes to the requested URL. Other tabs are assumed to be ready once their
+ * inner window ID is known.
+ *
+ * @param {XULElement} nativeTab The <tab> element.
+ * @returns {Promise} Resolves with the given tab once ready.
+ */
+ awaitTabReady(nativeTab) {
+ let deferred = this.tabReadyPromises.get(nativeTab);
+ if (!deferred) {
+ let promise = this.tabBlockedPromises.get(nativeTab);
+ if (promise) {
+ return promise;
+ }
+ deferred = PromiseUtils.defer();
+ if (
+ !this.initializingTabs.has(nativeTab) &&
+ (nativeTab.linkedBrowser.innerWindowID ||
+ nativeTab.linkedBrowser.currentURI.spec === "about:blank")
+ ) {
+ deferred.resolve(nativeTab);
+ } else {
+ this.initTabReady();
+ this.tabReadyPromises.set(nativeTab, deferred);
+ }
+ }
+ return deferred.promise;
+ },
+};
+
+const allAttrs = new Set([
+ "attention",
+ "audible",
+ "favIconUrl",
+ "mutedInfo",
+ "sharingState",
+ "title",
+]);
+const allProperties = new Set([
+ "attention",
+ "audible",
+ "discarded",
+ "favIconUrl",
+ "hidden",
+ "isArticle",
+ "mutedInfo",
+ "pinned",
+ "sharingState",
+ "status",
+ "title",
+]);
+const restricted = new Set(["url", "favIconUrl", "title"]);
+
+class TabsUpdateFilterEventManager extends EventManager {
+ constructor(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ let register = (fire, filterProps) => {
+ let filter = { ...filterProps };
+ if (filter.urls) {
+ filter.urls = new MatchPatternSet(filter.urls, {
+ restrictSchemes: false,
+ });
+ }
+ let needsModified = true;
+ if (filter.properties) {
+ // Default is to listen for all events.
+ needsModified = filter.properties.some(p => allAttrs.has(p));
+ filter.properties = new Set(filter.properties);
+ } else {
+ filter.properties = allProperties;
+ }
+
+ function sanitize(tab, changeInfo) {
+ let result = {};
+ let nonempty = false;
+ for (let prop in changeInfo) {
+ // In practice, changeInfo contains at most one property from
+ // restricted. Therefore it is not necessary to cache the value
+ // of tab.hasTabPermission outside the loop.
+ // Unnecessarily accessing tab.hasTabPermission can cause bugs, see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1694699#c21
+ if (!restricted.has(prop) || tab.hasTabPermission) {
+ nonempty = true;
+ result[prop] = changeInfo[prop];
+ }
+ }
+ return nonempty && result;
+ }
+
+ function getWindowID(windowId) {
+ if (windowId === Window.WINDOW_ID_CURRENT) {
+ let window = windowTracker.getTopWindow(context);
+ if (!window) {
+ return undefined;
+ }
+ return windowTracker.getId(window);
+ }
+ return windowId;
+ }
+
+ function matchFilters(tab, changed) {
+ if (!filterProps) {
+ return true;
+ }
+ if (filter.tabId != null && tab.id != filter.tabId) {
+ return false;
+ }
+ if (
+ filter.windowId != null &&
+ tab.windowId != getWindowID(filter.windowId)
+ ) {
+ return false;
+ }
+ if (filter.urls) {
+ return filter.urls.matches(tab._uri) && tab.hasTabPermission;
+ }
+ return true;
+ }
+
+ let fireForTab = (tab, changed, nativeTab) => {
+ // Tab may be null if private and not_allowed.
+ if (!tab || !matchFilters(tab, changed)) {
+ return;
+ }
+
+ let changeInfo = sanitize(tab, changed);
+ if (changeInfo) {
+ tabTracker.maybeWaitForTabOpen(nativeTab).then(() => {
+ if (!nativeTab.parentNode) {
+ // If the tab is already be destroyed, do nothing.
+ return;
+ }
+ fire.async(tab.id, changeInfo, tab.convert());
+ });
+ }
+ };
+
+ let listener = event => {
+ // Ignore any events prior to TabOpen
+ if (event.originalTarget.initializingTab) {
+ return;
+ }
+ if (!context.canAccessWindow(event.originalTarget.ownerGlobal)) {
+ return;
+ }
+ let needed = [];
+ if (event.type == "TabAttrModified") {
+ let changed = event.detail.changed;
+ if (
+ changed.includes("image") &&
+ filter.properties.has("favIconUrl")
+ ) {
+ needed.push("favIconUrl");
+ }
+ if (changed.includes("muted") && filter.properties.has("mutedInfo")) {
+ needed.push("mutedInfo");
+ }
+ if (
+ changed.includes("soundplaying") &&
+ filter.properties.has("audible")
+ ) {
+ needed.push("audible");
+ }
+ if (changed.includes("label") && filter.properties.has("title")) {
+ needed.push("title");
+ }
+ if (
+ changed.includes("sharing") &&
+ filter.properties.has("sharingState")
+ ) {
+ needed.push("sharingState");
+ }
+ if (
+ changed.includes("attention") &&
+ filter.properties.has("attention")
+ ) {
+ needed.push("attention");
+ }
+ } else if (event.type == "TabPinned") {
+ needed.push("pinned");
+ } else if (event.type == "TabUnpinned") {
+ needed.push("pinned");
+ } else if (event.type == "TabBrowserInserted") {
+ // This may be an adopted tab. Bail early to avoid asking tabManager
+ // about the tab before we run the adoption logic in ext-browser.js.
+ if (event.detail.insertedOnTabCreation) {
+ return;
+ }
+ needed.push("discarded");
+ } else if (event.type == "TabBrowserDiscarded") {
+ needed.push("discarded");
+ } else if (event.type == "TabShow") {
+ needed.push("hidden");
+ } else if (event.type == "TabHide") {
+ needed.push("hidden");
+ }
+
+ let tab = tabManager.getWrapper(event.originalTarget);
+
+ let changeInfo = {};
+ for (let prop of needed) {
+ changeInfo[prop] = tab[prop];
+ }
+
+ fireForTab(tab, changeInfo, event.originalTarget);
+ };
+
+ let statusListener = ({ browser, status, url }) => {
+ let { gBrowser } = browser.ownerGlobal;
+ let tabElem = gBrowser.getTabForBrowser(browser);
+ if (tabElem) {
+ if (!context.canAccessWindow(tabElem.ownerGlobal)) {
+ return;
+ }
+
+ let changed = { status };
+ if (url) {
+ changed.url = url;
+ }
+
+ fireForTab(tabManager.wrapTab(tabElem), changed, tabElem);
+ }
+ };
+
+ let isArticleChangeListener = (messageName, message) => {
+ let { gBrowser } = message.target.ownerGlobal;
+ let nativeTab = gBrowser.getTabForBrowser(message.target);
+
+ if (nativeTab && context.canAccessWindow(nativeTab.ownerGlobal)) {
+ let tab = tabManager.getWrapper(nativeTab);
+ fireForTab(tab, { isArticle: message.data.isArticle }, nativeTab);
+ }
+ };
+
+ let listeners = new Map();
+ if (filter.properties.has("status")) {
+ listeners.set("status", statusListener);
+ }
+ if (needsModified) {
+ listeners.set("TabAttrModified", listener);
+ }
+ if (filter.properties.has("pinned")) {
+ listeners.set("TabPinned", listener);
+ listeners.set("TabUnpinned", listener);
+ }
+ if (filter.properties.has("discarded")) {
+ listeners.set("TabBrowserInserted", listener);
+ listeners.set("TabBrowserDiscarded", listener);
+ }
+ if (filter.properties.has("hidden")) {
+ listeners.set("TabShow", listener);
+ listeners.set("TabHide", listener);
+ }
+
+ for (let [name, listener] of listeners) {
+ windowTracker.addListener(name, listener);
+ }
+
+ if (filter.properties.has("isArticle")) {
+ tabTracker.on("tab-isarticle", isArticleChangeListener);
+ }
+
+ return () => {
+ for (let [name, listener] of listeners) {
+ windowTracker.removeListener(name, listener);
+ }
+
+ if (filter.properties.has("isArticle")) {
+ tabTracker.off("tab-isarticle", isArticleChangeListener);
+ }
+ };
+ };
+
+ super({
+ context,
+ name: "tabs.onUpdated",
+ register,
+ });
+ }
+}
+
+function TabEventManager({ context, name, event, listener }) {
+ let register = fire => {
+ let listener2 = (eventName, eventData, ...args) => {
+ if (!("isPrivate" in eventData)) {
+ throw new Error(
+ `isPrivate property missing in tabTracker event "${eventName}"`
+ );
+ }
+
+ if (eventData.isPrivate && !context.privateBrowsingAllowed) {
+ return;
+ }
+
+ listener(fire, eventData, ...args);
+ };
+
+ tabTracker.on(event, listener2);
+ return () => {
+ tabTracker.off(event, listener2);
+ };
+ };
+
+ return new EventManager({ context, name, register }).api();
+}
+
+this.tabs = class extends ExtensionAPI {
+ static onUpdate(id, manifest) {
+ if (!manifest.permissions || !manifest.permissions.includes("tabHide")) {
+ showHiddenTabs(id);
+ }
+ }
+
+ static onDisable(id) {
+ showHiddenTabs(id);
+ tabHidePopup.clearConfirmation(id);
+ }
+
+ static onUninstall(id) {
+ tabHidePopup.clearConfirmation(id);
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+
+ let { tabManager, windowManager } = extension;
+
+ function getTabOrActive(tabId) {
+ let tab =
+ tabId !== null ? tabTracker.getTab(tabId) : tabTracker.activeTab;
+ if (!context.canAccessWindow(tab.ownerGlobal)) {
+ throw new ExtensionError(
+ tabId === null
+ ? "Cannot access activeTab"
+ : `Invalid tab ID: ${tabId}`
+ );
+ }
+ return tab;
+ }
+
+ function getNativeTabsFromIDArray(tabIds) {
+ if (!Array.isArray(tabIds)) {
+ tabIds = [tabIds];
+ }
+ return tabIds.map(tabId => {
+ let tab = tabTracker.getTab(tabId);
+ if (!context.canAccessWindow(tab.ownerGlobal)) {
+ throw new ExtensionError(`Invalid tab ID: ${tabId}`);
+ }
+ return tab;
+ });
+ }
+
+ async function promiseTabWhenReady(tabId) {
+ let tab;
+ if (tabId !== null) {
+ tab = tabManager.get(tabId);
+ } else {
+ tab = tabManager.getWrapper(tabTracker.activeTab);
+ }
+ if (!tab) {
+ throw new ExtensionError(
+ tabId == null ? "Cannot access activeTab" : `Invalid tab ID: ${tabId}`
+ );
+ }
+
+ await tabListener.awaitTabReady(tab.nativeTab);
+
+ return tab;
+ }
+
+ let self = {
+ tabs: {
+ onActivated: TabEventManager({
+ context,
+ name: "tabs.onActivated",
+ event: "tab-activated",
+ listener: (fire, event) => {
+ let {
+ tabId,
+ windowId,
+ previousTabId,
+ previousTabIsPrivate,
+ } = event;
+ if (previousTabIsPrivate && !context.privateBrowsingAllowed) {
+ previousTabId = undefined;
+ }
+ fire.async({ tabId, previousTabId, windowId });
+ },
+ }),
+
+ onCreated: TabEventManager({
+ context,
+ name: "tabs.onCreated",
+ event: "tab-created",
+ listener: (fire, event) => {
+ fire.async(
+ tabManager.convert(event.nativeTab, event.currentTabSize)
+ );
+ },
+ }),
+
+ onHighlighted: TabEventManager({
+ context,
+ name: "tabs.onHighlighted",
+ event: "tabs-highlighted",
+ listener: (fire, event) => {
+ fire.async({ tabIds: event.tabIds, windowId: event.windowId });
+ },
+ }),
+
+ onAttached: TabEventManager({
+ context,
+ name: "tabs.onAttached",
+ event: "tab-attached",
+ listener: (fire, event) => {
+ fire.async(event.tabId, {
+ newWindowId: event.newWindowId,
+ newPosition: event.newPosition,
+ });
+ },
+ }),
+
+ onDetached: TabEventManager({
+ context,
+ name: "tabs.onDetached",
+ event: "tab-detached",
+ listener: (fire, event) => {
+ fire.async(event.tabId, {
+ oldWindowId: event.oldWindowId,
+ oldPosition: event.oldPosition,
+ });
+ },
+ }),
+
+ onRemoved: TabEventManager({
+ context,
+ name: "tabs.onRemoved",
+ event: "tab-removed",
+ listener: (fire, event) => {
+ fire.async(event.tabId, {
+ windowId: event.windowId,
+ isWindowClosing: event.isWindowClosing,
+ });
+ },
+ }),
+
+ onReplaced: new EventManager({
+ context,
+ name: "tabs.onReplaced",
+ register: fire => {
+ return () => {};
+ },
+ }).api(),
+
+ onMoved: new EventManager({
+ context,
+ name: "tabs.onMoved",
+ register: fire => {
+ let moveListener = event => {
+ let nativeTab = event.originalTarget;
+ if (context.canAccessWindow(nativeTab.ownerGlobal)) {
+ fire.async(tabTracker.getId(nativeTab), {
+ windowId: windowTracker.getId(nativeTab.ownerGlobal),
+ fromIndex: event.detail,
+ toIndex: nativeTab._tPos,
+ });
+ }
+ };
+
+ windowTracker.addListener("TabMove", moveListener);
+ return () => {
+ windowTracker.removeListener("TabMove", moveListener);
+ };
+ },
+ }).api(),
+
+ onUpdated: new TabsUpdateFilterEventManager(context).api(),
+
+ create(createProperties) {
+ return new Promise((resolve, reject) => {
+ let window =
+ createProperties.windowId !== null
+ ? windowTracker.getWindow(createProperties.windowId, context)
+ : windowTracker.getTopNormalWindow(context);
+ if (!window || !context.canAccessWindow(window)) {
+ throw new Error(
+ "Not allowed to create tabs on the target window"
+ );
+ }
+ let { gBrowserInit } = window;
+ if (!gBrowserInit || !gBrowserInit.delayedStartupFinished) {
+ let obs = (finishedWindow, topic, data) => {
+ if (finishedWindow != window) {
+ return;
+ }
+ Services.obs.removeObserver(
+ obs,
+ "browser-delayed-startup-finished"
+ );
+ resolve(window);
+ };
+ Services.obs.addObserver(obs, "browser-delayed-startup-finished");
+ } else {
+ resolve(window);
+ }
+ }).then(window => {
+ let url;
+ let principal = context.principal;
+
+ let options = {};
+ if (createProperties.cookieStoreId) {
+ // May throw if validation fails.
+ options.userContextId = getUserContextIdForCookieStoreId(
+ extension,
+ createProperties.cookieStoreId,
+ PrivateBrowsingUtils.isBrowserPrivate(window.gBrowser)
+ );
+ }
+
+ if (createProperties.url !== null) {
+ url = context.uri.resolve(createProperties.url);
+
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+
+ if (createProperties.openInReaderMode) {
+ url = `about:reader?url=${encodeURIComponent(url)}`;
+ }
+ } else {
+ url = window.BROWSER_NEW_TAB_URL;
+ }
+ // Only set allowInheritPrincipal on discardable urls as it
+ // will override creating a lazy browser. Setting triggeringPrincipal
+ // will ensure other cases are handled, but setting it may prevent
+ // creating about and data urls.
+ let discardable = url && !url.startsWith("about:");
+ if (!discardable) {
+ // Make sure things like about:blank and data: URIs never inherit,
+ // and instead always get a NullPrincipal.
+ options.allowInheritPrincipal = false;
+ // Falling back to content here as about: requires it, however is safe.
+ principal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(url),
+ {
+ userContextId: options.userContextId,
+ privateBrowsingId: PrivateBrowsingUtils.isBrowserPrivate(
+ window.gBrowser
+ )
+ ? 1
+ : 0,
+ }
+ );
+ } else {
+ options.allowInheritPrincipal = true;
+ options.triggeringPrincipal = context.principal;
+ }
+
+ tabListener.initTabReady();
+ const currentTab = window.gBrowser.selectedTab;
+ const { frameLoader } = currentTab.linkedBrowser;
+ const currentTabSize = {
+ width: frameLoader.lazyWidth,
+ height: frameLoader.lazyHeight,
+ };
+
+ if (createProperties.openerTabId !== null) {
+ options.ownerTab = tabTracker.getTab(
+ createProperties.openerTabId
+ );
+ options.openerBrowser = options.ownerTab.linkedBrowser;
+ if (options.ownerTab.ownerGlobal !== window) {
+ return Promise.reject({
+ message:
+ "Opener tab must be in the same window as the tab being created",
+ });
+ }
+ }
+
+ // Simple properties
+ const properties = ["index", "pinned"];
+ for (let prop of properties) {
+ if (createProperties[prop] != null) {
+ options[prop] = createProperties[prop];
+ }
+ }
+
+ let active =
+ createProperties.active !== null
+ ? createProperties.active
+ : !createProperties.discarded;
+ if (createProperties.discarded) {
+ if (active) {
+ return Promise.reject({
+ message: `Active tabs cannot be created and discarded.`,
+ });
+ }
+ if (createProperties.pinned) {
+ return Promise.reject({
+ message: `Pinned tabs cannot be created and discarded.`,
+ });
+ }
+ if (!discardable) {
+ return Promise.reject({
+ message: `Cannot create a discarded new tab or "about" urls.`,
+ });
+ }
+ options.createLazyBrowser = true;
+ options.lazyTabTitle = createProperties.title;
+ } else if (createProperties.title) {
+ return Promise.reject({
+ message: `Title may only be set for discarded tabs.`,
+ });
+ }
+
+ options.triggeringPrincipal = principal;
+ let nativeTab = window.gBrowser.addTab(url, options);
+
+ if (active) {
+ window.gBrowser.selectedTab = nativeTab;
+ if (!createProperties.url) {
+ window.gURLBar.select();
+ }
+ }
+
+ if (
+ createProperties.url &&
+ createProperties.url !== window.BROWSER_NEW_TAB_URL
+ ) {
+ // We can't wait for a location change event for about:newtab,
+ // since it may be pre-rendered, in which case its initial
+ // location change event has already fired.
+
+ // Mark the tab as initializing, so that operations like
+ // `executeScript` wait until the requested URL is loaded in
+ // the tab before dispatching messages to the inner window
+ // that contains the URL we're attempting to load.
+ tabListener.initializingTabs.add(nativeTab);
+ }
+
+ return tabManager.convert(nativeTab, currentTabSize);
+ });
+ },
+
+ async remove(tabIds) {
+ let nativeTabs = getNativeTabsFromIDArray(tabIds);
+
+ if (nativeTabs.length === 1) {
+ nativeTabs[0].ownerGlobal.gBrowser.removeTab(nativeTabs[0]);
+ return;
+ }
+
+ // Or for multiple tabs, first group them by window
+ let windowTabMap = new DefaultMap(() => []);
+ for (let nativeTab of nativeTabs) {
+ windowTabMap.get(nativeTab.ownerGlobal).push(nativeTab);
+ }
+
+ // Then make one call to removeTabs() for each window, to keep the
+ // count accurate for SessionStore.getLastClosedTabCount().
+ // Note: always pass options to disable animation and the warning
+ // dialogue box, so that way all tabs are actually closed when the
+ // browser.tabs.remove() promise resolves
+ for (let [eachWindow, tabsToClose] of windowTabMap.entries()) {
+ eachWindow.gBrowser.removeTabs(tabsToClose, {
+ animate: false,
+ suppressWarnAboutClosingWindow: true,
+ });
+ }
+ },
+
+ async discard(tabIds) {
+ for (let nativeTab of getNativeTabsFromIDArray(tabIds)) {
+ nativeTab.ownerGlobal.gBrowser.discardBrowser(nativeTab);
+ }
+ },
+
+ async update(tabId, updateProperties) {
+ let nativeTab = getTabOrActive(tabId);
+
+ let tabbrowser = nativeTab.ownerGlobal.gBrowser;
+
+ if (updateProperties.url !== null) {
+ let url = context.uri.resolve(updateProperties.url);
+
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+
+ let options = {
+ flags: updateProperties.loadReplace
+ ? Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY
+ : Ci.nsIWebNavigation.LOAD_FLAGS_NONE,
+ triggeringPrincipal: context.principal,
+ };
+ nativeTab.linkedBrowser.loadURI(url, options);
+ }
+
+ if (updateProperties.active) {
+ tabbrowser.selectedTab = nativeTab;
+ }
+ if (updateProperties.highlighted !== null) {
+ if (updateProperties.highlighted) {
+ if (!nativeTab.selected && !nativeTab.multiselected) {
+ tabbrowser.addToMultiSelectedTabs(nativeTab);
+ // Select the highlighted tab unless active:false is provided.
+ // Note that Chrome selects it even in that case.
+ if (updateProperties.active !== false) {
+ tabbrowser.lockClearMultiSelectionOnce();
+ tabbrowser.selectedTab = nativeTab;
+ }
+ }
+ } else {
+ tabbrowser.removeFromMultiSelectedTabs(nativeTab);
+ }
+ }
+ if (updateProperties.muted !== null) {
+ if (nativeTab.muted != updateProperties.muted) {
+ nativeTab.toggleMuteAudio(extension.id);
+ }
+ }
+ if (updateProperties.pinned !== null) {
+ if (updateProperties.pinned) {
+ tabbrowser.pinTab(nativeTab);
+ } else {
+ tabbrowser.unpinTab(nativeTab);
+ }
+ }
+ if (updateProperties.openerTabId !== null) {
+ let opener = tabTracker.getTab(updateProperties.openerTabId);
+ if (opener.ownerDocument !== nativeTab.ownerDocument) {
+ return Promise.reject({
+ message:
+ "Opener tab must be in the same window as the tab being updated",
+ });
+ }
+ tabTracker.setOpener(nativeTab, opener);
+ }
+ if (updateProperties.successorTabId !== null) {
+ let successor = null;
+ if (updateProperties.successorTabId !== TAB_ID_NONE) {
+ successor = tabTracker.getTab(
+ updateProperties.successorTabId,
+ null
+ );
+ if (!successor) {
+ throw new ExtensionError("Invalid successorTabId");
+ }
+ // This also ensures "privateness" matches.
+ if (successor.ownerDocument !== nativeTab.ownerDocument) {
+ throw new ExtensionError(
+ "Successor tab must be in the same window as the tab being updated"
+ );
+ }
+ }
+ tabbrowser.setSuccessor(nativeTab, successor);
+ }
+
+ return tabManager.convert(nativeTab);
+ },
+
+ async reload(tabId, reloadProperties) {
+ let nativeTab = getTabOrActive(tabId);
+
+ let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+ if (reloadProperties && reloadProperties.bypassCache) {
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ }
+ nativeTab.linkedBrowser.reloadWithFlags(flags);
+ },
+
+ async warmup(tabId) {
+ let nativeTab = tabTracker.getTab(tabId);
+ let tabbrowser = nativeTab.ownerGlobal.gBrowser;
+ tabbrowser.warmupTab(nativeTab);
+ },
+
+ async get(tabId) {
+ return tabManager.get(tabId).convert();
+ },
+
+ getCurrent() {
+ let tabData;
+ if (context.tabId) {
+ tabData = tabManager.get(context.tabId).convert();
+ }
+ return Promise.resolve(tabData);
+ },
+
+ async query(queryInfo) {
+ return Array.from(tabManager.query(queryInfo, context), tab =>
+ tab.convert()
+ );
+ },
+
+ async captureTab(tabId, options) {
+ let nativeTab = getTabOrActive(tabId);
+ await tabListener.awaitTabReady(nativeTab);
+
+ let browser = nativeTab.linkedBrowser;
+ let window = browser.ownerGlobal;
+ let zoom = window.ZoomManager.getZoomForBrowser(browser);
+
+ let tab = tabManager.wrapTab(nativeTab);
+ return tab.capture(context, zoom, options);
+ },
+
+ async captureVisibleTab(windowId, options) {
+ let window =
+ windowId == null
+ ? windowTracker.getTopWindow(context)
+ : windowTracker.getWindow(windowId, context);
+
+ let tab = tabManager.wrapTab(window.gBrowser.selectedTab);
+ await tabListener.awaitTabReady(tab.nativeTab);
+
+ let zoom = window.ZoomManager.getZoomForBrowser(
+ tab.nativeTab.linkedBrowser
+ );
+ return tab.capture(context, zoom, options);
+ },
+
+ async detectLanguage(tabId) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.sendMessage(context, "Extension:DetectLanguage");
+ },
+
+ async executeScript(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.executeScript(context, details);
+ },
+
+ async insertCSS(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.insertCSS(context, details);
+ },
+
+ async removeCSS(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.removeCSS(context, details);
+ },
+
+ async move(tabIds, moveProperties) {
+ let tabsMoved = [];
+ if (!Array.isArray(tabIds)) {
+ tabIds = [tabIds];
+ }
+
+ let destinationWindow = null;
+ if (moveProperties.windowId !== null) {
+ destinationWindow = windowTracker.getWindow(
+ moveProperties.windowId,
+ context
+ );
+ // Fail on an invalid window.
+ if (!destinationWindow) {
+ return Promise.reject({
+ message: `Invalid window ID: ${moveProperties.windowId}`,
+ });
+ }
+ }
+
+ /*
+ Indexes are maintained on a per window basis so that a call to
+ move([tabA, tabB], {index: 0})
+ -> tabA to 0, tabB to 1 if tabA and tabB are in the same window
+ move([tabA, tabB], {index: 0})
+ -> tabA to 0, tabB to 0 if tabA and tabB are in different windows
+ */
+ let indexMap = new Map();
+ let lastInsertion = new Map();
+
+ for (let nativeTab of getNativeTabsFromIDArray(tabIds)) {
+ // If the window is not specified, use the window from the tab.
+ let window = destinationWindow || nativeTab.ownerGlobal;
+ let gBrowser = window.gBrowser;
+
+ // If we are not moving the tab to a different window, and the window
+ // only has one tab, do nothing.
+ if (nativeTab.ownerGlobal == window && gBrowser.tabs.length === 1) {
+ continue;
+ }
+ // If moving between windows, be sure privacy matches. While gBrowser
+ // prevents this, we want to silently ignore it.
+ if (
+ nativeTab.ownerGlobal != window &&
+ PrivateBrowsingUtils.isBrowserPrivate(window.gBrowser) !=
+ PrivateBrowsingUtils.isBrowserPrivate(
+ nativeTab.ownerGlobal.gBrowser
+ )
+ ) {
+ continue;
+ }
+
+ let insertionPoint = indexMap.get(window) || moveProperties.index;
+ // If the index is -1 it should go to the end of the tabs.
+ if (insertionPoint == -1) {
+ insertionPoint = gBrowser.tabs.length;
+ }
+
+ // We can only move pinned tabs to a point within, or just after,
+ // the current set of pinned tabs. Unpinned tabs, likewise, can only
+ // be moved to a position after the current set of pinned tabs.
+ // Attempts to move a tab to an illegal position are ignored.
+ let numPinned = gBrowser._numPinnedTabs;
+ let ok = nativeTab.pinned
+ ? insertionPoint <= numPinned
+ : insertionPoint >= numPinned;
+ if (!ok) {
+ continue;
+ }
+
+ // If this is not the first tab to be inserted into this window and
+ // the insertion point is the same as the last insertion and
+ // the tab is further to the right than the current insertion point
+ // then you need to bump up the insertion point. See bug 1323311.
+ if (
+ lastInsertion.has(window) &&
+ lastInsertion.get(window) === insertionPoint &&
+ nativeTab._tPos > insertionPoint
+ ) {
+ insertionPoint++;
+ indexMap.set(window, insertionPoint);
+ }
+
+ if (nativeTab.ownerGlobal != window) {
+ // If the window we are moving the tab in is different, then move the tab
+ // to the new window.
+ nativeTab = gBrowser.adoptTab(nativeTab, insertionPoint, false);
+ } else {
+ // If the window we are moving is the same, just move the tab.
+ gBrowser.moveTabTo(nativeTab, insertionPoint);
+ }
+ lastInsertion.set(window, nativeTab._tPos);
+ tabsMoved.push(nativeTab);
+ }
+
+ return tabsMoved.map(nativeTab => tabManager.convert(nativeTab));
+ },
+
+ duplicate(tabId, duplicateProperties) {
+ const { active, index } = duplicateProperties || {};
+ const inBackground = active === undefined ? false : !active;
+
+ // Schema requires tab id.
+ let nativeTab = getTabOrActive(tabId);
+
+ let gBrowser = nativeTab.ownerGlobal.gBrowser;
+ let newTab = gBrowser.duplicateTab(nativeTab, true, {
+ inBackground,
+ index,
+ });
+
+ tabListener.blockTabUntilRestored(newTab);
+ return new Promise(resolve => {
+ // Use SSTabRestoring to ensure that the tab's URL is ready before
+ // resolving the promise.
+ newTab.addEventListener(
+ "SSTabRestoring",
+ () => resolve(tabManager.convert(newTab)),
+ { once: true }
+ );
+ });
+ },
+
+ getZoom(tabId) {
+ let nativeTab = getTabOrActive(tabId);
+
+ let { ZoomManager } = nativeTab.ownerGlobal;
+ let zoom = ZoomManager.getZoomForBrowser(nativeTab.linkedBrowser);
+
+ return Promise.resolve(zoom);
+ },
+
+ setZoom(tabId, zoom) {
+ let nativeTab = getTabOrActive(tabId);
+
+ let { FullZoom, ZoomManager } = nativeTab.ownerGlobal;
+
+ if (zoom === 0) {
+ // A value of zero means use the default zoom factor.
+ return FullZoom.reset(nativeTab.linkedBrowser);
+ } else if (zoom >= ZoomManager.MIN && zoom <= ZoomManager.MAX) {
+ FullZoom.setZoom(zoom, nativeTab.linkedBrowser);
+ } else {
+ return Promise.reject({
+ message: `Zoom value ${zoom} out of range (must be between ${ZoomManager.MIN} and ${ZoomManager.MAX})`,
+ });
+ }
+
+ return Promise.resolve();
+ },
+
+ _getZoomSettings(tabId) {
+ let nativeTab = getTabOrActive(tabId);
+
+ let { FullZoom } = nativeTab.ownerGlobal;
+
+ return {
+ mode: "automatic",
+ scope: FullZoom.siteSpecific ? "per-origin" : "per-tab",
+ defaultZoomFactor: 1,
+ };
+ },
+
+ getZoomSettings(tabId) {
+ return Promise.resolve(this._getZoomSettings(tabId));
+ },
+
+ setZoomSettings(tabId, settings) {
+ let nativeTab = getTabOrActive(tabId);
+
+ let currentSettings = this._getZoomSettings(
+ tabTracker.getId(nativeTab)
+ );
+
+ if (
+ !Object.keys(settings).every(
+ key => settings[key] === currentSettings[key]
+ )
+ ) {
+ return Promise.reject(
+ `Unsupported zoom settings: ${JSON.stringify(settings)}`
+ );
+ }
+ return Promise.resolve();
+ },
+
+ onZoomChange: new EventManager({
+ context,
+ name: "tabs.onZoomChange",
+ register: fire => {
+ let getZoomLevel = browser => {
+ let { ZoomManager } = browser.ownerGlobal;
+
+ return ZoomManager.getZoomForBrowser(browser);
+ };
+
+ // Stores the last known zoom level for each tab's browser.
+ // WeakMap[<browser> -> number]
+ let zoomLevels = new WeakMap();
+
+ // Store the zoom level for all existing tabs.
+ for (let window of windowTracker.browserWindows()) {
+ if (!context.canAccessWindow(window)) {
+ continue;
+ }
+ for (let nativeTab of window.gBrowser.tabs) {
+ let browser = nativeTab.linkedBrowser;
+ zoomLevels.set(browser, getZoomLevel(browser));
+ }
+ }
+
+ let tabCreated = (eventName, event) => {
+ let browser = event.nativeTab.linkedBrowser;
+ if (!event.isPrivate || context.privateBrowsingAllowed) {
+ zoomLevels.set(browser, getZoomLevel(browser));
+ }
+ };
+
+ let zoomListener = event => {
+ let browser = event.originalTarget;
+
+ // For non-remote browsers, this event is dispatched on the document
+ // rather than on the <browser>. But either way we have a node here.
+ if (browser.nodeType == browser.DOCUMENT_NODE) {
+ browser = browser.docShell.chromeEventHandler;
+ }
+
+ if (!context.canAccessWindow(browser.ownerGlobal)) {
+ return;
+ }
+
+ let { gBrowser } = browser.ownerGlobal;
+ let nativeTab = gBrowser.getTabForBrowser(browser);
+ if (!nativeTab) {
+ // We only care about zoom events in the top-level browser of a tab.
+ return;
+ }
+
+ let oldZoomFactor = zoomLevels.get(browser);
+ let newZoomFactor = getZoomLevel(browser);
+
+ if (oldZoomFactor != newZoomFactor) {
+ zoomLevels.set(browser, newZoomFactor);
+
+ let tabId = tabTracker.getId(nativeTab);
+ fire.async({
+ tabId,
+ oldZoomFactor,
+ newZoomFactor,
+ zoomSettings: self.tabs._getZoomSettings(tabId),
+ });
+ }
+ };
+
+ tabTracker.on("tab-attached", tabCreated);
+ tabTracker.on("tab-created", tabCreated);
+
+ windowTracker.addListener("FullZoomChange", zoomListener);
+ windowTracker.addListener("TextZoomChange", zoomListener);
+ return () => {
+ tabTracker.off("tab-attached", tabCreated);
+ tabTracker.off("tab-created", tabCreated);
+
+ windowTracker.removeListener("FullZoomChange", zoomListener);
+ windowTracker.removeListener("TextZoomChange", zoomListener);
+ };
+ },
+ }).api(),
+
+ print() {
+ let activeTab = getTabOrActive(null);
+ let { PrintUtils } = activeTab.ownerGlobal;
+ PrintUtils.startPrintWindow(
+ "ext_tabs_print",
+ activeTab.linkedBrowser.browsingContext
+ );
+ },
+
+ async printPreview() {
+ let activeTab = getTabOrActive(null);
+ let { PrintUtils, PrintPreviewListener } = activeTab.ownerGlobal;
+ try {
+ await PrintUtils.printPreview(
+ "ext_tabs_printpreview",
+ PrintPreviewListener
+ );
+ } catch (ex) {
+ return Promise.reject({ message: "Print preview failed" });
+ }
+ },
+
+ saveAsPDF(pageSettings) {
+ let activeTab = getTabOrActive(null);
+ let picker = Cc["@mozilla.org/filepicker;1"].createInstance(
+ Ci.nsIFilePicker
+ );
+ let title = strBundle.GetStringFromName(
+ "saveaspdf.saveasdialog.title"
+ );
+ let filename;
+ if (
+ pageSettings.toFileName !== null &&
+ pageSettings.toFileName != ""
+ ) {
+ filename = pageSettings.toFileName;
+ } else if (activeTab.linkedBrowser.contentTitle != "") {
+ filename = activeTab.linkedBrowser.contentTitle;
+ } else {
+ let url = new URL(activeTab.linkedBrowser.currentURI.spec);
+ let path = decodeURIComponent(url.pathname);
+ path = path.replace(/\/$/, "");
+ filename = path.split("/").pop();
+ if (filename == "") {
+ filename = url.hostname;
+ }
+ }
+ filename = DownloadPaths.sanitize(filename);
+
+ picker.init(activeTab.ownerGlobal, title, Ci.nsIFilePicker.modeSave);
+ picker.appendFilter("PDF", "*.pdf");
+ picker.defaultExtension = "pdf";
+ picker.defaultString = filename;
+
+ return new Promise(resolve => {
+ picker.open(function(retval) {
+ if (retval == 0 || retval == 2) {
+ // OK clicked (retval == 0) or replace confirmed (retval == 2)
+
+ // Workaround: When trying to replace an existing file that is open in another application (i.e. a locked file),
+ // the print progress listener is never called. This workaround ensures that a correct status is always returned.
+ try {
+ let fstream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ fstream.init(picker.file, 0x2a, 0o666, 0); // ioflags = write|create|truncate, file permissions = rw-rw-rw-
+ fstream.close();
+ } catch (e) {
+ resolve(retval == 0 ? "not_saved" : "not_replaced");
+ return;
+ }
+
+ let psService = Cc[
+ "@mozilla.org/gfx/printsettings-service;1"
+ ].getService(Ci.nsIPrintSettingsService);
+ let printSettings = psService.newPrintSettings;
+
+ printSettings.printerName = "";
+ printSettings.isInitializedFromPrinter = true;
+ printSettings.isInitializedFromPrefs = true;
+
+ printSettings.printToFile = true;
+ printSettings.toFileName = picker.file.path;
+
+ printSettings.printSilent = true;
+ printSettings.showPrintProgress = false;
+
+ printSettings.outputFormat =
+ Ci.nsIPrintSettings.kOutputFormatPDF;
+
+ if (pageSettings.paperSizeUnit !== null) {
+ printSettings.paperSizeUnit = pageSettings.paperSizeUnit;
+ }
+ if (pageSettings.paperWidth !== null) {
+ printSettings.paperWidth = pageSettings.paperWidth;
+ }
+ if (pageSettings.paperHeight !== null) {
+ printSettings.paperHeight = pageSettings.paperHeight;
+ }
+ if (pageSettings.orientation !== null) {
+ printSettings.orientation = pageSettings.orientation;
+ }
+ if (pageSettings.scaling !== null) {
+ printSettings.scaling = pageSettings.scaling;
+ }
+ if (pageSettings.shrinkToFit !== null) {
+ printSettings.shrinkToFit = pageSettings.shrinkToFit;
+ }
+ if (pageSettings.showBackgroundColors !== null) {
+ printSettings.printBGColors =
+ pageSettings.showBackgroundColors;
+ }
+ if (pageSettings.showBackgroundImages !== null) {
+ printSettings.printBGImages =
+ pageSettings.showBackgroundImages;
+ }
+ if (pageSettings.edgeLeft !== null) {
+ printSettings.edgeLeft = pageSettings.edgeLeft;
+ }
+ if (pageSettings.edgeRight !== null) {
+ printSettings.edgeRight = pageSettings.edgeRight;
+ }
+ if (pageSettings.edgeTop !== null) {
+ printSettings.edgeTop = pageSettings.edgeTop;
+ }
+ if (pageSettings.edgeBottom !== null) {
+ printSettings.edgeBottom = pageSettings.edgeBottom;
+ }
+ if (pageSettings.marginLeft !== null) {
+ printSettings.marginLeft = pageSettings.marginLeft;
+ }
+ if (pageSettings.marginRight !== null) {
+ printSettings.marginRight = pageSettings.marginRight;
+ }
+ if (pageSettings.marginTop !== null) {
+ printSettings.marginTop = pageSettings.marginTop;
+ }
+ if (pageSettings.marginBottom !== null) {
+ printSettings.marginBottom = pageSettings.marginBottom;
+ }
+ if (pageSettings.headerLeft !== null) {
+ printSettings.headerStrLeft = pageSettings.headerLeft;
+ }
+ if (pageSettings.headerCenter !== null) {
+ printSettings.headerStrCenter = pageSettings.headerCenter;
+ }
+ if (pageSettings.headerRight !== null) {
+ printSettings.headerStrRight = pageSettings.headerRight;
+ }
+ if (pageSettings.footerLeft !== null) {
+ printSettings.footerStrLeft = pageSettings.footerLeft;
+ }
+ if (pageSettings.footerCenter !== null) {
+ printSettings.footerStrCenter = pageSettings.footerCenter;
+ }
+ if (pageSettings.footerRight !== null) {
+ printSettings.footerStrRight = pageSettings.footerRight;
+ }
+
+ activeTab.linkedBrowser
+ .print(activeTab.linkedBrowser.outerWindowID, printSettings)
+ .then(() => resolve(retval == 0 ? "saved" : "replaced"))
+ .catch(() =>
+ resolve(retval == 0 ? "not_saved" : "not_replaced")
+ );
+ } else {
+ // Cancel clicked (retval == 1)
+ resolve("canceled");
+ }
+ });
+ });
+ },
+
+ async toggleReaderMode(tabId) {
+ let tab = await promiseTabWhenReady(tabId);
+ if (!tab.isInReaderMode && !tab.isArticle) {
+ throw new ExtensionError(
+ "The specified tab cannot be placed into reader mode."
+ );
+ }
+ let nativeTab = getTabOrActive(tabId);
+
+ nativeTab.linkedBrowser.sendMessageToActor(
+ "Reader:ToggleReaderMode",
+ {},
+ "AboutReader"
+ );
+ },
+
+ moveInSuccession(tabIds, tabId, options) {
+ const { insert, append } = options || {};
+ const tabIdSet = new Set(tabIds);
+ if (tabIdSet.size !== tabIds.length) {
+ throw new ExtensionError(
+ "IDs must not occur more than once in tabIds"
+ );
+ }
+ if ((append || insert) && tabIdSet.has(tabId)) {
+ throw new ExtensionError(
+ "Value of tabId must not occur in tabIds if append or insert is true"
+ );
+ }
+
+ const referenceTab = tabTracker.getTab(tabId, null);
+ let referenceWindow = referenceTab && referenceTab.ownerGlobal;
+ if (referenceWindow && !context.canAccessWindow(referenceWindow)) {
+ throw new ExtensionError(`Invalid tab ID: ${tabId}`);
+ }
+ let previousTab, lastSuccessor;
+ if (append) {
+ previousTab = referenceTab;
+ lastSuccessor =
+ (insert && referenceTab && referenceTab.successor) || null;
+ } else {
+ lastSuccessor = referenceTab;
+ }
+
+ let firstTab;
+ for (const tabId of tabIds) {
+ const tab = tabTracker.getTab(tabId, null);
+ if (tab === null) {
+ continue;
+ }
+ if (!context.canAccessWindow(tab.ownerGlobal)) {
+ throw new ExtensionError(`Invalid tab ID: ${tabId}`);
+ }
+ if (referenceWindow === null) {
+ referenceWindow = tab.ownerGlobal;
+ } else if (tab.ownerGlobal !== referenceWindow) {
+ continue;
+ }
+ referenceWindow.gBrowser.replaceInSuccession(tab, tab.successor);
+ if (append && tab === lastSuccessor) {
+ lastSuccessor = tab.successor;
+ }
+ if (previousTab) {
+ referenceWindow.gBrowser.setSuccessor(previousTab, tab);
+ } else {
+ firstTab = tab;
+ }
+ previousTab = tab;
+ }
+
+ if (previousTab) {
+ if (!append && insert && lastSuccessor !== null) {
+ referenceWindow.gBrowser.replaceInSuccession(
+ lastSuccessor,
+ firstTab
+ );
+ }
+ referenceWindow.gBrowser.setSuccessor(previousTab, lastSuccessor);
+ }
+ },
+
+ show(tabIds) {
+ for (let tab of getNativeTabsFromIDArray(tabIds)) {
+ if (tab.ownerGlobal) {
+ tab.ownerGlobal.gBrowser.showTab(tab);
+ }
+ }
+ },
+
+ hide(tabIds) {
+ let hidden = [];
+ for (let tab of getNativeTabsFromIDArray(tabIds)) {
+ if (tab.ownerGlobal && !tab.hidden) {
+ tab.ownerGlobal.gBrowser.hideTab(tab, extension.id);
+ if (tab.hidden) {
+ hidden.push(tabTracker.getId(tab));
+ }
+ }
+ }
+ if (hidden.length) {
+ let win = Services.wm.getMostRecentWindow("navigator:browser");
+ tabHidePopup.open(win, extension.id);
+ }
+ return hidden;
+ },
+
+ highlight(highlightInfo) {
+ let { windowId, tabs, populate } = highlightInfo;
+ if (windowId == null) {
+ windowId = Window.WINDOW_ID_CURRENT;
+ }
+ let window = windowTracker.getWindow(windowId, context);
+ if (!context.canAccessWindow(window)) {
+ throw new ExtensionError(`Invalid window ID: ${windowId}`);
+ }
+
+ if (!Array.isArray(tabs)) {
+ tabs = [tabs];
+ } else if (!tabs.length) {
+ throw new ExtensionError("No highlighted tab.");
+ }
+ window.gBrowser.selectedTabs = tabs.map(tabIndex => {
+ let tab = window.gBrowser.tabs[tabIndex];
+ if (!tab) {
+ throw new ExtensionError("No tab at index: " + tabIndex);
+ }
+ return tab;
+ });
+ return windowManager.convert(window, { populate });
+ },
+
+ goForward(tabId) {
+ let nativeTab = getTabOrActive(tabId);
+ nativeTab.linkedBrowser.goForward();
+ },
+
+ goBack(tabId) {
+ let nativeTab = getTabOrActive(tabId);
+ nativeTab.linkedBrowser.goBack();
+ },
+ },
+ };
+ return self;
+ }
+};
diff --git a/browser/components/extensions/parent/ext-topSites.js b/browser/components/extensions/parent/ext-topSites.js
new file mode 100644
index 0000000000..815e7afece
--- /dev/null
+++ b/browser/components/extensions/parent/ext-topSites.js
@@ -0,0 +1,116 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AboutNewTab: "resource:///modules/AboutNewTab.jsm",
+ NewTabUtils: "resource://gre/modules/NewTabUtils.jsm",
+ shortURL: "resource://activity-stream/lib/ShortURL.jsm",
+ getSearchProvider: "resource://activity-stream/lib/SearchShortcuts.jsm",
+});
+
+const SHORTCUTS_PREF =
+ "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts";
+const TOPSITES_FEED_PREF =
+ "browser.newtabpage.activity-stream.feeds.system.topsites";
+
+this.topSites = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ topSites: {
+ get: async function(options) {
+ // We fallback to newtab = false behavior if the user disabled their
+ // Top Sites feed.
+ let getNewtabSites =
+ options.newtab &&
+ Services.prefs.getBoolPref(TOPSITES_FEED_PREF, false);
+ let links = getNewtabSites
+ ? AboutNewTab.getTopSites()
+ : await NewTabUtils.activityStreamLinks.getTopSites({
+ ignoreBlocked: options.includeBlocked,
+ onePerDomain: options.onePerDomain,
+ numItems: options.limit,
+ includeFavicon: options.includeFavicon,
+ });
+
+ if (options.includePinned && !getNewtabSites) {
+ let pinnedLinks = NewTabUtils.pinnedLinks.links;
+ if (options.includeFavicon) {
+ pinnedLinks = NewTabUtils.activityStreamProvider._faviconBytesToDataURI(
+ await NewTabUtils.activityStreamProvider._addFavicons(
+ pinnedLinks
+ )
+ );
+ }
+ pinnedLinks.forEach((pinnedLink, index) => {
+ if (
+ pinnedLink &&
+ (!pinnedLink.searchTopSite || options.includeSearchShortcuts)
+ ) {
+ // Remove any dupes from history.
+ links = links.filter(
+ link =>
+ link.url != pinnedLink.url &&
+ (!options.onePerDomain ||
+ NewTabUtils.extractSite(link.url) !=
+ pinnedLink.baseDomain)
+ );
+ links.splice(index, 0, pinnedLink);
+ }
+ });
+ }
+
+ // Convert links to search shortcuts, if necessary.
+ if (
+ options.includeSearchShortcuts &&
+ Services.prefs.getBoolPref(SHORTCUTS_PREF, false) &&
+ !getNewtabSites
+ ) {
+ // Pinned shortcuts are already returned as searchTopSite links,
+ // with a proper label and url. But certain non-pinned links may
+ // also be promoted to search shortcuts; here we convert them.
+ links = links.map(link => {
+ let searchProvider = getSearchProvider(shortURL(link));
+ if (searchProvider) {
+ link.searchTopSite = true;
+ link.label = searchProvider.keyword;
+ link.url = searchProvider.url;
+ }
+ return link;
+ });
+ }
+
+ // Because we may have added links, we must crop again.
+ if (typeof options.limit == "number") {
+ links = links.slice(0, options.limit);
+ }
+
+ const makeDataURI = url => url && ExtensionUtils.makeDataURI(url);
+
+ return Promise.all(
+ links.map(async link => ({
+ type: link.searchTopSite ? "search" : "url",
+ url: link.url,
+ // The newtab page allows the user to set custom site titles, which
+ // are stored in `label`, so prefer it. Search top sites currently
+ // don't have titles but `hostname` instead.
+ title: link.label || link.title || link.hostname || "",
+ // Default top sites don't have a favicon property. Instead they
+ // have tippyTopIcon, a 96x96pt image used on the newtab page.
+ // We'll use it as the favicon for now, but ideally default top
+ // sites would have real favicons. Non-default top sites (i.e.,
+ // those from the user's history) will have favicons.
+ favicon: options.includeFavicon
+ ? link.favicon || (await makeDataURI(link.tippyTopIcon)) || null
+ : null,
+ }))
+ );
+ },
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-url-overrides.js b/browser/components/extensions/parent/ext-url-overrides.js
new file mode 100644
index 0000000000..af2c367e1f
--- /dev/null
+++ b/browser/components/extensions/parent/ext-url-overrides.js
@@ -0,0 +1,210 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionControlledPopup",
+ "resource:///modules/ExtensionControlledPopup.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionSettingsStore",
+ "resource://gre/modules/ExtensionSettingsStore.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "AboutNewTab",
+ "resource:///modules/AboutNewTab.jsm"
+);
+
+const STORE_TYPE = "url_overrides";
+const NEW_TAB_SETTING_NAME = "newTabURL";
+const NEW_TAB_CONFIRMED_TYPE = "newTabNotification";
+const NEW_TAB_PRIVATE_ALLOWED = "browser.newtab.privateAllowed";
+const NEW_TAB_EXTENSION_CONTROLLED = "browser.newtab.extensionControlled";
+
+XPCOMUtils.defineLazyGetter(this, "newTabPopup", () => {
+ return new ExtensionControlledPopup({
+ confirmedType: NEW_TAB_CONFIRMED_TYPE,
+ observerTopic: "browser-open-newtab-start",
+ popupnotificationId: "extension-new-tab-notification",
+ settingType: STORE_TYPE,
+ settingKey: NEW_TAB_SETTING_NAME,
+ descriptionId: "extension-new-tab-notification-description",
+ descriptionMessageId: "newTabControlled.message2",
+ learnMoreMessageId: "newTabControlled.learnMore",
+ learnMoreLink: "extension-home",
+ preferencesLocation: "home-newtabOverride",
+ preferencesEntrypoint: "addon-manage-newtab-override",
+ onObserverAdded() {
+ AboutNewTab.willNotifyUser = true;
+ },
+ onObserverRemoved() {
+ AboutNewTab.willNotifyUser = false;
+ },
+ async beforeDisableAddon(popup, win) {
+ // ExtensionControlledPopup will disable the add-on once this function completes.
+ // Disabling an add-on should remove the tabs that it has open, but we want
+ // to open the new New Tab in this tab (which might get closed).
+ // 1. Replace the tab's URL with about:blank
+ // 2. Return control to ExtensionControlledPopup once about:blank has loaded
+ // 3. Once the New Tab URL has changed, replace the tab's URL with the new New Tab URL
+ let gBrowser = win.gBrowser;
+ let tab = gBrowser.selectedTab;
+ await replaceUrlInTab(gBrowser, tab, "about:blank");
+ Services.obs.addObserver(
+ {
+ async observe() {
+ await replaceUrlInTab(gBrowser, tab, AboutNewTab.newTabURL);
+ // Now that the New Tab is loading, try to open the popup again. This
+ // will only open the popup if a new extension is controlling the New Tab.
+ popup.open();
+ Services.obs.removeObserver(this, "newtab-url-changed");
+ },
+ },
+ "newtab-url-changed"
+ );
+ },
+ });
+});
+
+function setNewTabURL(extensionId, url) {
+ if (extensionId) {
+ newTabPopup.addObserver(extensionId);
+ let policy = ExtensionParent.WebExtensionPolicy.getByID(extensionId);
+ Services.prefs.setBoolPref(
+ NEW_TAB_PRIVATE_ALLOWED,
+ policy && policy.privateBrowsingAllowed
+ );
+ Services.prefs.setBoolPref(NEW_TAB_EXTENSION_CONTROLLED, true);
+ } else {
+ newTabPopup.removeObserver();
+ Services.prefs.clearUserPref(NEW_TAB_PRIVATE_ALLOWED);
+ Services.prefs.clearUserPref(NEW_TAB_EXTENSION_CONTROLLED);
+ }
+ if (url) {
+ AboutNewTab.newTabURL = url;
+ }
+}
+
+// eslint-disable-next-line mozilla/balanced-listeners
+ExtensionParent.apiManager.on(
+ "extension-setting-changed",
+ async (eventName, setting) => {
+ let extensionId, url;
+ if (setting.type === STORE_TYPE && setting.key === NEW_TAB_SETTING_NAME) {
+ // If the actual setting has changed in some way, we will have
+ // setting.item which is what the setting has been changed to. If
+ // we have an item, we always want to update the newTabUrl values.
+ let { item } = setting;
+ if (item) {
+ // If we're resetting, id will be undefined.
+ extensionId = item.id;
+ url = item.value || item.initialValue;
+ setNewTabURL(extensionId, url);
+ }
+ }
+ }
+);
+
+async function processSettings(action, id) {
+ await ExtensionSettingsStore.initialize();
+ if (ExtensionSettingsStore.hasSetting(id, STORE_TYPE, NEW_TAB_SETTING_NAME)) {
+ ExtensionSettingsStore[action](id, STORE_TYPE, NEW_TAB_SETTING_NAME);
+ }
+}
+
+this.urlOverrides = class extends ExtensionAPI {
+ static async onDisable(id) {
+ newTabPopup.clearConfirmation(id);
+ await processSettings("disable", id);
+ }
+
+ static async onEnabling(id) {
+ await processSettings("enable", id);
+ }
+
+ static async onUninstall(id) {
+ // TODO: This can be removed once bug 1438364 is fixed and all data is cleaned up.
+ newTabPopup.clearConfirmation(id);
+ await processSettings("removeSetting", id);
+ }
+
+ static async onUpdate(id, manifest) {
+ if (
+ !manifest.chrome_url_overrides ||
+ !manifest.chrome_url_overrides.newtab
+ ) {
+ await ExtensionSettingsStore.initialize();
+ if (
+ ExtensionSettingsStore.hasSetting(id, STORE_TYPE, NEW_TAB_SETTING_NAME)
+ ) {
+ ExtensionSettingsStore.removeSetting(
+ id,
+ STORE_TYPE,
+ NEW_TAB_SETTING_NAME
+ );
+ }
+ }
+ }
+
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+ let { manifest } = extension;
+
+ if (manifest.chrome_url_overrides.newtab) {
+ let url = extension.baseURI.resolve(manifest.chrome_url_overrides.newtab);
+
+ await ExtensionSettingsStore.initialize();
+ let item = await ExtensionSettingsStore.addSetting(
+ extension.id,
+ STORE_TYPE,
+ NEW_TAB_SETTING_NAME,
+ url,
+ () => AboutNewTab.newTabURL
+ );
+
+ // Set the newTabURL to the current value of the setting.
+ if (item) {
+ setNewTabURL(item.id, item.value || item.initialValue);
+ }
+
+ // We need to monitor permission change and update the preferences.
+ // eslint-disable-next-line mozilla/balanced-listeners
+ extension.on("add-permissions", async (ignoreEvent, permissions) => {
+ if (
+ permissions.permissions.includes("internal:privateBrowsingAllowed")
+ ) {
+ let item = await ExtensionSettingsStore.getSetting(
+ STORE_TYPE,
+ NEW_TAB_SETTING_NAME
+ );
+ if (item && item.id == extension.id) {
+ Services.prefs.setBoolPref(NEW_TAB_PRIVATE_ALLOWED, true);
+ }
+ }
+ });
+ // eslint-disable-next-line mozilla/balanced-listeners
+ extension.on("remove-permissions", async (ignoreEvent, permissions) => {
+ if (
+ permissions.permissions.includes("internal:privateBrowsingAllowed")
+ ) {
+ let item = await ExtensionSettingsStore.getSetting(
+ STORE_TYPE,
+ NEW_TAB_SETTING_NAME
+ );
+ if (item && item.id == extension.id) {
+ Services.prefs.setBoolPref(NEW_TAB_PRIVATE_ALLOWED, false);
+ }
+ }
+ });
+ }
+ }
+};
diff --git a/browser/components/extensions/parent/ext-urlbar.js b/browser/components/extensions/parent/ext-urlbar.js
new file mode 100644
index 0000000000..70b20716e9
--- /dev/null
+++ b/browser/components/extensions/parent/ext-urlbar.js
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm",
+ UrlbarProviderExtension: "resource:///modules/UrlbarProviderExtension.jsm",
+});
+
+var { ExtensionPreferencesManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPreferencesManager.jsm"
+);
+var { getSettingsAPI } = ExtensionPreferencesManager;
+
+ExtensionPreferencesManager.addSetting("engagementTelemetry", {
+ prefNames: ["browser.urlbar.eventTelemetry.enabled"],
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+this.urlbar = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ urlbar: {
+ closeView() {
+ let window = windowTracker.getTopNormalWindow(context);
+ window.gURLBar.view.close();
+ },
+
+ focus(select = false) {
+ let window = windowTracker.getTopNormalWindow(context);
+ if (select) {
+ window.gURLBar.select();
+ } else {
+ window.gURLBar.focus();
+ }
+ },
+
+ search(searchString, options = {}) {
+ let window = windowTracker.getTopNormalWindow(context);
+ window.gURLBar.search(searchString, options);
+ },
+
+ onBehaviorRequested: new EventManager({
+ context,
+ name: "urlbar.onBehaviorRequested",
+ register: (fire, providerName) => {
+ let provider = UrlbarProviderExtension.getOrCreate(providerName);
+ provider.setEventListener(
+ "behaviorRequested",
+ async queryContext => {
+ if (queryContext.isPrivate && !context.privateBrowsingAllowed) {
+ return "inactive";
+ }
+ return fire.async(queryContext).catch(error => {
+ throw context.normalizeError(error);
+ });
+ }
+ );
+ return () => provider.setEventListener("behaviorRequested", null);
+ },
+ }).api(),
+
+ onEngagement: new EventManager({
+ context,
+ name: "urlbar.onEngagement",
+ register: (fire, providerName) => {
+ let provider = UrlbarProviderExtension.getOrCreate(providerName);
+ provider.setEventListener(
+ "engagement",
+ async (isPrivate, state) => {
+ if (isPrivate && !context.privateBrowsingAllowed) {
+ return;
+ }
+ return fire.async(state).catch(error => {
+ throw context.normalizeError(error);
+ });
+ }
+ );
+ return () => provider.setEventListener("engagement", null);
+ },
+ }).api(),
+
+ onQueryCanceled: new EventManager({
+ context,
+ name: "urlbar.onQueryCanceled",
+ register: (fire, providerName) => {
+ let provider = UrlbarProviderExtension.getOrCreate(providerName);
+ provider.setEventListener("queryCanceled", async queryContext => {
+ if (queryContext.isPrivate && !context.privateBrowsingAllowed) {
+ return;
+ }
+ await fire.async(queryContext).catch(error => {
+ throw context.normalizeError(error);
+ });
+ });
+ return () => provider.setEventListener("queryCanceled", null);
+ },
+ }).api(),
+
+ onResultsRequested: new EventManager({
+ context,
+ name: "urlbar.onResultsRequested",
+ register: (fire, providerName) => {
+ let provider = UrlbarProviderExtension.getOrCreate(providerName);
+ provider.setEventListener(
+ "resultsRequested",
+ async queryContext => {
+ if (queryContext.isPrivate && !context.privateBrowsingAllowed) {
+ return [];
+ }
+ return fire.async(queryContext).catch(error => {
+ throw context.normalizeError(error);
+ });
+ }
+ );
+ return () => provider.setEventListener("resultsRequested", null);
+ },
+ }).api(),
+
+ onResultPicked: new EventManager({
+ context,
+ name: "urlbar.onResultPicked",
+ inputHandling: true,
+ register: (fire, providerName) => {
+ let provider = UrlbarProviderExtension.getOrCreate(providerName);
+ provider.setEventListener(
+ "resultPicked",
+ async (resultPayload, dynamicElementName) => {
+ return fire
+ .async(resultPayload, dynamicElementName)
+ .catch(error => {
+ throw context.normalizeError(error);
+ });
+ }
+ );
+ return () => provider.setEventListener("resultPicked", null);
+ },
+ }).api(),
+
+ engagementTelemetry: getSettingsAPI({
+ context,
+ name: "engagementTelemetry",
+ callback: () => UrlbarPrefs.get("eventTelemetry.enabled"),
+ }),
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-windows.js b/browser/components/extensions/parent/ext-windows.js
new file mode 100644
index 0000000000..dde88dc880
--- /dev/null
+++ b/browser/components/extensions/parent/ext-windows.js
@@ -0,0 +1,443 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "HomePage",
+ "resource:///modules/HomePage.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+
+var { promiseObserved } = ExtensionUtils;
+
+/**
+ * An event manager API provider which listens for a DOM event in any browser
+ * window, and calls the given listener function whenever an event is received.
+ * That listener function receives a `fire` object, which it can use to dispatch
+ * events to the extension, and a DOM event object.
+ *
+ * @param {BaseContext} context
+ * The extension context which the event manager belongs to.
+ * @param {string} name
+ * The API name of the event manager, e.g.,"runtime.onMessage".
+ * @param {string} event
+ * The name of the DOM event to listen for.
+ * @param {function} listener
+ * The listener function to call when a DOM event is received.
+ *
+ * @returns {object} An injectable api for the new event.
+ */
+function WindowEventManager(context, name, event, listener) {
+ let register = fire => {
+ let listener2 = (window, ...args) => {
+ if (context.canAccessWindow(window)) {
+ listener(fire, window, ...args);
+ }
+ };
+
+ windowTracker.addListener(event, listener2);
+ return () => {
+ windowTracker.removeListener(event, listener2);
+ };
+ };
+
+ return new EventManager({ context, name, register }).api();
+}
+
+this.windows = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+
+ const { windowManager } = extension;
+
+ return {
+ windows: {
+ onCreated: WindowEventManager(
+ context,
+ "windows.onCreated",
+ "domwindowopened",
+ (fire, window) => {
+ fire.async(windowManager.convert(window));
+ }
+ ),
+
+ onRemoved: WindowEventManager(
+ context,
+ "windows.onRemoved",
+ "domwindowclosed",
+ (fire, window) => {
+ fire.async(windowTracker.getId(window));
+ }
+ ),
+
+ onFocusChanged: new EventManager({
+ context,
+ name: "windows.onFocusChanged",
+ register: fire => {
+ // Keep track of the last windowId used to fire an onFocusChanged event
+ let lastOnFocusChangedWindowId;
+
+ let listener = event => {
+ // Wait a tick to avoid firing a superfluous WINDOW_ID_NONE
+ // event when switching focus between two Firefox windows.
+ Promise.resolve().then(() => {
+ let windowId = Window.WINDOW_ID_NONE;
+ let window = Services.focus.activeWindow;
+ if (window && context.canAccessWindow(window)) {
+ windowId = windowTracker.getId(window);
+ }
+ if (windowId !== lastOnFocusChangedWindowId) {
+ fire.async(windowId);
+ lastOnFocusChangedWindowId = windowId;
+ }
+ });
+ };
+ windowTracker.addListener("focus", listener);
+ windowTracker.addListener("blur", listener);
+ return () => {
+ windowTracker.removeListener("focus", listener);
+ windowTracker.removeListener("blur", listener);
+ };
+ },
+ }).api(),
+
+ get: function(windowId, getInfo) {
+ let window = windowTracker.getWindow(windowId, context);
+ if (!window || !context.canAccessWindow(window)) {
+ return Promise.reject({
+ message: `Invalid window ID: ${windowId}`,
+ });
+ }
+ return Promise.resolve(windowManager.convert(window, getInfo));
+ },
+
+ getCurrent: function(getInfo) {
+ let window = context.currentWindow || windowTracker.topWindow;
+ if (!context.canAccessWindow(window)) {
+ return Promise.reject({ message: `Invalid window` });
+ }
+ return Promise.resolve(windowManager.convert(window, getInfo));
+ },
+
+ getLastFocused: function(getInfo) {
+ let window = windowTracker.topWindow;
+ if (!context.canAccessWindow(window)) {
+ return Promise.reject({ message: `Invalid window` });
+ }
+ return Promise.resolve(windowManager.convert(window, getInfo));
+ },
+
+ getAll: function(getInfo) {
+ let doNotCheckTypes =
+ getInfo === null || getInfo.windowTypes === null;
+ let windows = [];
+ // incognito access is checked in getAll
+ for (let win of windowManager.getAll()) {
+ if (doNotCheckTypes || getInfo.windowTypes.includes(win.type)) {
+ windows.push(win.convert(getInfo));
+ }
+ }
+ return windows;
+ },
+
+ create: function(createData) {
+ let needResize =
+ createData.left !== null ||
+ createData.top !== null ||
+ createData.width !== null ||
+ createData.height !== null;
+ if (createData.incognito && !context.privateBrowsingAllowed) {
+ return Promise.reject({
+ message: "Extension does not have permission for incognito mode",
+ });
+ }
+
+ if (needResize) {
+ if (createData.state !== null && createData.state != "normal") {
+ return Promise.reject({
+ message: `"state": "${createData.state}" may not be combined with "left", "top", "width", or "height"`,
+ });
+ }
+ createData.state = "normal";
+ }
+
+ function mkstr(s) {
+ let result = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ result.data = s;
+ return result;
+ }
+
+ let args = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+
+ let principal = context.principal;
+ if (createData.tabId !== null) {
+ if (createData.url !== null) {
+ return Promise.reject({
+ message: "`tabId` may not be used in conjunction with `url`",
+ });
+ }
+
+ if (createData.allowScriptsToClose) {
+ return Promise.reject({
+ message:
+ "`tabId` may not be used in conjunction with `allowScriptsToClose`",
+ });
+ }
+
+ let tab = tabTracker.getTab(createData.tabId);
+ if (!context.canAccessWindow(tab.ownerGlobal)) {
+ return Promise.reject({
+ message: `Invalid tab ID: ${createData.tabId}`,
+ });
+ }
+ // Private browsing tabs can only be moved to private browsing
+ // windows.
+ let incognito = PrivateBrowsingUtils.isBrowserPrivate(
+ tab.linkedBrowser
+ );
+ if (
+ createData.incognito !== null &&
+ createData.incognito != incognito
+ ) {
+ return Promise.reject({
+ message:
+ "`incognito` property must match the incognito state of tab",
+ });
+ }
+ createData.incognito = incognito;
+
+ if (
+ createData.cookieStoreId &&
+ createData.cookieStoreId !==
+ getCookieStoreIdForTab(createData, tab)
+ ) {
+ return Promise.reject({
+ message: "`cookieStoreId` must match the tab's cookieStoreId",
+ });
+ }
+
+ args.appendElement(tab);
+ } else if (createData.url !== null) {
+ if (Array.isArray(createData.url)) {
+ let array = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ for (let url of createData.url) {
+ array.appendElement(mkstr(url));
+ }
+ args.appendElement(array);
+ } else {
+ args.appendElement(mkstr(createData.url));
+ }
+ } else {
+ let url =
+ createData.incognito &&
+ !PrivateBrowsingUtils.permanentPrivateBrowsing
+ ? "about:privatebrowsing"
+ : HomePage.get().split("|", 1)[0];
+ args.appendElement(mkstr(url));
+
+ if (
+ url.startsWith("about:") &&
+ !context.checkLoadURL(url, { dontReportErrors: true })
+ ) {
+ // The extension principal cannot directly load about:-URLs,
+ // except for about:blank. So use the system principal instead.
+ principal = Services.scriptSecurityManager.getSystemPrincipal();
+ }
+ }
+
+ args.appendElement(null); // unused
+ args.appendElement(null); // referrerInfo
+ args.appendElement(null); // postData
+ args.appendElement(null); // allowThirdPartyFixup
+
+ if (createData.cookieStoreId) {
+ let userContextIdSupports = Cc[
+ "@mozilla.org/supports-PRUint32;1"
+ ].createInstance(Ci.nsISupportsPRUint32);
+ // May throw if validation fails.
+ userContextIdSupports.data = getUserContextIdForCookieStoreId(
+ extension,
+ createData.cookieStoreId,
+ createData.incognito
+ );
+ args.appendElement(userContextIdSupports); // userContextId
+ } else {
+ args.appendElement(null);
+ }
+
+ args.appendElement(context.principal); // originPrincipal - not important.
+ args.appendElement(context.principal); // originStoragePrincipal - not important.
+ args.appendElement(principal); // triggeringPrincipal
+ args.appendElement(
+ Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ )
+ ); // allowInheritPrincipal
+ // There is no CSP associated with this extension, hence we explicitly pass null as the CSP argument.
+ args.appendElement(null); // csp
+
+ let features = ["chrome"];
+
+ if (createData.type === null || createData.type == "normal") {
+ features.push("dialog=no", "all");
+ } else {
+ // All other types create "popup"-type windows by default.
+ features.push(
+ "dialog",
+ "resizable",
+ "minimizable",
+ "centerscreen",
+ "titlebar",
+ "close"
+ );
+ }
+
+ if (createData.incognito !== null) {
+ if (createData.incognito) {
+ if (!PrivateBrowsingUtils.enabled) {
+ return Promise.reject({
+ message:
+ "`incognito` cannot be used if incognito mode is disabled",
+ });
+ }
+ features.push("private");
+ } else {
+ features.push("non-private");
+ }
+ }
+
+ let { allowScriptsToClose, url } = createData;
+ if (allowScriptsToClose === null) {
+ allowScriptsToClose =
+ typeof url === "string" && url.startsWith("moz-extension://");
+ }
+
+ let window = Services.ww.openWindow(
+ null,
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ features.join(","),
+ args
+ );
+
+ let win = windowManager.getWrapper(window);
+ win.updateGeometry(createData);
+
+ // TODO: focused, type
+
+ return new Promise(resolve => {
+ window.addEventListener(
+ "DOMContentLoaded",
+ function() {
+ if (allowScriptsToClose) {
+ window.gBrowserAllowScriptsToCloseInitialTabs = true;
+ }
+ resolve(
+ promiseObserved(
+ "browser-delayed-startup-finished",
+ win => win == window
+ )
+ );
+ },
+ { once: true }
+ );
+ }).then(() => {
+ if (
+ [
+ "minimized",
+ "fullscreen",
+ "docked",
+ "normal",
+ "maximized",
+ ].includes(createData.state)
+ ) {
+ win.state = createData.state;
+ }
+ if (createData.titlePreface !== null) {
+ win.setTitlePreface(createData.titlePreface);
+ }
+ return win.convert({ populate: true });
+ });
+ },
+
+ update: function(windowId, updateInfo) {
+ if (updateInfo.state !== null && updateInfo.state != "normal") {
+ if (
+ updateInfo.left !== null ||
+ updateInfo.top !== null ||
+ updateInfo.width !== null ||
+ updateInfo.height !== null
+ ) {
+ return Promise.reject({
+ message: `"state": "${updateInfo.state}" may not be combined with "left", "top", "width", or "height"`,
+ });
+ }
+ }
+
+ let win = windowManager.get(windowId, context);
+ if (!win) {
+ return Promise.reject({
+ message: `Invalid window ID: ${windowId}`,
+ });
+ }
+ if (updateInfo.focused) {
+ win.window.focus();
+ }
+
+ if (updateInfo.state !== null) {
+ win.state = updateInfo.state;
+ }
+
+ if (updateInfo.drawAttention) {
+ // Bug 1257497 - Firefox can't cancel attention actions.
+ win.window.getAttention();
+ }
+
+ win.updateGeometry(updateInfo);
+
+ if (updateInfo.titlePreface !== null) {
+ win.setTitlePreface(updateInfo.titlePreface);
+ win.window.gBrowser.updateTitlebar();
+ }
+
+ // TODO: All the other properties, focused=false...
+
+ return Promise.resolve(win.convert());
+ },
+
+ remove: function(windowId) {
+ let window = windowTracker.getWindow(windowId, context);
+ if (!context.canAccessWindow(window)) {
+ return Promise.reject({
+ message: `Invalid window ID: ${windowId}`,
+ });
+ }
+ window.close();
+
+ return new Promise(resolve => {
+ let listener = () => {
+ windowTracker.removeListener("domwindowclosed", listener);
+ resolve();
+ };
+ windowTracker.addListener("domwindowclosed", listener);
+ });
+ },
+ },
+ };
+ }
+};