diff options
Diffstat (limited to 'browser/components/extensions/parent')
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); + }); + }, + }, + }; + } +}; |