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.js32
-rw-r--r--browser/components/extensions/parent/ext-bookmarks.js511
-rw-r--r--browser/components/extensions/parent/ext-browser.js1243
-rw-r--r--browser/components/extensions/parent/ext-browserAction.js1018
-rw-r--r--browser/components/extensions/parent/ext-chrome-settings-overrides.js572
-rw-r--r--browser/components/extensions/parent/ext-commands.js82
-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.js691
-rw-r--r--browser/components/extensions/parent/ext-devtools.js510
-rw-r--r--browser/components/extensions/parent/ext-find.js272
-rw-r--r--browser/components/extensions/parent/ext-history.js326
-rw-r--r--browser/components/extensions/parent/ext-menus.js1471
-rw-r--r--browser/components/extensions/parent/ext-normandyAddonStudy.js84
-rw-r--r--browser/components/extensions/parent/ext-omnibox.js177
-rw-r--r--browser/components/extensions/parent/ext-pageAction.js383
-rw-r--r--browser/components/extensions/parent/ext-pkcs11.js187
-rw-r--r--browser/components/extensions/parent/ext-search.js113
-rw-r--r--browser/components/extensions/parent/ext-sessions.js305
-rw-r--r--browser/components/extensions/parent/ext-sidebarAction.js520
-rw-r--r--browser/components/extensions/parent/ext-tabs.js1635
-rw-r--r--browser/components/extensions/parent/ext-topSites.js117
-rw-r--r--browser/components/extensions/parent/ext-url-overrides.js205
-rw-r--r--browser/components/extensions/parent/ext-windows.js544
24 files changed, 11133 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..b7c3113e49
--- /dev/null
+++ b/browser/components/extensions/parent/.eslintrc.js
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+module.exports = {
+ extends: "../../../../toolkit/components/extensions/parent/.eslintrc.js",
+
+ globals: {
+ Tab: true,
+ TabContext: true,
+ Window: true,
+ actionContextMenu: true,
+ browserActionFor: true,
+ clickModifiersFromEvent: true,
+ getContainerForCookieStoreId: true,
+ getTargetTabIdForToolbox: true,
+ getToolboxEvalOptions: true,
+ isContainerCookieStoreId: true,
+ isPrivateCookieStoreId: true,
+ isValidCookieStoreId: true,
+ makeWidgetId: true,
+ openOptionsPage: true,
+ pageActionFor: true,
+ replaceUrlInTab: true,
+ sidebarActionFor: 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..34bb6cb6cc
--- /dev/null
+++ b/browser/components/extensions/parent/ext-bookmarks.js
@@ -0,0 +1,511 @@
+/* -*- 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.defineESModuleGetters(this, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+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:";
+
+ChromeUtils.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.handlePlacesEvents = this.handlePlacesEvents.bind(this);
+ }
+
+ 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),
+ title: event.title,
+ };
+
+ this.emit("removed", {
+ guid: event.guid,
+ info: { parentId: event.parentGuid, index: event.index, node },
+ });
+ break;
+ case "bookmark-moved":
+ this.emit("moved", {
+ guid: event.guid,
+ info: {
+ parentId: event.parentGuid,
+ index: event.index,
+ oldParentId: event.oldParentGuid,
+ oldIndex: event.oldIndex,
+ },
+ });
+ break;
+ case "bookmark-title-changed":
+ if (event.isTagging) {
+ continue;
+ }
+
+ this.emit("changed", {
+ guid: event.guid,
+ info: { title: event.title },
+ });
+ break;
+ case "bookmark-url-changed":
+ if (event.isTagging) {
+ continue;
+ }
+
+ this.emit("changed", {
+ guid: event.guid,
+ info: { url: event.url },
+ });
+ break;
+ }
+ }
+ }
+})();
+
+const decrementListeners = () => {
+ listenerCount -= 1;
+ if (!listenerCount) {
+ PlacesUtils.observers.removeListener(
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "bookmark-moved",
+ "bookmark-title-changed",
+ "bookmark-url-changed",
+ ],
+ observer.handlePlacesEvents
+ );
+ }
+};
+
+const incrementListeners = () => {
+ listenerCount++;
+ if (listenerCount == 1) {
+ PlacesUtils.observers.addListener(
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "bookmark-moved",
+ "bookmark-title-changed",
+ "bookmark-url-changed",
+ ],
+ observer.handlePlacesEvents
+ );
+ }
+};
+
+this.bookmarks = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ onCreated({ fire }) {
+ let listener = (event, bookmark) => {
+ fire.sync(bookmark.id, bookmark);
+ };
+
+ observer.on("created", listener);
+ incrementListeners();
+ return {
+ unregister() {
+ observer.off("created", listener);
+ decrementListeners();
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+
+ onRemoved({ fire }) {
+ let listener = (event, data) => {
+ fire.sync(data.guid, data.info);
+ };
+
+ observer.on("removed", listener);
+ incrementListeners();
+ return {
+ unregister() {
+ observer.off("removed", listener);
+ decrementListeners();
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+
+ onChanged({ fire }) {
+ let listener = (event, data) => {
+ fire.sync(data.guid, data.info);
+ };
+
+ observer.on("changed", listener);
+ incrementListeners();
+ return {
+ unregister() {
+ observer.off("changed", listener);
+ decrementListeners();
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+
+ onMoved({ fire }) {
+ let listener = (event, data) => {
+ fire.sync(data.guid, data.info);
+ };
+
+ observer.on("moved", listener);
+ incrementListeners();
+ return {
+ unregister() {
+ observer.off("moved", listener);
+ decrementListeners();
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ 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,
+ module: "bookmarks",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ module: "bookmarks",
+ event: "onRemoved",
+ extensionApi: this,
+ }).api(),
+
+ onChanged: new EventManager({
+ context,
+ module: "bookmarks",
+ event: "onChanged",
+ extensionApi: this,
+ }).api(),
+
+ onMoved: new EventManager({
+ context,
+ module: "bookmarks",
+ event: "onMoved",
+ extensionApi: this,
+ }).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..355f4f0668
--- /dev/null
+++ b/browser/components/extensions/parent/ext-browser.js
@@ -0,0 +1,1243 @@
+/* -*- 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.defineESModuleGetters(this, {
+ AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs",
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+var { ExtensionError } = ExtensionUtils;
+
+var { defineLazyGetter } = ExtensionCommon;
+
+const READER_MODE_PREFIX = "about:reader";
+
+let tabTracker;
+let windowTracker;
+
+function isPrivateTab(nativeTab) {
+ return PrivateBrowsingUtils.isBrowserPrivate(nativeTab.linkedBrowser);
+}
+
+/* eslint-disable mozilla/balanced-listeners */
+extensions.on("uninstalling", (msg, extension) => {
+ if (extension.uninstallURL) {
+ let browser = windowTracker.topWindow.gBrowser;
+ browser.addTab(extension.uninstallURL, {
+ 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, uri) => {
+ let loaded = waitForTabLoaded(tab, uri.spec);
+ gBrowser.loadURI(uri, {
+ 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);
+ }
+};
+
+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.");
+ }
+ if (nativeTab.ownerGlobal.closed) {
+ throw new Error("Cannot attach ID to a tab in a closed window.");
+ }
+
+ 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 adoptedBy = adoptingTab;
+ let oldWindowId = windowTracker.getId(nativeTab.ownerGlobal);
+ let oldPosition = nativeTab._tPos;
+ this.emit("tab-detached", {
+ nativeTab,
+ adoptedBy,
+ tabId,
+ oldWindowId,
+ oldPosition,
+ });
+ }
+ if (this.has("tab-attached")) {
+ let nativeTab = adoptingTab;
+ let newWindowId = windowTracker.getId(nativeTab.ownerGlobal);
+ let newPosition = nativeTab._tPos;
+ this.emit("tab-attached", {
+ nativeTab,
+ tabId,
+ newWindowId,
+ newPosition,
+ });
+ }
+ }
+
+ _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 = Promise.withResolvers();
+ 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);
+ } 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", {
+ tabId: this.getId(nativeTab),
+ previousTabId,
+ previousTabIsPrivate,
+ windowId: windowTracker.getId(nativeTab.ownerGlobal),
+ nativeTab,
+ });
+ }
+
+ /**
+ * 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,
+ });
+ }
+
+ /**
+ * 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,
+ });
+ }
+
+ /**
+ * 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,
+ });
+ }
+
+ 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.hasAttribute("attention");
+ }
+
+ get audible() {
+ return this.nativeTab.soundPlaying;
+ }
+
+ get autoDiscardable() {
+ return !this.nativeTab.undiscardable;
+ }
+
+ 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 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,
+ };
+
+ let entries = tabData.state ? tabData.state.entries : tabData.entries;
+ let lastTabIndex = tabData.state ? tabData.state.index : tabData.index;
+
+ // Tab may have empty history.
+ if (entries.length) {
+ // 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];
+
+ // 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" or host permission (because tabData represents a closed tab,
+ // and so we already know that it can't be the activeTab).
+ if (
+ extension.hasPermission("tabs") ||
+ extension.allowedOrigins.matches(entry.url)
+ ) {
+ 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_FULLSCREEN]: "fullscreen",
+ [window.STATE_NORMAL]: "normal",
+ };
+ return STATES[window.windowState];
+ }
+
+ get state() {
+ return Window.getState(this.window);
+ }
+
+ async setState(state) {
+ let { window } = this;
+
+ const expectedState = (function () {
+ switch (state) {
+ case "maximized":
+ return window.STATE_MAXIMIZED;
+ case "minimized":
+ case "docked":
+ return window.STATE_MINIMIZED;
+ case "normal":
+ return window.STATE_NORMAL;
+ case "fullscreen":
+ return window.STATE_FULLSCREEN;
+ }
+ throw new Error(`Unexpected window state: ${state}`);
+ })();
+
+ const initialState = window.windowState;
+ if (expectedState == initialState) {
+ return;
+ }
+
+ // We check for window.fullScreen here to make sure to exit fullscreen even
+ // if DOM and widget disagree on what the state is. This is a speculative
+ // fix for bug 1780876, ideally it should not be needed.
+ if (initialState == window.STATE_FULLSCREEN || window.fullScreen) {
+ window.fullScreen = false;
+ }
+
+ switch (expectedState) {
+ case window.STATE_MAXIMIZED:
+ window.maximize();
+ break;
+ case window.STATE_MINIMIZED:
+ window.minimize();
+ break;
+
+ case window.STATE_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();
+ }
+ break;
+
+ case window.STATE_FULLSCREEN:
+ window.fullScreen = true;
+ break;
+
+ default:
+ throw new Error(`Unexpected window state: ${state}`);
+ }
+
+ if (window.windowState != expectedState) {
+ // On Linux, sizemode changes are asynchronous. Some of them might not
+ // even happen if the window manager doesn't want to, so wait for a bit
+ // instead of forever for a sizemode change that might not ever happen.
+ const noWindowManagerTimeout = 2000;
+
+ let onSizeModeChange;
+ const promiseExpectedSizeMode = new Promise(resolve => {
+ onSizeModeChange = function () {
+ if (window.windowState == expectedState) {
+ resolve();
+ }
+ };
+ window.addEventListener("sizemodechange", onSizeModeChange);
+ });
+
+ await Promise.any([
+ promiseExpectedSizeMode,
+ new Promise(resolve => setTimeout(resolve, noWindowManagerTimeout)),
+ ]);
+
+ window.removeEventListener("sizemodechange", onSizeModeChange);
+ }
+ }
+
+ *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) {
+ let tab = tabManager.getWrapper(nativeTab);
+ if (tab) {
+ yield tab;
+ }
+ }
+ }
+
+ *getHighlightedTabs() {
+ let { tabManager } = this.extension;
+ for (let nativeTab of this.window.gBrowser.selectedTabs) {
+ let tab = tabManager.getWrapper(nativeTab);
+ if (tab) {
+ yield 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
+ // Bug 1781226: we assert "state" is "normal" in tests, but we could use
+ // the "sizemode" property if we wanted.
+ state: "normal",
+ 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.canAccessTab(nativeTab)) {
+ 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) {
+ // Check private browsing access at browser window level.
+ if (!this.extension.canAccessWindow(nativeTab.ownerGlobal)) {
+ return false;
+ }
+ if (
+ this.extension.userContextIsolation &&
+ !this.extension.canAccessContainer(nativeTab.userContextId)
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ wrapTab(nativeTab) {
+ return new Tab(this.extension, nativeTab, tabTracker.getId(nativeTab));
+ }
+
+ getWrapper(nativeTab) {
+ if (!nativeTab.ownerGlobal.gBrowserInit.isAdoptingTab()) {
+ return super.getWrapper(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..4122856104
--- /dev/null
+++ b/browser/components/extensions/parent/ext-browserAction.js
@@ -0,0 +1,1018 @@
+/* -*- 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.defineESModuleGetters(this, {
+ BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs",
+ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
+ ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
+ OriginControls: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+ ViewPopup: "resource:///modules/ExtensionPopups.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+var { DefaultWeakMap, ExtensionError } = ExtensionUtils;
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { BrowserActionBase } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionActions.sys.mjs"
+);
+
+var { IconDetails, StartupCache } = ExtensionParent;
+
+const POPUP_PRELOAD_TIMEOUT_MS = 200;
+
+// WeakMap[Extension -> BrowserAction]
+const browserActionMap = new WeakMap();
+
+ChromeUtils.defineLazyGetter(this, "browserAreas", () => {
+ return {
+ navbar: CustomizableUI.AREA_NAVBAR,
+ menupanel: CustomizableUI.AREA_ADDONS,
+ 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;
+ }
+
+ dispatchClick(tab, clickInfo) {
+ this.buttonDelegate.emit("click", tab, clickInfo);
+ }
+}
+
+this.browserAction = class extends ExtensionAPIPersistent {
+ static for(extension) {
+ return browserActionMap.get(extension);
+ }
+
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+
+ let options =
+ extension.manifest.browser_action || extension.manifest.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}-BAV`;
+ 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 || "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 { extension } = this;
+ let widgetId = makeWidgetId(extension.id);
+ let widget = CustomizableUI.createWidget({
+ id: this.id,
+ viewId: this.viewId,
+ type: "custom",
+ webExtension: true,
+ removable: true,
+ label: this.action.getProperty(null, "title"),
+ tooltiptext: this.action.getProperty(null, "title"),
+ defaultArea: browserAreas[this.action.getDefaultArea()],
+ showInPrivateBrowsing: extension.privateBrowsingAllowed,
+ disallowSubView: true,
+
+ // Don't attempt to load properties from the built-in widget string
+ // bundle.
+ localized: false,
+
+ // Build a custom widget that looks like a `unified-extensions-item`
+ // custom element.
+ onBuild(document) {
+ let viewId = widgetId + "-BAP";
+ let button = document.createXULElement("toolbarbutton");
+ button.setAttribute("id", viewId);
+ // Ensure the extension context menuitems are available by setting this
+ // on all button children and the item.
+ button.setAttribute("data-extensionid", extension.id);
+ button.classList.add("unified-extensions-item-action-button");
+
+ let contents = document.createXULElement("vbox");
+ contents.classList.add("unified-extensions-item-contents");
+ contents.setAttribute("move-after-stack", "true");
+
+ let name = document.createXULElement("label");
+ name.classList.add("unified-extensions-item-name");
+ contents.appendChild(name);
+
+ // This deck (and its labels) should be kept in sync with
+ // `browser/base/content/unified-extensions-viewcache.inc.xhtml`.
+ let deck = document.createXULElement("deck");
+ deck.classList.add("unified-extensions-item-message-deck");
+
+ let messageDefault = document.createXULElement("label");
+ messageDefault.classList.add(
+ "unified-extensions-item-message",
+ "unified-extensions-item-message-default"
+ );
+ deck.appendChild(messageDefault);
+
+ let messageHover = document.createXULElement("label");
+ messageHover.classList.add(
+ "unified-extensions-item-message",
+ "unified-extensions-item-message-hover"
+ );
+ deck.appendChild(messageHover);
+
+ let messageHoverForMenuButton = document.createXULElement("label");
+ messageHoverForMenuButton.classList.add(
+ "unified-extensions-item-message",
+ "unified-extensions-item-message-hover-menu-button"
+ );
+ document.l10n.setAttributes(
+ messageHoverForMenuButton,
+ "unified-extensions-item-message-manage"
+ );
+ deck.appendChild(messageHoverForMenuButton);
+
+ contents.appendChild(deck);
+
+ button.appendChild(contents);
+
+ let menuButton = document.createXULElement("toolbarbutton");
+ menuButton.classList.add(
+ "toolbarbutton-1",
+ "unified-extensions-item-menu-button"
+ );
+
+ document.l10n.setAttributes(
+ menuButton,
+ "unified-extensions-item-open-menu"
+ );
+ // Allow the users to quickly move between extension items using
+ // the arrow keys, see: `PanelMultiView._isNavigableWithTabOnly()`.
+ menuButton.setAttribute("data-navigable-with-tab-only", true);
+
+ menuButton.setAttribute("data-extensionid", extension.id);
+ menuButton.setAttribute("closemenu", "none");
+
+ let node = document.createXULElement("toolbaritem");
+ node.classList.add(
+ "toolbaritem-combined-buttons",
+ "unified-extensions-item"
+ );
+ node.setAttribute("view-button-id", viewId);
+ node.setAttribute("data-extensionid", extension.id);
+ node.append(button, menuButton);
+ node.viewButton = button;
+
+ return node;
+ },
+
+ onBeforeCreated: document => {
+ let view = document.createXULElement("panelview");
+ view.id = this.viewId;
+ view.setAttribute("flex", "1");
+ view.setAttribute("extension", true);
+ view.setAttribute("neverhidden", true);
+ view.setAttribute("disallowSubView", 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 => {
+ let actionButton = node.querySelector(
+ ".unified-extensions-item-action-button"
+ );
+ actionButton.classList.add("panel-no-padding");
+ actionButton.classList.add("webextension-browser-action");
+ actionButton.setAttribute("badged", "true");
+ actionButton.setAttribute("constrain-size", "true");
+ actionButton.setAttribute("data-extensionid", this.extension.id);
+
+ actionButton.onmousedown = event => this.handleEvent(event);
+ actionButton.onmouseover = event => this.handleEvent(event);
+ actionButton.onmouseout = event => this.handleEvent(event);
+ actionButton.onauxclick = event => this.handleEvent(event);
+
+ const menuButton = node.querySelector(
+ ".unified-extensions-item-menu-button"
+ );
+ node.ownerDocument.l10n.setAttributes(
+ menuButton,
+ "unified-extensions-item-open-menu",
+ { extensionName: this.extension.name }
+ );
+
+ menuButton.onblur = event => this.handleMenuButtonEvent(event);
+ menuButton.onfocus = event => this.handleMenuButtonEvent(event);
+ menuButton.onmouseout = event => this.handleMenuButtonEvent(event);
+ menuButton.onmouseover = event => this.handleMenuButtonEvent(event);
+
+ actionButton.onblur = event => this.handleEvent(event);
+ actionButton.onfocus = event => this.handleEvent(event);
+
+ this.updateButton(
+ node,
+ this.action.getContextData(null),
+ /* sync */ true
+ );
+ },
+
+ onBeforeCommand: (event, node) => {
+ this.lastClickInfo = {
+ button: event.button || 0,
+ modifiers: clickModifiersFromEvent(event),
+ };
+
+ // The openPopupWithoutUserInteraction flag may be set by openPopup.
+ this.openPopupWithoutUserInteraction =
+ event.detail?.openPopupWithoutUserInteraction === true;
+
+ if (
+ event.target.classList.contains(
+ "unified-extensions-item-action-button"
+ )
+ ) {
+ return "view";
+ } else if (
+ event.target.classList.contains("unified-extensions-item-menu-button")
+ ) {
+ return "command";
+ }
+ },
+
+ onCommand: event => {
+ const { target } = event;
+
+ if (event.button !== 0) {
+ return;
+ }
+
+ // Open the unified extensions context menu.
+ const popup = target.ownerDocument.getElementById(
+ "unified-extensions-context-menu"
+ );
+ // Anchor to the visible part of the button.
+ const anchor = target.firstElementChild;
+ popup.openPopup(
+ anchor,
+ "after_end",
+ 0,
+ 0,
+ true /* isContextMenu */,
+ false /* attributesOverride */,
+ 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.openPopupWithoutUserInteraction
+ ? this.action.triggerClickOrPopup(tab, this.lastClickInfo)
+ : this.action.getPopupUrl(tab);
+
+ 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();
+ // 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;
+ }
+
+ /**
+ * Shows the popup. The caller is expected to check if a popup is set before
+ * this is called.
+ *
+ * @param {Window} window Window to show the popup for
+ * @param {boolean} openPopupWithoutUserInteraction
+ * If the popup was opened without user interaction
+ */
+ async openPopup(window, openPopupWithoutUserInteraction = false) {
+ const widgetForWindow = this.widget.forWindow(window);
+
+ if (!widgetForWindow.node) {
+ return;
+ }
+
+ // We want to focus hidden or minimized windows (both for the API, and to
+ // avoid an issue where showing the popup in a non-focused window
+ // immediately triggers a popuphidden event)
+ window.focus();
+
+ if (widgetForWindow.node.firstElementChild.open) {
+ return;
+ }
+
+ if (this.widget.areaType == CustomizableUI.TYPE_PANEL) {
+ await window.gUnifiedExtensions.togglePanel();
+ }
+
+ // This should already have been checked by callers, but acts as an
+ // an additional safeguard. It also makes sure we don't dispatch a click
+ // if the URL is removed while waiting for the overflow to show above.
+ if (!this.action.getPopupUrl(window.gBrowser.selectedTab)) {
+ return;
+ }
+
+ const event = new window.CustomEvent("command", {
+ bubbles: true,
+ cancelable: true,
+ detail: {
+ openPopupWithoutUserInteraction,
+ },
+ });
+ widgetForWindow.node.firstElementChild.dispatchEvent(event);
+ }
+
+ /**
+ * 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
+ */
+ triggerAction(window) {
+ let popup = ViewPopup.for(this.extension, window);
+ if (!this.pendingPopup && popup) {
+ popup.closePopup();
+ return;
+ }
+
+ let tab = window.gBrowser.selectedTab;
+
+ let popupUrl = this.action.triggerClickOrPopup(tab, {
+ button: 0,
+ modifiers: [],
+ });
+ if (popupUrl) {
+ this.openPopup(window);
+ }
+ }
+
+ /**
+ * Handles events on the (secondary) menu/cog button in an extension widget.
+ *
+ * @param {Event} event
+ */
+ handleMenuButtonEvent(event) {
+ let window = event.target.ownerGlobal;
+ let { node } = window.gBrowser && this.widget.forWindow(window);
+ let messageDeck = node?.querySelector(
+ ".unified-extensions-item-message-deck"
+ );
+
+ switch (event.type) {
+ case "focus":
+ case "mouseover": {
+ if (messageDeck) {
+ messageDeck.selectedIndex =
+ window.gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER;
+ }
+ break;
+ }
+
+ case "blur":
+ case "mouseout": {
+ if (messageDeck) {
+ messageDeck.selectedIndex =
+ window.gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT;
+ }
+ break;
+ }
+ }
+ }
+
+ handleEvent(event) {
+ // This button is the action/primary button in the custom widget.
+ let button = event.target;
+ let window = button.ownerGlobal;
+
+ switch (event.type) {
+ case "mousedown":
+ if (event.button == 0) {
+ let tab = window.gBrowser.selectedTab;
+
+ // 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 popupURL = this.action.getPopupUrl(tab);
+ if (
+ popupURL &&
+ (this.pendingPopup || !ViewPopup.for(this.extension, window))
+ ) {
+ // Add permission for the active tab so it will exist for the popup.
+ this.action.setActiveTabForPreload(tab);
+ this.eventQueue.push("Mousedown");
+ 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 "focus":
+ case "mouseover": {
+ let tab = window.gBrowser.selectedTab;
+ let popupURL = this.action.getPopupUrl(tab);
+
+ let { node } = window.gBrowser && this.widget.forWindow(window);
+ if (node) {
+ node.querySelector(
+ ".unified-extensions-item-message-deck"
+ ).selectedIndex = window.gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER;
+ }
+
+ // We don't want to preload the popup on focus (for now).
+ if (event.type === "focus") {
+ break;
+ }
+
+ // Begin pre-loading the browser for the popup, so it's more likely to
+ // be ready by the time we get a complete click.
+ if (
+ popupURL &&
+ (this.pendingPopup || !ViewPopup.for(this.extension, window))
+ ) {
+ this.eventQueue.push("Hover");
+ this.pendingPopup = this.getPopup(window, popupURL, true);
+ }
+ break;
+ }
+
+ case "blur":
+ case "mouseout": {
+ let { node } = window.gBrowser && this.widget.forWindow(window);
+ if (node) {
+ node.querySelector(
+ ".unified-extensions-item-message-deck"
+ ).selectedIndex =
+ window.gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT;
+ }
+
+ // We don't want to clear the popup on blur for now.
+ if (event.type === "blur") {
+ break;
+ }
+
+ 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)) {
+ this.updateContextMenu(menu);
+ }
+ break;
+
+ case "auxclick":
+ if (event.button !== 1) {
+ return;
+ }
+
+ let tab = window.gBrowser.selectedTab;
+ if (this.action.getProperty(tab, "enabled")) {
+ this.action.setActiveTabForPreload(null);
+ this.tabManager.addActiveTabPermission(tab);
+ this.action.dispatchClick(tab, {
+ button: 1,
+ modifiers: clickModifiersFromEvent(event),
+ });
+ // Ensure we close any popups this node was in:
+ CustomizableUI.hidePanelForNode(event.target);
+ }
+ break;
+ }
+ }
+
+ /**
+ * Updates the given context menu with the extension's actions.
+ *
+ * @param {Element} menu
+ * The context menu element that should be updated.
+ */
+ updateContextMenu(menu) {
+ const action =
+ this.extension.manifestVersion < 3 ? "onBrowserAction" : "onAction";
+
+ global.actionContextMenu({
+ extension: this.extension,
+ [action]: true,
+ menu,
+ });
+ }
+
+ /**
+ * 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();
+ }
+
+ return new ViewPopup(
+ this.extension,
+ window,
+ popupURL,
+ this.browserStyle,
+ false,
+ blockParser
+ );
+ }
+
+ /**
+ * Clears any pending pre-loaded popup and related timeouts.
+ */
+ clearPopup() {
+ this.clearPopupTimeout();
+ this.action.setActiveTabForPreload(null);
+ if (this.pendingPopup) {
+ this.pendingPopup.destroy();
+ this.pendingPopup = 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,
+ attention = false,
+ quarantined = false
+ ) {
+ // This is the primary/action button in the custom widget.
+ let button = node.querySelector(".unified-extensions-item-action-button");
+ let extensionTitle = tabData.title || this.extension.name;
+
+ let policy = WebExtensionPolicy.getByID(this.extension.id);
+ let messages = OriginControls.getStateMessageIDs({
+ policy,
+ tab: node.ownerGlobal.gBrowser.selectedTab,
+ isAction: true,
+ hasPopup: !!tabData.popup,
+ });
+
+ let callback = () => {
+ // This is set on the node so that it looks good in the toolbar.
+ node.toggleAttribute("attention", attention);
+
+ let msgId = "origin-controls-toolbar-button";
+ if (attention) {
+ msgId = quarantined
+ ? "origin-controls-toolbar-button-quarantined"
+ : "origin-controls-toolbar-button-permission-needed";
+ }
+ node.ownerDocument.l10n.setAttributes(button, msgId, { extensionTitle });
+
+ button.querySelector(".unified-extensions-item-name").textContent =
+ this.extension?.name;
+
+ if (messages) {
+ const messageDefaultElement = button.querySelector(
+ ".unified-extensions-item-message-default"
+ );
+ node.ownerDocument.l10n.setAttributes(
+ messageDefaultElement,
+ messages.default
+ );
+
+ const messageHoverElement = button.querySelector(
+ ".unified-extensions-item-message-hover"
+ );
+ node.ownerDocument.l10n.setAttributes(
+ messageHoverElement,
+ messages.onHover || messages.default
+ );
+ }
+
+ if (tabData.badgeText) {
+ button.setAttribute("badge", tabData.badgeText);
+ } else {
+ button.removeAttribute("badge");
+ }
+
+ if (tabData.enabled) {
+ button.removeAttribute("disabled");
+ } else {
+ button.setAttribute("disabled", "true");
+ }
+
+ let serializeColor = ([r, g, b, a]) =>
+ `rgba(${r}, ${g}, ${b}, ${a / 255})`;
+ button.setAttribute(
+ "badgeStyle",
+ [
+ `background-color: ${serializeColor(tabData.badgeBackgroundColor)}`,
+ `color: ${serializeColor(this.action.getTextColor(tabData))}`,
+ ].join("; ")
+ );
+
+ let style = this.iconData.get(tabData.icon);
+ button.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, icon1x, icon2x) => {
+ return `
+ --webextension-${name}: image-set(
+ url("${getIcon(icon1x, "default")}"),
+ url("${getIcon(icon2x, "default")}") 2x
+ );
+ --webextension-${name}-light: image-set(
+ url("${getIcon(icon1x, "light")}"),
+ url("${getIcon(icon2x, "light")}") 2x
+ );
+ --webextension-${name}-dark: image-set(
+ url("${getIcon(icon1x, "dark")}"),
+ url("${getIcon(icon2x, "dark")}") 2x
+ );
+ `;
+ };
+
+ let icon16 = IconDetails.getPreferredIcon(icons, this.extension, 16).icon;
+ let icon32 = IconDetails.getPreferredIcon(icons, this.extension, 32).icon;
+ let icon64 = IconDetails.getPreferredIcon(icons, this.extension, 64).icon;
+
+ return `
+ ${getStyle("menupanel-image", icon32, icon64)}
+ ${getStyle("toolbar-image", icon16, 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;
+ let { attention, quarantined } = OriginControls.getAttentionState(
+ this.extension.policy,
+ window
+ );
+
+ this.updateButton(
+ node,
+ this.action.getContextData(tab),
+ /* sync */ false,
+ attention,
+ quarantined
+ );
+ }
+ }
+
+ PERSISTENT_EVENTS = {
+ onClicked({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(_event, tab, clickInfo) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ // TODO: we should double-check if the tab is already being closed by the time
+ // the background script got started and we converted the primed listener.
+ context?.withPendingBrowser(tab.linkedBrowser, () =>
+ fire.sync(tabManager.convert(tab), clickInfo)
+ );
+ }
+ this.on("click", listener);
+ return {
+ unregister: () => {
+ this.off("click", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ let { action } = this;
+ let namespace = extension.manifestVersion < 3 ? "browserAction" : "action";
+
+ return {
+ [namespace]: {
+ ...action.api(context),
+
+ onClicked: new EventManager({
+ context,
+ // module name is "browserAction" because it the name used in the
+ // ext-browser.json, indipendently from the manifest version.
+ module: "browserAction",
+ event: "onClicked",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+
+ getUserSettings: () => {
+ let { area } = CustomizableUI.getPlacementOfWidget(
+ action.buttonDelegate.id
+ );
+ return { isOnToolbar: area !== CustomizableUI.AREA_ADDONS };
+ },
+ openPopup: async options => {
+ const isHandlingUserInput =
+ context.callContextData?.isHandlingUserInput;
+
+ if (
+ !Services.prefs.getBoolPref(
+ "extensions.openPopupWithoutUserGesture.enabled"
+ ) &&
+ !isHandlingUserInput
+ ) {
+ throw new ExtensionError("openPopup requires a user gesture");
+ }
+
+ const window =
+ typeof options?.windowId === "number"
+ ? windowTracker.getWindow(options.windowId, context)
+ : windowTracker.getTopNormalWindow(context);
+
+ if (this.action.getPopupUrl(window.gBrowser.selectedTab, true)) {
+ await this.openPopup(window, !isHandlingUserInput);
+ }
+ },
+ },
+ };
+ }
+};
+
+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..2e3a285014
--- /dev/null
+++ b/browser/components/extensions/parent/ext-chrome-settings-overrides.js
@@ -0,0 +1,572 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If 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.importESModule(
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionControlledPopup:
+ "resource:///modules/ExtensionControlledPopup.sys.mjs",
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+ HomePage: "resource:///modules/HomePage.sys.mjs",
+});
+
+const DEFAULT_SEARCH_STORE_TYPE = "default_search";
+const DEFAULT_SEARCH_SETTING_NAME = "defaultSearch";
+
+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";
+
+ChromeUtils.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",
+ 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, Services.io.newURI("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) {
+ // For new installs and enabling a disabled addon, we will show
+ // the prompt. We clear the confirmation in onDisabled and
+ // onUninstalled, so in either ADDON_INSTALL or ADDON_ENABLE it
+ // is already cleared, resulting in the prompt being shown if
+ // necessary the next time the homepage is shown.
+
+ // For localizing the homepageUrl, or otherwise updating the value
+ // we need to always set the setting here.
+ let inControl = await ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "homepage_override",
+ homepageUrl
+ );
+
+ 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) {
+ await Services.search.setDefault(
+ engine,
+ action == "enable"
+ ? Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ : Ci.nsISearchService.CHANGE_REASON_ADDON_UNINSTALL
+ );
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ }
+
+ static async removeEngine(id) {
+ 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 onUninstall(id) {
+ let searchStartupPromise = pendingSearchSetupTasks.get(id);
+ if (searchStartupPromise) {
+ await searchStartupPromise.catch(Cu.reportError);
+ }
+ // Note: We do not have to manage the homepage setting here
+ // as it is managed by the ExtensionPreferencesManager.
+ return Promise.all([
+ this.removeSearchSettings(id),
+ homepagePopup.clearConfirmation(id),
+ ]);
+ }
+
+ static async onUpdate(id, manifest) {
+ if (!manifest?.chrome_settings_overrides?.homepage) {
+ // New or changed values are handled during onManifest.
+ ExtensionPreferencesManager.removeSetting(id, "homepage_override");
+ }
+
+ let search_provider = manifest?.chrome_settings_overrides?.search_provider;
+
+ if (!search_provider) {
+ // Remove setting and engine from search if necessary.
+ this.removeSearchSettings(id);
+ } else if (!search_provider.is_default) {
+ // Remove the setting, but keep the engine in search.
+ 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 Services.search.promiseInitialized 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 ensureSetting(engineName, disable = false) {
+ let { extension } = this;
+ // Ensure the addon always has a setting
+ await ExtensionSettingsStore.initialize();
+ let item = ExtensionSettingsStore.getSetting(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ extension.id
+ );
+ if (!item) {
+ let defaultEngine = await Services.search.getDefault();
+ item = await ExtensionSettingsStore.addSetting(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ engineName,
+ () => defaultEngine.name
+ );
+ // If there was no setting, we're fixing old behavior in this api.
+ // A lack of a setting would mean it was disabled before, disable it now.
+ disable =
+ disable ||
+ ["ADDON_UPGRADE", "ADDON_DOWNGRADE", "ADDON_ENABLE"].includes(
+ extension.startupReason
+ );
+ }
+
+ // Ensure the item is disabled (either if exists and is not default or if it does not
+ // exist yet).
+ if (disable) {
+ item = await ExtensionSettingsStore.disable(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+ return item;
+ }
+
+ async promptDefaultSearch(engineName) {
+ let { extension } = this;
+ // Don't ask if it is already the current engine
+ let engine = Services.search.getEngineByName(engineName);
+ let defaultEngine = await Services.search.getDefault();
+ if (defaultEngine.name == engine.name) {
+ return;
+ }
+ // Ensures the setting exists and is disabled. If the
+ // user somehow bypasses the prompt, we do not want this
+ // setting enabled for this extension.
+ await this.ensureSetting(engineName, true);
+
+ 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.
+ // As well, we still notify if no topWindow exists to support
+ // testing from xpcshell.
+ browser: windowTracker.topWindow?.gBrowser.selectedBrowser,
+ id: extension.id,
+ name: extension.name,
+ icon: extension.getPreferredIcon(32),
+ currentEngine: defaultEngine.name,
+ newEngine: engineName,
+ async respond(allow) {
+ if (allow) {
+ await chrome_settings_overrides.processDefaultSearchSetting(
+ "enable",
+ extension.id
+ );
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ }
+ // For testing
+ Services.obs.notifyObservers(
+ null,
+ "webextension-defaultsearch-prompt-response"
+ );
+ },
+ },
+ };
+ Services.obs.notifyObservers(subject, "webextension-defaultsearch-prompt");
+ }
+
+ 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. Search Service will also prevent overriding a builtin
+ // engine appropriately.
+ if (!searchProvider.is_default) {
+ await this.addSearchEngine();
+ return;
+ }
+
+ await Services.search.promiseInitialized;
+ 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);
+ // This will only be set to true when the specified engine is an app-provided
+ // engine, or when it is an allowed add-on defined in the list stored in
+ // SearchDefaultOverrideAllowlistHandler.
+ if (result.canChangeToAppProvided) {
+ await this.setDefault(engineName, true);
+ }
+ 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") {
+ await this.promptDefaultSearch(engineName);
+ } else {
+ // Needs to be called every time to handle reenabling.
+ await this.setDefault(engineName);
+ }
+ }
+
+ async setDefault(engineName, skipEnablePrompt = false) {
+ let { extension } = this;
+
+ if (extension.startupReason === "ADDON_INSTALL") {
+ // 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 this.ensureSetting(engineName);
+ await Services.search.setDefault(
+ Services.search.getEngineByName(item.value),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (
+ ["ADDON_UPGRADE", "ADDON_DOWNGRADE", "ADDON_ENABLE"].includes(
+ extension.startupReason
+ )
+ ) {
+ // 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
+ );
+
+ // Check for an inconsistency between the value returned by getLevelOfcontrol
+ // and the current engine actually set.
+ if (
+ control === "controlled_by_this_extension" &&
+ Services.search.defaultEngine.name !== engineName
+ ) {
+ // Check for and fix any inconsistency between the extensions settings storage
+ // and the current engine actually set. If settings claims the extension is default
+ // but the search service claims otherwise, select what the search service claims
+ // (See Bug 1767550).
+ const allSettings = ExtensionSettingsStore.getAllSettings(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ for (const setting of allSettings) {
+ if (setting.value !== Services.search.defaultEngine.name) {
+ await ExtensionSettingsStore.disable(
+ setting.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+ }
+ 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),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (control === "controllable_by_this_extension") {
+ if (skipEnablePrompt) {
+ // For overriding app-provided engines, we don't prompt, so set
+ // the default straight away.
+ await chrome_settings_overrides.processDefaultSearchSetting(
+ "enable",
+ extension.id
+ );
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (extension.startupReason == "ADDON_ENABLE") {
+ // This extension has precedence, but is not in control. Ask the user.
+ await this.promptDefaultSearch(engineName);
+ }
+ }
+ }
+ }
+
+ async addSearchEngine() {
+ let { extension } = this;
+ try {
+ await Services.search.addEnginesFromExtension(extension);
+ } 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..88e7dae307
--- /dev/null
+++ b/browser/components/extensions/parent/ext-commands.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";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionShortcuts: "resource://gre/modules/ExtensionShortcuts.sys.mjs",
+});
+
+this.commands = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ onCommand({ fire }) {
+ let listener = (eventName, commandName) => {
+ fire.async(commandName);
+ };
+ this.on("command", listener);
+ return {
+ unregister: () => this.off("command", listener),
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onChanged({ fire }) {
+ let listener = (eventName, changeInfo) => {
+ fire.async(changeInfo);
+ };
+ this.on("shortcutChanged", listener);
+ return {
+ unregister: () => this.off("shortcutChanged", listener),
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ static onUninstall(extensionId) {
+ return ExtensionShortcuts.removeCommandsFromStorage(extensionId);
+ }
+
+ async onManifestEntry(entryName) {
+ let shortcuts = new ExtensionShortcuts({
+ extension: this.extension,
+ onCommand: name => this.emit("command", name),
+ onShortcutChanged: changeInfo => this.emit("shortcutChanged", changeInfo),
+ });
+ 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,
+ module: "commands",
+ event: "onCommand",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onChanged: new EventManager({
+ context,
+ module: "commands",
+ event: "onChanged",
+ extensionApi: this,
+ }).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..9da54b9cfc
--- /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 toolboxEvalOptions = await getToolboxEvalOptions(context);
+ const evalOptions = Object.assign({}, options, toolboxEvalOptions);
+
+ const commands = await context.getDevToolsCommands();
+ const evalResult = await commands.inspectedWindowCommand.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 commands = await context.getDevToolsCommands();
+ commands.inspectedWindowCommand.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..5c69b4a03b
--- /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..9f0dba5c25
--- /dev/null
+++ b/browser/components/extensions/parent/ext-devtools-panels.js
@@ -0,0 +1,691 @@
+/* -*- 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.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ BroadcastConduit: "resource://gre/modules/ConduitsParent.sys.mjs",
+});
+
+var { 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.fixupAndLoadURIString(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.`,
+ isToolSupported: toolbox => toolbox.commands.descriptorFront.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;
+ }
+
+ 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.fixupAndLoadURIString(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) {
+ // 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);
+
+ const toolboxEvalOptions = await getToolboxEvalOptions(context);
+
+ const commands = await context.getDevToolsCommands();
+ const target = commands.targetCommand.targetFront;
+ const consoleFront = await target.getFront("console");
+ toolboxEvalOptions.consoleFront = consoleFront;
+
+ const evalResult = await commands.inspectedWindowCommand.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 === "") {
+ icon = context.extension.getPreferredIcon(128);
+ }
+
+ 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..98efd25489
--- /dev/null
+++ b/browser/components/extensions/parent/ext-devtools.js
@@ -0,0 +1,510 @@
+/* -*- 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.defineESModuleGetters(this, {
+ DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs",
+});
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+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 { descriptorFront } = toolbox.commands;
+
+ if (!descriptorFront.isLocalTab) {
+ throw new Error(
+ "Unexpected target type: only local tabs are currently supported."
+ );
+ }
+
+ let parentWindow = descriptorFront.localTab.linkedBrowser.ownerGlobal;
+ let tab = parentWindow.gBrowser.getTabForBrowser(
+ descriptorFront.localTab.linkedBrowser
+ );
+
+ return tabTracker.getId(tab);
+};
+
+// 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.fixupAndLoadURIString(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.commands.descriptorFront.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.commands.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.commands.descriptorFront.isLocalTab ||
+ !this.extension.canAccessWindow(
+ toolbox.commands.descriptorFront.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.onToolboxReady = this.onToolboxReady.bind(this);
+ this.onToolboxDestroy = this.onToolboxDestroy.bind(this);
+
+ /* eslint-disable mozilla/balanced-listeners */
+ extension.on("add-permissions", (ignoreEvent, permissions) => {
+ if (permissions.permissions.includes("devtools")) {
+ Services.prefs.setBoolPref(
+ `${getDevToolsPrefBranchName(extension.id)}.enabled`,
+ true
+ );
+
+ this._initialize();
+ }
+ });
+
+ extension.on("remove-permissions", (ignoreEvent, permissions) => {
+ if (permissions.permissions.includes("devtools")) {
+ Services.prefs.setBoolPref(
+ `${getDevToolsPrefBranchName(extension.id)}.enabled`,
+ false
+ );
+
+ 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-ready", this.onToolboxReady);
+ 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-ready", this.onToolboxReady);
+ 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: {},
+ };
+ }
+
+ onToolboxReady(toolbox) {
+ if (
+ !toolbox.commands.descriptorFront.isLocalTab ||
+ !this.extension.canAccessWindow(
+ toolbox.commands.descriptorFront.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.commands.descriptorFront.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..5397caa85b
--- /dev/null
+++ b/browser/components/extensions/parent/ext-find.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/. */
+
+/* global tabTracker */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+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..b7e24aecaa
--- /dev/null
+++ b/browser/components/extensions/parent/ext-history.js
@@ -0,0 +1,326 @@
+/* -*- 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.defineESModuleGetters(this, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+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 mozIStorageRow into a HistoryItem
+ */
+const convertRowToHistoryItem = row => {
+ return {
+ id: row.getResultByName("guid"),
+ url: row.getResultByName("url"),
+ title: row.getResultByName("page_title"),
+ lastVisitTime: PlacesUtils.toDate(
+ row.getResultByName("last_visit_date")
+ ).getTime(),
+ visitCount: row.getResultByName("visit_count"),
+ };
+};
+
+/*
+ * Converts a mozIStorageRow into a VisitItem
+ */
+const convertRowToVisitItem = row => {
+ return {
+ id: row.getResultByName("guid"),
+ visitId: String(row.getResultByName("id")),
+ visitTime: PlacesUtils.toDate(row.getResultByName("visit_date")).getTime(),
+ referringVisitId: String(row.getResultByName("from_visit")),
+ transition: getTransition(row.getResultByName("visit_type")),
+ };
+};
+
+/*
+ * Converts a mozIStorageResultSet into an array of objects
+ */
+const accumulateNavHistoryResults = (resultSet, converter, results) => {
+ let row;
+ while ((row = resultSet.getNextRow())) {
+ results.push(converter(row));
+ }
+};
+
+function executeAsyncQuery(historyQuery, options, resultConverter) {
+ let results = [];
+ return new Promise((resolve, reject) => {
+ PlacesUtils.history.asyncExecuteLegacyQuery(historyQuery, options, {
+ handleResult(resultSet) {
+ accumulateNavHistoryResults(resultSet, resultConverter, results);
+ },
+ handleError(error) {
+ reject(
+ new Error(
+ "Async execution error (" + error.result + "): " + error.message
+ )
+ );
+ },
+ handleCompletion(reason) {
+ resolve(results);
+ },
+ });
+ });
+}
+
+this.history = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ onVisited({ 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 {
+ unregister() {
+ PlacesUtils.observers.removeListener(["page-visited"], listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onVisitRemoved({ fire }) {
+ const listener = events => {
+ const removedURLs = [];
+
+ for (const event of events) {
+ switch (event.type) {
+ case "history-cleared": {
+ fire.sync({ allHistory: true, urls: [] });
+ break;
+ }
+ case "page-removed": {
+ if (!event.isPartialVisistsRemoval) {
+ removedURLs.push(event.url);
+ }
+ break;
+ }
+ }
+ }
+
+ if (removedURLs.length) {
+ fire.sync({ allHistory: false, urls: removedURLs });
+ }
+ };
+
+ PlacesUtils.observers.addListener(
+ ["history-cleared", "page-removed"],
+ listener
+ );
+ return {
+ unregister() {
+ PlacesUtils.observers.removeListener(
+ ["history-cleared", "page-removed"],
+ listener
+ );
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onTitleChanged({ 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 {
+ unregister() {
+ PlacesUtils.observers.removeListener(
+ ["page-title-changed"],
+ listener
+ );
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ 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;
+ return executeAsyncQuery(
+ historyQuery,
+ options,
+ convertRowToHistoryItem
+ );
+ },
+
+ 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);
+ return executeAsyncQuery(
+ historyQuery,
+ options,
+ convertRowToVisitItem
+ );
+ },
+
+ onVisited: new EventManager({
+ context,
+ module: "history",
+ event: "onVisited",
+ extensionApi: this,
+ }).api(),
+
+ onVisitRemoved: new EventManager({
+ context,
+ module: "history",
+ event: "onVisitRemoved",
+ extensionApi: this,
+ }).api(),
+
+ onTitleChanged: new EventManager({
+ context,
+ module: "history",
+ event: "onTitleChanged",
+ extensionApi: this,
+ }).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..74ce398b48
--- /dev/null
+++ b/browser/components/extensions/parent/ext-menus.js
@@ -0,0 +1,1471 @@
+/* -*- 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.defineESModuleGetters(this, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+var { DefaultMap, ExtensionError, parseMatchPatterns } = ExtensionUtils;
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+var { IconDetails, StartupCache } = 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 -> Map[ID -> MenuCreateProperties]]
+// The map object for each extension is a reference to the same
+// object in StartupCache.menus. This provides a non-async
+// getter for that object.
+var gStartupCache = 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.onAction ||
+ 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
+ );
+ if (!nextSibling) {
+ // The extension menu should be rendered at the top. If we use
+ // a navigation group (on non-macOS), the extension menu should
+ // come after that to avoid styling issues.
+ if (AppConstants.platform == "macosx") {
+ nextSibling = this.xulMenu.firstElementChild;
+ } else {
+ 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 &&
+ rootElement.getAttribute("type") !== "checkbox"
+ ) {
+ this.setMenuItemIcon(
+ rootElement,
+ root.extension,
+ contextData,
+ root.extension.manifest.icons
+ );
+ } else {
+ this.removeMenuItemIcon(rootElement);
+ }
+ }
+ }
+ return children;
+ },
+
+ 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");
+ }
+
+ 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 = event.button;
+
+ let _execute_action =
+ item.extension.manifestVersion < 3
+ ? "_execute_browser_action"
+ : "_execute_action";
+
+ // Allow menus to open various actions supported in webext prior
+ // to notifying onclicked.
+ let actionFor = {
+ [_execute_action]: global.browserActionFor,
+ _execute_page_action: global.pageActionFor,
+ _execute_sidebar_action: global.sidebarActionFor,
+ }[item.command];
+ if (actionFor) {
+ let win = event.target.ownerGlobal;
+ actionFor(item.extension).triggerAction(win);
+ return;
+ }
+
+ item.extension.emit(
+ "webext-menu-menuitem-click",
+ info,
+ contextData.tab
+ );
+ },
+ { once: true }
+ );
+
+ // 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.xulMenu.showHideSeparators?.();
+ },
+
+ // 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.onAction ||
+ 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;
+ }
+ }
+
+ if (this.xulMenu.showHideSeparators) {
+ this.xulMenu.showHideSeparators();
+ }
+ },
+
+ 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",
+ onAction: "action",
+ 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;
+ }
+}
+
+class MenuItem {
+ constructor(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);
+ }
+ }
+
+ static mergeProps(obj, properties) {
+ for (let propName in properties) {
+ if (properties[propName] === null) {
+ // Omitted optional argument.
+ continue;
+ }
+ obj[propName] = properties[propName];
+ }
+
+ if ("icons" in properties && properties.icons === null && obj.icons) {
+ obj.icons = null;
+ }
+ }
+
+ setProps(createProperties) {
+ MenuItem.mergeProps(this, createProperties);
+
+ 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."
+ );
+ }
+ }
+ }
+
+ /**
+ * When updating menu properties we need to ensure parents exist
+ * in the cache map before children. That allows the menus to be
+ * created in the correct sequence on startup. This reparents the
+ * tree starting from this instance of MenuItem.
+ */
+ reparentInCache() {
+ let { id, extension } = this;
+ let cachedMap = gStartupCache.get(extension);
+ let createProperties = cachedMap.get(id);
+ cachedMap.delete(id);
+ cachedMap.set(id, createProperties);
+
+ for (let child of this.children) {
+ child.reparentInCache();
+ }
+ }
+
+ 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);
+ // Menu items are saved if !extension.persistentBackground.
+ if (gStartupCache.get(this.extension)?.delete(this.id)) {
+ StartupCache.save();
+ }
+ 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 targetURIs = [];
+ if (contextData.onImage || contextData.onAudio || contextData.onVideo) {
+ // TODO: double check if srcUrl is always set when we need it
+ targetURIs.push(Services.io.newURI(contextData.srcUrl));
+ }
+ // contextData.linkURI may be null despite contextData.onLink, when
+ // contextData.linkUrl is an invalid URL.
+ if (contextData.onLink && contextData.linkURI) {
+ targetURIs.push(contextData.linkURI);
+ }
+ if (!targetURIs.some(targetURI => targetPattern.matches(targetURI))) {
+ 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);
+ const bookmarkId = node && PlacesUtils.getConcreteItemGuid(node);
+
+ if (!bookmarkId || PlacesUtils.isVirtualLeftPaneItem(bookmarkId)) {
+ return;
+ }
+
+ gMenuBuilder.build({ menu, bookmarkId, onBookmark: true });
+ },
+};
+
+this.menusInternal = class extends ExtensionAPIPersistent {
+ constructor(extension) {
+ super(extension);
+
+ if (!gMenuMap.size) {
+ menuTracker.register();
+ }
+ gMenuMap.set(extension, new Map());
+ }
+
+ restoreFromCache() {
+ let { extension } = this;
+ // ensure extension has not shutdown
+ if (!this.extension) {
+ return;
+ }
+ for (let createProperties of gStartupCache.get(extension).values()) {
+ // The order of menu creation is significant, see reparentInCache.
+ let menuItem = new MenuItem(extension, createProperties);
+ gMenuMap.get(extension).set(menuItem.id, menuItem);
+ }
+ // Used for testing
+ extension.emit("webext-menus-created", gMenuMap.get(extension));
+ }
+
+ async onStartup() {
+ let { extension } = this;
+ if (extension.persistentBackground) {
+ return;
+ }
+ // Using the map retains insertion order.
+ let cachedMenus = await StartupCache.menus.get(extension.id, () => {
+ return new Map();
+ });
+ gStartupCache.set(extension, cachedMenus);
+ if (!cachedMenus.size) {
+ return;
+ }
+
+ this.restoreFromCache();
+ }
+
+ onShutdown() {
+ let { extension } = this;
+
+ if (gMenuMap.has(extension)) {
+ gMenuMap.delete(extension);
+ gRootItems.delete(extension);
+ gShownMenuItems.delete(extension);
+ gStartupCache.delete(extension);
+ gOnShownSubscribers.delete(extension);
+ if (!gMenuMap.size) {
+ menuTracker.unregister();
+ }
+ }
+ }
+
+ PERSISTENT_EVENTS = {
+ onShown({ fire }) {
+ let { extension } = this;
+ 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(listener);
+ extension.on("webext-menu-shown", listener);
+ return {
+ unregister() {
+ const listeners = gOnShownSubscribers.get(extension);
+ listeners.delete(listener);
+ if (listeners.size === 0) {
+ gOnShownSubscribers.delete(extension);
+ }
+ extension.off("webext-menu-shown", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onHidden({ fire }) {
+ let { extension } = this;
+ let listener = () => {
+ fire.sync();
+ };
+ extension.on("webext-menu-hidden", listener);
+ return {
+ unregister() {
+ extension.off("webext-menu-hidden", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onClicked({ context, fire }) {
+ let { extension } = this;
+ let listener = async (event, info, nativeTab) => {
+ let { linkedBrowser } = nativeTab || tabTracker.activeTab;
+ let tab = nativeTab && extension.tabManager.convert(nativeTab);
+ if (fire.wakeup) {
+ // force the wakeup, thus the call to convert to get the context.
+ await fire.wakeup();
+ // If while waiting the tab disappeared we bail out.
+ if (
+ !linkedBrowser.ownerGlobal.gBrowser.getTabForBrowser(linkedBrowser)
+ ) {
+ Cu.reportError(
+ `menus.onClicked: target tab closed during background startup.`
+ );
+ return;
+ }
+ }
+ context.withPendingBrowser(linkedBrowser, () => fire.sync(info, tab));
+ };
+
+ extension.on("webext-menu-menuitem-click", listener);
+ return {
+ unregister() {
+ extension.off("webext-menu-menuitem-click", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+
+ const menus = {
+ refresh() {
+ gMenuBuilder.rebuildMenu(extension);
+ },
+
+ onShown: new EventManager({
+ context,
+ module: "menusInternal",
+ event: "onShown",
+ name: "menus.onShown",
+ extensionApi: this,
+ }).api(),
+ onHidden: new EventManager({
+ context,
+ module: "menusInternal",
+ event: "onHidden",
+ name: "menus.onHidden",
+ extensionApi: this,
+ }).api(),
+ };
+
+ return {
+ contextMenus: menus,
+ menus,
+ menusInternal: {
+ create(createProperties) {
+ // event pages require id
+ if (!extension.persistentBackground) {
+ if (!createProperties.id) {
+ throw new ExtensionError(
+ "menus.create requires an id for non-persistent background scripts."
+ );
+ }
+ if (gMenuMap.get(extension).has(createProperties.id)) {
+ throw new ExtensionError(
+ `The menu id ${createProperties.id} already exists in menus.create.`
+ );
+ }
+ }
+
+ // Note that the id is required by the schema. If the addon did not set
+ // it, the implementation of menus.create in the child will add it for
+ // extensions with persistent backgrounds, but not otherwise.
+ let menuItem = new MenuItem(extension, createProperties);
+ gMenuMap.get(extension).set(menuItem.id, menuItem);
+ if (!extension.persistentBackground) {
+ // Only cache properties that are necessary.
+ let cached = {};
+ MenuItem.mergeProps(cached, createProperties);
+ gStartupCache.get(extension).set(menuItem.id, cached);
+ StartupCache.save();
+ }
+ },
+
+ update(id, updateProperties) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (!menuItem) {
+ return;
+ }
+ menuItem.setProps(updateProperties);
+
+ // Update the startup cache for non-persistent extensions.
+ if (extension.persistentBackground) {
+ return;
+ }
+
+ let cached = gStartupCache.get(extension).get(id);
+ let reparent =
+ updateProperties.parentId != null &&
+ cached.parentId != updateProperties.parentId;
+ MenuItem.mergeProps(cached, updateProperties);
+ if (reparent) {
+ // The order of menu creation is significant, see reparentInCache.
+ menuItem.reparentInCache();
+ }
+ StartupCache.save();
+ },
+
+ remove(id) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (menuItem) {
+ menuItem.remove();
+ }
+ },
+
+ removeAll() {
+ let root = gRootItems.get(extension);
+ if (root) {
+ root.remove();
+ }
+ // Should be empty, just extra assurance.
+ if (!extension.persistentBackground) {
+ let cached = gStartupCache.get(extension);
+ if (cached.size) {
+ cached.clear();
+ StartupCache.save();
+ }
+ }
+ },
+
+ onClicked: new EventManager({
+ context,
+ module: "menusInternal",
+ event: "onClicked",
+ name: "menus.onClicked",
+ extensionApi: this,
+ }).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..0fcca4c678
--- /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.importESModule(
+ "resource://normandy/lib/AddonStudies.sys.mjs"
+);
+const { ClientID } = ChromeUtils.importESModule(
+ "resource://gre/modules/ClientID.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+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..363db67325
--- /dev/null
+++ b/browser/components/extensions/parent/ext-omnibox.js
@@ -0,0 +1,177 @@
+/* -*- 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.defineESModuleGetters(this, {
+ ExtensionSearchHandler:
+ "resource://gre/modules/ExtensionSearchHandler.sys.mjs",
+});
+
+this.omnibox = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ onInputStarted({ fire }) {
+ let { extension } = this;
+ let listener = eventName => {
+ fire.sync();
+ };
+ extension.on(ExtensionSearchHandler.MSG_INPUT_STARTED, listener);
+ return {
+ unregister() {
+ extension.off(ExtensionSearchHandler.MSG_INPUT_STARTED, listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onInputCancelled({ fire }) {
+ let { extension } = this;
+ let listener = eventName => {
+ fire.sync();
+ };
+ extension.on(ExtensionSearchHandler.MSG_INPUT_CANCELLED, listener);
+ return {
+ unregister() {
+ extension.off(ExtensionSearchHandler.MSG_INPUT_CANCELLED, listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onInputEntered({ fire }) {
+ let { extension } = this;
+ let listener = (eventName, text, disposition) => {
+ fire.sync(text, disposition);
+ };
+ extension.on(ExtensionSearchHandler.MSG_INPUT_ENTERED, listener);
+ return {
+ unregister() {
+ extension.off(ExtensionSearchHandler.MSG_INPUT_ENTERED, listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onInputChanged({ fire }) {
+ let { extension } = this;
+ let listener = (eventName, text, id) => {
+ fire.sync(text, id);
+ };
+ extension.on(ExtensionSearchHandler.MSG_INPUT_CHANGED, listener);
+ return {
+ unregister() {
+ extension.off(ExtensionSearchHandler.MSG_INPUT_CHANGED, listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onDeleteSuggestion({ fire }) {
+ let { extension } = this;
+ let listener = (eventName, text) => {
+ fire.sync(text);
+ };
+ extension.on(ExtensionSearchHandler.MSG_INPUT_DELETED, listener);
+ return {
+ unregister() {
+ extension.off(ExtensionSearchHandler.MSG_INPUT_DELETED, listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ 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) {
+ 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,
+ module: "omnibox",
+ event: "onInputStarted",
+ extensionApi: this,
+ }).api(),
+
+ onInputCancelled: new EventManager({
+ context,
+ module: "omnibox",
+ event: "onInputCancelled",
+ extensionApi: this,
+ }).api(),
+
+ onInputEntered: new EventManager({
+ context,
+ module: "omnibox",
+ event: "onInputEntered",
+ extensionApi: this,
+ }).api(),
+
+ onInputChanged: new EventManager({
+ context,
+ module: "omnibox",
+ event: "onInputChanged",
+ extensionApi: this,
+ }).api(),
+
+ onDeleteSuggestion: new EventManager({
+ context,
+ module: "omnibox",
+ event: "onDeleteSuggestion",
+ extensionApi: this,
+ }).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.
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/parent/ext-pageAction.js b/browser/components/extensions/parent/ext-pageAction.js
new file mode 100644
index 0000000000..aa45be8256
--- /dev/null
+++ b/browser/components/extensions/parent/ext-pageAction.js
@@ -0,0 +1,383 @@
+/* -*- 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.defineESModuleGetters(this, {
+ BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs",
+ ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
+ PageActions: "resource:///modules/PageActions.sys.mjs",
+ PanelPopup: "resource:///modules/ExtensionPopups.sys.mjs",
+});
+
+var { DefaultWeakMap } = ExtensionUtils;
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { PageActionBase } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionActions.sys.mjs"
+);
+
+// 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);
+ }
+
+ dispatchClick(tab, clickInfo) {
+ this.buttonDelegate.emit("click", tab, clickInfo);
+ }
+
+ getTab(tabId) {
+ if (tabId !== null) {
+ return tabTracker.getTab(tabId);
+ }
+ return null;
+ }
+}
+
+this.pageAction = class extends ExtensionAPIPersistent {
+ 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;
+ }
+
+ // 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.tabManager.addActiveTabPermission(tab);
+ this.action.dispatchClick(tab, {
+ button: event.button,
+ modifiers: clickModifiersFromEvent(event),
+ });
+ });
+ };
+
+ this.browserPageAction = PageActions.addAction(
+ new PageActions.Action({
+ id: widgetId,
+ extensionID: extension.id,
+ title: this.action.getProperty(null, "title"),
+ iconURL: this.action.getProperty(null, "icon"),
+ pinnedToUrlbar: this.action.getPinned(),
+ disabled: !this.action.getProperty(null, "enabled"),
+ onCommand: (event, buttonNode) => {
+ this.handleClick(event.target.ownerGlobal, {
+ button: event.button || 0,
+ modifiers: clickModifiersFromEvent(event),
+ });
+ },
+ 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) {
+ this.handleClick(window, { button: 0, modifiers: [] });
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "popupshowing":
+ const menu = event.target;
+ const trigger = menu.triggerNode;
+ const getActionId = () => {
+ let actionId = trigger.getAttribute("actionid");
+ if (actionId) {
+ return actionId;
+ }
+ // When a page action is clicked, triggerNode will be an ancestor of
+ // a node corresponding to an action. triggerNode will be the page
+ // action node itself when a page action is selected with the
+ // keyboard. That's because the semantic meaning of page action is on
+ // an hbox that contains an <image>.
+ for (let n = trigger; n && !actionId; n = n.parentElement) {
+ if (n.id == "page-action-buttons" || n.localName == "panelview") {
+ // We reached the page-action-buttons or panelview container.
+ // Stop looking; no action was found.
+ break;
+ }
+ actionId = n.getAttribute("actionid");
+ }
+ return actionId;
+ };
+ if (
+ menu.id === "pageActionContextMenu" &&
+ trigger &&
+ getActionId() === 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, clickInfo) {
+ const { extension } = this;
+
+ ExtensionTelemetry.pageActionPopupOpen.stopwatchStart(extension, this);
+ let tab = window.gBrowser.selectedTab;
+ let popupURL = this.action.triggerClickOrPopup(tab, clickInfo);
+
+ // 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);
+ }
+ }
+
+ PERSISTENT_EVENTS = {
+ onClicked({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+
+ let listener = async (_event, tab, clickInfo) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ // TODO: we should double-check if the tab is already being closed by the time
+ // the background script got started and we converted the primed listener.
+ context?.withPendingBrowser(tab.linkedBrowser, () =>
+ fire.sync(tabManager.convert(tab), clickInfo)
+ );
+ };
+
+ this.on("click", listener);
+ return {
+ unregister: () => {
+ this.off("click", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ const { action } = this;
+
+ return {
+ pageAction: {
+ ...action.api(context),
+
+ onClicked: new EventManager({
+ context,
+ module: "pageAction",
+ event: "onClicked",
+ inputHandling: true,
+ extensionApi: this,
+ }).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..696133bfc5
--- /dev/null
+++ b/browser/components/extensions/parent/ext-pkcs11.js
@@ -0,0 +1,187 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If 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.defineESModuleGetters(this, {
+ NativeManifests: "resource://gre/modules/NativeManifests.sys.mjs",
+ ctypes: "resource://gre/modules/ctypes.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "pkcs11db",
+ "@mozilla.org/security/pkcs11moduledb;1",
+ "nsIPKCS11ModuleDB"
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["PathUtils"]);
+
+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) {
+ // We don't normalize the absolute path below because
+ // `Path.normalize` throws when the target file doesn't
+ // exist, and that might be the case on non Windows
+ // builds.
+ let absolutePath = PathUtils.isAbsolute(hostInfo.manifest.path)
+ ? hostInfo.manifest.path
+ : PathUtils.joinRelative(
+ PathUtils.parent(hostInfo.path),
+ hostInfo.manifest.path
+ );
+
+ if (AppConstants.platform === "win") {
+ // On Windows, `hostInfo.manifest.path` is expected to be a normalized
+ // absolute path. On other platforms, this path may be relative but we
+ // cannot use `PathUtils.normalize()` on non-absolute paths.
+ absolutePath = PathUtils.normalize(absolutePath);
+ hostInfo.manifest.path = absolutePath;
+ }
+
+ // PathUtils.filename throws if the path is not an absolute path.
+ // The result is expected to be the basename of the file (without
+ // the dir path and the extension) so it is fine to use an absolute
+ // path that may not be normalized (non-Windows platforms).
+ let manifestLib = PathUtils.filename(absolutePath);
+
+ if (AppConstants.platform !== "linux") {
+ manifestLib = manifestLib.toLowerCase(manifestLib);
+ }
+ if (
+ manifestLib !== ctypes.libraryName("nssckbi") &&
+ manifestLib !== ctypes.libraryName("osclientcerts") &&
+ manifestLib !== ctypes.libraryName("ipcclientcerts")
+ ) {
+ 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..4fe7a096f4
--- /dev/null
+++ b/browser/components/extensions/parent/ext-search.js
@@ -0,0 +1,113 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+var { ExtensionError } = ExtensionUtils;
+
+const dispositionMap = {
+ CURRENT_TAB: "current",
+ NEW_TAB: "tab",
+ NEW_WINDOW: "window",
+};
+
+this.search = class extends ExtensionAPI {
+ getAPI(context) {
+ function getTarget({ tabId, disposition, defaultDisposition }) {
+ let tab, where;
+ if (disposition) {
+ if (tabId) {
+ throw new ExtensionError(`Cannot set both 'disposition' and 'tabId'`);
+ }
+ where = dispositionMap[disposition];
+ } else if (tabId) {
+ tab = tabTracker.getTab(tabId);
+ } else {
+ where = dispositionMap[defaultDisposition];
+ }
+ return { tab, where };
+ }
+
+ return {
+ search: {
+ async get() {
+ await Services.search.promiseInitialized;
+ let visibleEngines = await Services.search.getVisibleEngines();
+ let defaultEngine = await Services.search.getDefault();
+ return Promise.all(
+ visibleEngines.map(async engine => {
+ let favIconUrl = engine.getIconURL();
+ // 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 (
+ favIconUrl &&
+ favIconUrl.startsWith("moz-extension:") &&
+ !favIconUrl.startsWith(context.extension.baseURL)
+ ) {
+ favIconUrl = await ExtensionUtils.makeDataURI(favIconUrl);
+ }
+
+ return {
+ name: engine.name,
+ isDefault: engine.name === defaultEngine.name,
+ alias: engine.alias || undefined,
+ favIconUrl,
+ };
+ })
+ );
+ },
+
+ async search(searchProperties) {
+ await Services.search.promiseInitialized;
+ let engine;
+
+ if (searchProperties.engine) {
+ engine = Services.search.getEngineByName(searchProperties.engine);
+ if (!engine) {
+ throw new ExtensionError(
+ `${searchProperties.engine} was not found`
+ );
+ }
+ }
+
+ let { tab, where } = getTarget({
+ tabId: searchProperties.tabId,
+ disposition: searchProperties.disposition,
+ defaultDisposition: "NEW_TAB",
+ });
+
+ await windowTracker.topWindow.BrowserSearch.loadSearchFromExtension({
+ query: searchProperties.query,
+ where,
+ engine,
+ tab,
+ triggeringPrincipal: context.principal,
+ });
+ },
+
+ async query(queryProperties) {
+ await Services.search.promiseInitialized;
+
+ let { tab, where } = getTarget({
+ tabId: queryProperties.tabId,
+ disposition: queryProperties.disposition,
+ defaultDisposition: "CURRENT_TAB",
+ });
+
+ await windowTracker.topWindow.BrowserSearch.loadSearchFromExtension({
+ query: queryProperties.text,
+ where,
+ tab,
+ triggeringPrincipal: 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..ace6bf87be
--- /dev/null
+++ b/browser/components/extensions/parent/ext-sessions.js
@@ -0,0 +1,305 @@
+/* -*- 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.defineESModuleGetters(this, {
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+});
+
+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();
+ 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.getClosedTabDataForWindow(window);
+ 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.isChromeWindow) {
+ 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 ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ onChanged({ fire }) {
+ let observer = () => {
+ fire.async();
+ };
+
+ Services.obs.addObserver(observer, SS_ON_CLOSED_OBJECTS_CHANGED);
+ return {
+ unregister() {
+ Services.obs.removeObserver(observer, SS_ON_CLOSED_OBJECTS_CHANGED);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ 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 };
+ }
+
+ function getClosedIdFromSessionId(sessionId) {
+ // sessionId is a string, but internally closedId values are integers.
+ // convertFromSessionStoreClosedData in ext-browser.js does the opposite conversion.
+ let closedId = parseInt(sessionId, 10);
+ if (Number.isInteger(closedId)) {
+ return closedId;
+ }
+ throw new ExtensionError(`Invalid sessionId: ${sessionId}.`);
+ }
+
+ 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.getClosedTabDataForWindow(window);
+ let closedId = getClosedIdFromSessionId(sessionId);
+
+ let closedTabIndex = closedTabData.findIndex(closedTab => {
+ return closedTab.closedId === closedId;
+ });
+
+ 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();
+ let closedId = getClosedIdFromSessionId(sessionId);
+ let closedWindowIndex = closedWindowData.findIndex(closedWindow => {
+ return closedWindow.closedId === closedId;
+ });
+
+ 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;
+ let closedId;
+ if (sessionId) {
+ closedId = getClosedIdFromSessionId(sessionId);
+ }
+ let targetWindow;
+
+ // closedId is internally represented as an integer and could be 0.
+ if (closedId !== undefined) {
+ if (SessionStore.getObjectTypeForClosedId(closedId) == "tab") {
+ // we want to restore the tab to the original window is was closed from
+ targetWindow = SessionStore.getWindowForTabClosedId(
+ closedId,
+ extension.privateBrowsingAllowed
+ );
+ }
+ session = SessionStore.undoCloseById(
+ closedId,
+ extension.privateBrowsingAllowed,
+ targetWindow // ignored if we are restoring a window
+ );
+ } 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.getClosedTabDataForWindow(window);
+ 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;
+ // we want the tab to be re-opened into the same window it was closed from
+ targetWindow = SessionStore.getWindowForTabClosedId(
+ closedId,
+ extension.privateBrowsingAllowed
+ );
+ session = SessionStore.undoCloseById(
+ closedId,
+ extension.privateBrowsingAllowed,
+ targetWindow
+ );
+ }
+ }
+ 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,
+ module: "sessions",
+ event: "onChanged",
+ extensionApi: this,
+ }).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..648b34e557
--- /dev/null
+++ b/browser/components/extensions/parent/ext-sidebarAction.js
@@ -0,0 +1,520 @@
+/* -*- 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.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+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 = `menubar_menu_${this.id}`;
+ this.switcherMenuId = `sidebarswitcher_menu_${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();
+ }
+ document.getElementById(this.menuId)?.remove();
+ document.getElementById(this.switcherMenuId)?.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,
+ switcherMenuId: this.switcherMenuId,
+ // 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 switcherMenuitem = menuitem.cloneNode();
+ switcherMenuitem.setAttribute("id", this.switcherMenuId);
+ switcherMenuitem.removeAttribute("type");
+
+ document.getElementById("viewSidebarMenu").appendChild(menuitem);
+ let separator = document.getElementById("sidebar-extensions-separator");
+ separator.parentNode.insertBefore(switcherMenuitem, separator);
+
+ return menuitem;
+ }
+
+ setMenuIcon(menuitem, details) {
+ let getIcon = size =>
+ IconDetails.escapeUrl(
+ IconDetails.getPreferredIcon(details.icon, this.extension, size).icon
+ );
+
+ menuitem.setAttribute(
+ "style",
+ `
+ --webextension-menuitem-image: image-set(
+ url("${getIcon(16)}"),
+ url("${getIcon(32)}") 2x
+ );
+ `
+ );
+ }
+
+ /**
+ * 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.switcherMenuId);
+ 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.
+ * @param {number} [details.tabId]
+ * The target tab.
+ * @param {number} [details.windowId]
+ * The target window.
+ * @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..53d470e6f1
--- /dev/null
+++ b/browser/components/extensions/parent/ext-tabs.js
@@ -0,0 +1,1635 @@
+/* -*- 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.defineESModuleGetters(this, {
+ BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs",
+ DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
+ ExtensionControlledPopup:
+ "resource:///modules/ExtensionControlledPopup.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+});
+
+ChromeUtils.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;
+
+ChromeUtils.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 BrowserUIUtils.getLocalizedFragment(
+ doc,
+ message,
+ addonDetails,
+ image
+ );
+ },
+ 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 = Promise.withResolvers();
+ 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",
+ "autoDiscardable",
+]);
+const allProperties = new Set([
+ "attention",
+ "audible",
+ "autoDiscardable",
+ "discarded",
+ "favIconUrl",
+ "hidden",
+ "isArticle",
+ "mutedInfo",
+ "pinned",
+ "sharingState",
+ "status",
+ "title",
+ "url",
+]);
+const restricted = new Set(["url", "favIconUrl", "title"]);
+
+this.tabs = class extends ExtensionAPIPersistent {
+ 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);
+ }
+
+ tabEventRegistrar({ event, listener }) {
+ let { extension } = this;
+ let { tabManager } = extension;
+ return ({ fire }) => {
+ let listener2 = (eventName, eventData, ...args) => {
+ if (!tabManager.canAccessTab(eventData.nativeTab)) {
+ return;
+ }
+
+ listener(fire, eventData, ...args);
+ };
+
+ tabTracker.on(event, listener2);
+ return {
+ unregister() {
+ tabTracker.off(event, listener2);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ onActivated: this.tabEventRegistrar({
+ event: "tab-activated",
+ listener: (fire, event) => {
+ let { extension } = this;
+ let { tabId, windowId, previousTabId, previousTabIsPrivate } = event;
+ if (previousTabIsPrivate && !extension.privateBrowsingAllowed) {
+ previousTabId = undefined;
+ }
+ fire.async({ tabId, previousTabId, windowId });
+ },
+ }),
+ onAttached: this.tabEventRegistrar({
+ event: "tab-attached",
+ listener: (fire, event) => {
+ fire.async(event.tabId, {
+ newWindowId: event.newWindowId,
+ newPosition: event.newPosition,
+ });
+ },
+ }),
+ onCreated: this.tabEventRegistrar({
+ event: "tab-created",
+ listener: (fire, event) => {
+ let { tabManager } = this.extension;
+ fire.async(tabManager.convert(event.nativeTab, event.currentTabSize));
+ },
+ }),
+ onDetached: this.tabEventRegistrar({
+ event: "tab-detached",
+ listener: (fire, event) => {
+ fire.async(event.tabId, {
+ oldWindowId: event.oldWindowId,
+ oldPosition: event.oldPosition,
+ });
+ },
+ }),
+ onRemoved: this.tabEventRegistrar({
+ event: "tab-removed",
+ listener: (fire, event) => {
+ fire.async(event.tabId, {
+ windowId: event.windowId,
+ isWindowClosing: event.isWindowClosing,
+ });
+ },
+ }),
+ onMoved({ fire }) {
+ let { tabManager } = this.extension;
+ let moveListener = event => {
+ let nativeTab = event.originalTarget;
+ if (tabManager.canAccessTab(nativeTab)) {
+ fire.async(tabTracker.getId(nativeTab), {
+ windowId: windowTracker.getId(nativeTab.ownerGlobal),
+ fromIndex: event.detail,
+ toIndex: nativeTab._tPos,
+ });
+ }
+ };
+
+ windowTracker.addListener("TabMove", moveListener);
+ return {
+ unregister() {
+ windowTracker.removeListener("TabMove", moveListener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+
+ onHighlighted({ fire, context }) {
+ let { windowManager } = this.extension;
+ let highlightListener = (eventName, event) => {
+ // TODO see if we can avoid "context" here
+ let window = windowTracker.getWindow(event.windowId, context, false);
+ if (!window) {
+ return;
+ }
+ let windowWrapper = windowManager.getWrapper(window);
+ if (!windowWrapper) {
+ return;
+ }
+ let tabIds = Array.from(
+ windowWrapper.getHighlightedTabs(),
+ tab => tab.id
+ );
+ fire.async({ tabIds: tabIds, windowId: event.windowId });
+ };
+
+ tabTracker.on("tabs-highlighted", highlightListener);
+ return {
+ unregister() {
+ tabTracker.off("tabs-highlighted", highlightListener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+
+ onUpdated({ fire, context }, params) {
+ let { extension } = this;
+ let { tabManager } = extension;
+ let [filterProps] = params;
+ 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
+ // and events that are triggered while tabs are swapped between windows.
+ if (
+ event.originalTarget.initializingTab ||
+ event.originalTarget.ownerGlobal.gBrowserInit?.isAdoptingTab()
+ ) {
+ return;
+ }
+ if (!extension.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("undiscardable") &&
+ filter.properties.has("autoDiscardable")
+ ) {
+ needed.push("autoDiscardable");
+ }
+ 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 (!extension.canAccessWindow(tabElem.ownerGlobal)) {
+ return;
+ }
+
+ let changed = {};
+ if (filter.properties.has("status")) {
+ changed.status = status;
+ }
+ if (url && filter.properties.has("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 && extension.canAccessWindow(nativeTab.ownerGlobal)) {
+ let tab = tabManager.getWrapper(nativeTab);
+ fireForTab(tab, { isArticle: message.data.isArticle }, nativeTab);
+ }
+ };
+
+ let listeners = new Map();
+ if (filter.properties.has("status") || filter.properties.has("url")) {
+ 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 {
+ unregister() {
+ for (let [name, listener] of listeners) {
+ windowTracker.removeListener(name, listener);
+ }
+
+ if (filter.properties.has("isArticle")) {
+ tabTracker.off("tab-isarticle", isArticleChangeListener);
+ }
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager, windowManager } = extension;
+ let extensionApi = this;
+ let module = "tabs";
+
+ function getTabOrActive(tabId) {
+ let tab =
+ tabId !== null ? tabTracker.getTab(tabId) : tabTracker.activeTab;
+ if (!tabManager.canAccessTab(tab)) {
+ 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 (!tabManager.canAccessTab(tab)) {
+ 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;
+ }
+
+ function setContentTriggeringPrincipal(url, browser, options) {
+ // For urls that we want to allow an extension to open in a tab, but
+ // that it may not otherwise have access to, we set the triggering
+ // principal to the url that is being opened. This is used for newtab,
+ // about: and moz-extension: protocols.
+ options.triggeringPrincipal =
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(url),
+ {
+ userContextId: options.userContextId,
+ privateBrowsingId: PrivateBrowsingUtils.isBrowserPrivate(browser)
+ ? 1
+ : 0,
+ }
+ );
+ }
+
+ let tabsApi = {
+ tabs: {
+ onActivated: new EventManager({
+ context,
+ module,
+ event: "onActivated",
+ extensionApi,
+ }).api(),
+
+ onCreated: new EventManager({
+ context,
+ module,
+ event: "onCreated",
+ extensionApi,
+ }).api(),
+
+ onHighlighted: new EventManager({
+ context,
+ module,
+ event: "onHighlighted",
+ extensionApi,
+ }).api(),
+
+ onAttached: new EventManager({
+ context,
+ module,
+ event: "onAttached",
+ extensionApi,
+ }).api(),
+
+ onDetached: new EventManager({
+ context,
+ module,
+ event: "onDetached",
+ extensionApi,
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ module,
+ event: "onRemoved",
+ extensionApi,
+ }).api(),
+
+ onReplaced: new EventManager({
+ context,
+ name: "tabs.onReplaced",
+ register: fire => {
+ return () => {};
+ },
+ }).api(),
+
+ onMoved: new EventManager({
+ context,
+ module,
+ event: "onMoved",
+ extensionApi,
+ }).api(),
+
+ onUpdated: new EventManager({
+ context,
+ module,
+ event: "onUpdated",
+ extensionApi,
+ }).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 options = { triggeringPrincipal: context.principal };
+ 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 (
+ !url.startsWith("moz-extension://") &&
+ !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;
+ }
+ let discardable = url && !url.startsWith("about:");
+ // Handle moz-ext separately from the discardable flag to retain prior behavior.
+ if (!discardable || url.startsWith("moz-extension://")) {
+ setContentTriggeringPrincipal(url, window.gBrowser, options);
+ }
+
+ 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.`,
+ });
+ }
+
+ 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);
+ }
+
+ if (createProperties.muted) {
+ nativeTab.toggleMuteAudio(extension.id);
+ }
+
+ 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);
+
+ let options = {
+ flags: updateProperties.loadReplace
+ ? Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY
+ : Ci.nsIWebNavigation.LOAD_FLAGS_NONE,
+ triggeringPrincipal: context.principal,
+ };
+
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ // We allow loading top level tabs for "other" extensions.
+ if (url.startsWith("moz-extension://")) {
+ setContentTriggeringPrincipal(url, tabbrowser, options);
+ } else {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+ }
+
+ let browser = nativeTab.linkedBrowser;
+ if (nativeTab.linkedPanel) {
+ browser.fixupAndLoadURIString(url, options);
+ } else {
+ // Shift to fully loaded browser and make
+ // sure load handler is instantiated.
+ nativeTab.addEventListener(
+ "SSTabRestoring",
+ () => browser.fixupAndLoadURIString(url, options),
+ { once: true }
+ );
+ tabbrowser._insertBrowser(nativeTab);
+ }
+ }
+
+ if (updateProperties.active) {
+ tabbrowser.selectedTab = nativeTab;
+ }
+ if (updateProperties.autoDiscardable !== null) {
+ nativeTab.undiscardable = !updateProperties.autoDiscardable;
+ }
+ 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);
+ if (!tabManager.canAccessTab(nativeTab)) {
+ throw new ExtensionError(`Invalid tab ID: ${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);
+ let results = await tab.queryContent("DetectLanguage", {});
+ return results[0];
+ },
+
+ 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 lastInsertionMap = 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 isSameWindow = nativeTab.ownerGlobal == window;
+ 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 (isSameWindow && gBrowser.tabs.length === 1) {
+ lastInsertionMap.set(window, 0);
+ continue;
+ }
+ // If moving between windows, be sure privacy matches. While gBrowser
+ // prevents this, we want to silently ignore it.
+ if (
+ !isSameWindow &&
+ PrivateBrowsingUtils.isBrowserPrivate(gBrowser) !=
+ PrivateBrowsingUtils.isBrowserPrivate(
+ nativeTab.ownerGlobal.gBrowser
+ )
+ ) {
+ continue;
+ }
+
+ let insertionPoint;
+ let lastInsertion = lastInsertionMap.get(window);
+ if (lastInsertion == null) {
+ insertionPoint = moveProperties.index;
+ let maxIndex = gBrowser.tabs.length - (isSameWindow ? 1 : 0);
+ if (insertionPoint == -1) {
+ // If the index is -1 it should go to the end of the tabs.
+ insertionPoint = maxIndex;
+ } else {
+ insertionPoint = Math.min(insertionPoint, maxIndex);
+ }
+ } else if (isSameWindow && nativeTab._tPos <= lastInsertion) {
+ // lastInsertion is the current index of the last inserted tab.
+ // insertionPoint is the desired index of the current tab *after* moving it.
+ // When the tab is moved, the last inserted tab will no longer be at index
+ // lastInsertion, but (lastInsertion - 1). To position the tabs adjacent to
+ // each other, the tab should therefore be at index (lastInsertion - 1 + 1).
+ insertionPoint = lastInsertion;
+ } else {
+ // In this case the last inserted tab will stay at index lastInsertion,
+ // so we should move the current tab to index (lastInsertion + 1).
+ insertionPoint = lastInsertion + 1;
+ }
+
+ // 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 (isSameWindow) {
+ // If the window we are moving is the same, just move the tab.
+ gBrowser.moveTabTo(nativeTab, insertionPoint);
+ } else {
+ // 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);
+ }
+ lastInsertionMap.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();
+ },
+
+ async getZoomSettings(tabId) {
+ let nativeTab = getTabOrActive(tabId);
+
+ let { FullZoom, ZoomUI } = nativeTab.ownerGlobal;
+
+ return {
+ mode: "automatic",
+ scope: FullZoom.siteSpecific ? "per-origin" : "per-tab",
+ defaultZoomFactor: await ZoomUI.getGlobalValue(),
+ };
+ },
+
+ async setZoomSettings(tabId, settings) {
+ let nativeTab = getTabOrActive(tabId);
+
+ let currentSettings = await this.getZoomSettings(
+ tabTracker.getId(nativeTab)
+ );
+
+ if (
+ !Object.keys(settings).every(
+ key => settings[key] === currentSettings[key]
+ )
+ ) {
+ throw new ExtensionError(
+ `Unsupported zoom settings: ${JSON.stringify(settings)}`
+ );
+ }
+ },
+
+ 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 = async 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: await tabsApi.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(activeTab.linkedBrowser.browsingContext);
+ },
+
+ // Legacy API
+ printPreview() {
+ return Promise.resolve(this.print());
+ },
+
+ 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.createNewPrintSettings();
+
+ printSettings.printerName = "";
+ printSettings.isInitializedFromPrinter = true;
+ printSettings.isInitializedFromPrefs = true;
+
+ printSettings.outputDestination =
+ Ci.nsIPrintSettings.kOutputDestinationFile;
+ printSettings.toFileName = picker.file.path;
+
+ printSettings.printSilent = true;
+
+ 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.browsingContext
+ .print(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 (!tabManager.canAccessTab(tab)) {
+ 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 || !tabManager.canAccessTab(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 tabsApi;
+ }
+};
diff --git a/browser/components/extensions/parent/ext-topSites.js b/browser/components/extensions/parent/ext-topSites.js
new file mode 100644
index 0000000000..1400a7c236
--- /dev/null
+++ b/browser/components/extensions/parent/ext-topSites.js
@@ -0,0 +1,117 @@
+/* -*- 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.defineESModuleGetters(this, {
+ AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs",
+ NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
+ getSearchProvider: "resource://activity-stream/lib/SearchShortcuts.sys.mjs",
+ shortURL: "resource://activity-stream/lib/ShortURL.sys.mjs",
+});
+
+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..cff36a8762
--- /dev/null
+++ b/browser/components/extensions/parent/ext-url-overrides.js
@@ -0,0 +1,205 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs",
+ ExtensionControlledPopup:
+ "resource:///modules/ExtensionControlledPopup.sys.mjs",
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+});
+
+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";
+
+ChromeUtils.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",
+ 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, Services.io.newURI("about:blank"));
+ Services.obs.addObserver(
+ {
+ async observe() {
+ await replaceUrlInTab(
+ gBrowser,
+ tab,
+ Services.io.newURI(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-windows.js b/browser/components/extensions/parent/ext-windows.js
new file mode 100644
index 0000000000..3691ecdf56
--- /dev/null
+++ b/browser/components/extensions/parent/ext-windows.js
@@ -0,0 +1,544 @@
+/* -*- 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.defineESModuleGetters(this, {
+ HomePage: "resource:///modules/HomePage.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+var { ExtensionError, promiseObserved } = ExtensionUtils;
+
+function sanitizePositionParams(params, window = null, positionOffset = 0) {
+ if (params.left === null && params.top === null) {
+ return;
+ }
+
+ if (params.left === null) {
+ const baseLeft = window ? window.screenX : 0;
+ params.left = baseLeft + positionOffset;
+ }
+ if (params.top === null) {
+ const baseTop = window ? window.screenY : 0;
+ params.top = baseTop + positionOffset;
+ }
+
+ // boundary check: don't put window out of visible area
+ const baseWidth = window ? window.outerWidth : 0;
+ const baseHeight = window ? window.outerHeight : 0;
+ // Secure minimum size of an window should be same to the one
+ // defined at nsGlobalWindowOuter::CheckSecurityWidthAndHeight.
+ const minWidth = 100;
+ const minHeight = 100;
+ const width = Math.max(
+ minWidth,
+ params.width !== null ? params.width : baseWidth
+ );
+ const height = Math.max(
+ minHeight,
+ params.height !== null ? params.height : baseHeight
+ );
+ const screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService(
+ Ci.nsIScreenManager
+ );
+ const screen = screenManager.screenForRect(
+ params.left,
+ params.top,
+ width,
+ height
+ );
+ const availDeviceLeft = {};
+ const availDeviceTop = {};
+ const availDeviceWidth = {};
+ const availDeviceHeight = {};
+ screen.GetAvailRect(
+ availDeviceLeft,
+ availDeviceTop,
+ availDeviceWidth,
+ availDeviceHeight
+ );
+ const slopX = window?.screenEdgeSlopX || 0;
+ const slopY = window?.screenEdgeSlopY || 0;
+ const factor = screen.defaultCSSScaleFactor;
+ const availLeft = Math.floor(availDeviceLeft.value / factor) - slopX;
+ const availTop = Math.floor(availDeviceTop.value / factor) - slopY;
+ const availWidth = Math.floor(availDeviceWidth.value / factor) + slopX;
+ const availHeight = Math.floor(availDeviceHeight.value / factor) + slopY;
+ params.left = Math.min(
+ availLeft + availWidth - width,
+ Math.max(availLeft, params.left)
+ );
+ params.top = Math.min(
+ availTop + availHeight - height,
+ Math.max(availTop, params.top)
+ );
+}
+
+this.windows = class extends ExtensionAPIPersistent {
+ windowEventRegistrar(event, listener) {
+ let { extension } = this;
+ return ({ fire }) => {
+ let listener2 = (window, ...args) => {
+ if (extension.canAccessWindow(window)) {
+ listener(fire, window, ...args);
+ }
+ };
+
+ windowTracker.addListener(event, listener2);
+ return {
+ unregister() {
+ windowTracker.removeListener(event, listener2);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ onCreated: this.windowEventRegistrar("domwindowopened", (fire, window) => {
+ fire.async(this.extension.windowManager.convert(window));
+ }),
+ onRemoved: this.windowEventRegistrar("domwindowclosed", (fire, window) => {
+ fire.async(windowTracker.getId(window));
+ }),
+ onFocusChanged({ fire }) {
+ let { extension } = this;
+ // 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 && extension.canAccessWindow(window)) {
+ windowId = windowTracker.getId(window);
+ }
+ if (windowId !== lastOnFocusChangedWindowId) {
+ fire.async(windowId);
+ lastOnFocusChangedWindowId = windowId;
+ }
+ });
+ };
+ windowTracker.addListener("focus", listener);
+ windowTracker.addListener("blur", listener);
+ return {
+ unregister() {
+ windowTracker.removeListener("focus", listener);
+ windowTracker.removeListener("blur", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+
+ const { windowManager } = extension;
+
+ return {
+ windows: {
+ onCreated: new EventManager({
+ context,
+ module: "windows",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ module: "windows",
+ event: "onRemoved",
+ extensionApi: this,
+ }).api(),
+
+ onFocusChanged: new EventManager({
+ context,
+ module: "windows",
+ event: "onFocusChanged",
+ extensionApi: this,
+ }).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: async function (createData) {
+ let needResize =
+ createData.left !== null ||
+ createData.top !== null ||
+ createData.width !== null ||
+ createData.height !== null;
+ if (createData.incognito && !context.privateBrowsingAllowed) {
+ throw new ExtensionError(
+ "Extension does not have permission for incognito mode"
+ );
+ }
+
+ if (needResize) {
+ if (createData.state !== null && createData.state != "normal") {
+ throw new ExtensionError(
+ `"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
+ );
+
+ // Whether there is only one URL to load, and it is a moz-extension:-URL.
+ let isOnlyMozExtensionUrl = false;
+
+ // Creating a new window allows one single triggering principal for all tabs that
+ // are created in the window. Due to that, if we need a browser principal to load
+ // some urls, we fallback to using a content principal like we do in the tabs api.
+ // Throws if url is an array and any url can't be loaded by the extension principal.
+ let principal = context.principal;
+ function setContentTriggeringPrincipal(url) {
+ principal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(url),
+ {
+ // Note: privateBrowsingAllowed was already checked before.
+ privateBrowsingId: createData.incognito ? 1 : 0,
+ }
+ );
+ }
+
+ if (createData.tabId !== null) {
+ if (createData.url !== null) {
+ throw new ExtensionError(
+ "`tabId` may not be used in conjunction with `url`"
+ );
+ }
+
+ if (createData.allowScriptsToClose) {
+ throw new ExtensionError(
+ "`tabId` may not be used in conjunction with `allowScriptsToClose`"
+ );
+ }
+
+ let tab = tabTracker.getTab(createData.tabId);
+ if (!context.canAccessWindow(tab.ownerGlobal)) {
+ throw new ExtensionError(`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
+ ) {
+ throw new ExtensionError(
+ "`incognito` property must match the incognito state of tab"
+ );
+ }
+ createData.incognito = incognito;
+
+ if (
+ createData.cookieStoreId &&
+ createData.cookieStoreId !==
+ getCookieStoreIdForTab(createData, tab)
+ ) {
+ throw new ExtensionError(
+ "`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.map(u => context.uri.resolve(u))) {
+ // We can only provide a single triggering principal when
+ // opening a window, so if the extension cannot normally
+ // access a url, we fail. This includes about and moz-ext
+ // urls.
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+ array.appendElement(mkstr(url));
+ }
+ args.appendElement(array);
+ // TODO bug 1780583: support multiple triggeringPrincipals to
+ // avoid having to use the system principal here.
+ principal = Services.scriptSecurityManager.getSystemPrincipal();
+ } else {
+ let url = context.uri.resolve(createData.url);
+ args.appendElement(mkstr(url));
+ isOnlyMozExtensionUrl = url.startsWith("moz-extension://");
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ if (isOnlyMozExtensionUrl) {
+ // For backwards-compatibility (also in tabs APIs), we allow
+ // extensions to open other moz-extension:-URLs even if that
+ // other resource is not listed in web_accessible_resources.
+ setContentTriggeringPrincipal(url);
+ } else {
+ throw new ExtensionError(`Illegal URL: ${url}`);
+ }
+ }
+ }
+ } else {
+ let url =
+ createData.incognito &&
+ !PrivateBrowsingUtils.permanentPrivateBrowsing
+ ? "about:privatebrowsing"
+ : HomePage.get().split("|", 1)[0];
+ args.appendElement(mkstr(url));
+ isOnlyMozExtensionUrl = url.startsWith("moz-extension://");
+
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ // The extension principal cannot directly load about:-URLs,
+ // except for about:blank, or other moz-extension:-URLs that are
+ // not in web_accessible_resources. Ensure any page set as a home
+ // page will load by using a content principal.
+ setContentTriggeringPrincipal(url);
+ }
+ }
+
+ args.appendElement(null); // extraOptions
+ 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",
+ "titlebar",
+ "close"
+ );
+ if (createData.left === null && createData.top === null) {
+ features.push("centerscreen");
+ }
+ }
+
+ if (createData.incognito !== null) {
+ if (createData.incognito) {
+ if (!PrivateBrowsingUtils.enabled) {
+ throw new ExtensionError(
+ "`incognito` cannot be used if incognito mode is disabled"
+ );
+ }
+ features.push("private");
+ } else {
+ features.push("non-private");
+ }
+ }
+
+ const baseWindow = windowTracker.getTopNormalWindow(context);
+ // 10px offset is same to Chromium
+ sanitizePositionParams(createData, baseWindow, 10);
+
+ 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
+
+ const contentLoaded = new Promise(resolve => {
+ window.addEventListener(
+ "DOMContentLoaded",
+ function () {
+ let { allowScriptsToClose } = createData;
+ if (allowScriptsToClose === null && isOnlyMozExtensionUrl) {
+ allowScriptsToClose = true;
+ }
+ if (allowScriptsToClose) {
+ window.gBrowserAllowScriptsToCloseInitialTabs = true;
+ }
+ resolve();
+ },
+ { once: true }
+ );
+ });
+
+ const startupFinished = promiseObserved(
+ "browser-delayed-startup-finished",
+ win => win == window
+ );
+
+ await contentLoaded;
+ await startupFinished;
+
+ if (
+ [
+ "minimized",
+ "fullscreen",
+ "docked",
+ "normal",
+ "maximized",
+ ].includes(createData.state)
+ ) {
+ await win.setState(createData.state);
+ }
+
+ if (createData.titlePreface !== null) {
+ win.setTitlePreface(createData.titlePreface);
+ }
+ return win.convert({ populate: true });
+ },
+
+ update: async function (windowId, updateInfo) {
+ if (updateInfo.state !== null && updateInfo.state != "normal") {
+ if (
+ updateInfo.left !== null ||
+ updateInfo.top !== null ||
+ updateInfo.width !== null ||
+ updateInfo.height !== null
+ ) {
+ throw new ExtensionError(
+ `"state": "${updateInfo.state}" may not be combined with "left", "top", "width", or "height"`
+ );
+ }
+ }
+
+ let win = windowManager.get(windowId, context);
+ if (!win) {
+ throw new ExtensionError(`Invalid window ID: ${windowId}`);
+ }
+ if (updateInfo.focused) {
+ win.window.focus();
+ }
+
+ if (updateInfo.state !== null) {
+ await win.setState(updateInfo.state);
+ }
+
+ if (updateInfo.drawAttention) {
+ // Bug 1257497 - Firefox can't cancel attention actions.
+ win.window.getAttention();
+ }
+
+ sanitizePositionParams(updateInfo, win.window);
+ win.updateGeometry(updateInfo);
+
+ if (updateInfo.titlePreface !== null) {
+ win.setTitlePreface(updateInfo.titlePreface);
+ win.window.gBrowser.updateTitlebar();
+ }
+
+ // TODO: All the other properties, focused=false...
+
+ return 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);
+ });
+ },
+ },
+ };
+ }
+};