diff options
Diffstat (limited to 'browser/components/extensions/parent/ext-tabs.js')
-rw-r--r-- | browser/components/extensions/parent/ext-tabs.js | 1627 |
1 files changed, 1627 insertions, 0 deletions
diff --git a/browser/components/extensions/parent/ext-tabs.js b/browser/components/extensions/parent/ext-tabs.js new file mode 100644 index 0000000000..c8d6b9f6dd --- /dev/null +++ b/browser/components/extensions/parent/ext-tabs.js @@ -0,0 +1,1627 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "BrowserUIUtils", + "resource:///modules/BrowserUIUtils.jsm" +); +ChromeUtils.defineESModuleGetters(this, { + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", + ExtensionControlledPopup: + "resource:///modules/ExtensionControlledPopup.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(this, "strBundle", function () { + return Services.strings.createBundle( + "chrome://global/locale/extensions.properties" + ); +}); + +var { DefaultMap, ExtensionError } = ExtensionUtils; + +const TAB_HIDE_CONFIRMED_TYPE = "tabHideNotification"; + +const TAB_ID_NONE = -1; + +XPCOMUtils.defineLazyGetter(this, "tabHidePopup", () => { + return new ExtensionControlledPopup({ + confirmedType: TAB_HIDE_CONFIRMED_TYPE, + anchorId: "alltabs-button", + popupnotificationId: "extension-tab-hide-notification", + descriptionId: "extension-tab-hide-notification-description", + descriptionMessageId: "tabHideControlled.message", + getLocalizedDescription: (doc, message, addonDetails) => { + let image = doc.createXULElement("image"); + image.setAttribute("class", "extension-controlled-icon alltabs-icon"); + return BrowserUIUtils.getLocalizedFragment( + doc, + message, + addonDetails, + image + ); + }, + learnMoreMessageId: "tabHideControlled.learnMore", + learnMoreLink: "extension-hiding-tabs", + }); +}); + +function showHiddenTabs(id) { + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (win.closed || !win.gBrowser) { + continue; + } + + for (let tab of win.gBrowser.tabs) { + if ( + tab.hidden && + tab.ownerGlobal && + SessionStore.getCustomTabValue(tab, "hiddenBy") === id + ) { + win.gBrowser.showTab(tab); + } + } + } +} + +let tabListener = { + tabReadyInitialized: false, + // Map[tab -> Promise] + tabBlockedPromises: new WeakMap(), + // Map[tab -> Deferred] + tabReadyPromises: new WeakMap(), + initializingTabs: new WeakSet(), + + initTabReady() { + if (!this.tabReadyInitialized) { + windowTracker.addListener("progress", this); + + this.tabReadyInitialized = true; + } + }, + + onLocationChange(browser, webProgress, request, locationURI, flags) { + if (webProgress.isTopLevel) { + let { gBrowser } = browser.ownerGlobal; + let nativeTab = gBrowser.getTabForBrowser(browser); + + // Now we are certain that the first page in the tab was loaded. + this.initializingTabs.delete(nativeTab); + + // browser.innerWindowID is now set, resolve the promises if any. + let deferred = this.tabReadyPromises.get(nativeTab); + if (deferred) { + deferred.resolve(nativeTab); + this.tabReadyPromises.delete(nativeTab); + } + } + }, + + blockTabUntilRestored(nativeTab) { + let promise = ExtensionUtils.promiseEvent(nativeTab, "SSTabRestored").then( + ({ target }) => { + this.tabBlockedPromises.delete(target); + return target; + } + ); + + this.tabBlockedPromises.set(nativeTab, promise); + }, + + /** + * Returns a promise that resolves when the tab is ready. + * Tabs created via the `tabs.create` method are "ready" once the location + * changes to the requested URL. Other tabs are assumed to be ready once their + * inner window ID is known. + * + * @param {XULElement} nativeTab The <tab> element. + * @returns {Promise} Resolves with the given tab once ready. + */ + awaitTabReady(nativeTab) { + let deferred = this.tabReadyPromises.get(nativeTab); + if (!deferred) { + let promise = this.tabBlockedPromises.get(nativeTab); + if (promise) { + return promise; + } + deferred = PromiseUtils.defer(); + if ( + !this.initializingTabs.has(nativeTab) && + (nativeTab.linkedBrowser.innerWindowID || + nativeTab.linkedBrowser.currentURI.spec === "about:blank") + ) { + deferred.resolve(nativeTab); + } else { + this.initTabReady(); + this.tabReadyPromises.set(nativeTab, deferred); + } + } + return deferred.promise; + }, +}; + +const allAttrs = new Set([ + "attention", + "audible", + "favIconUrl", + "mutedInfo", + "sharingState", + "title", +]); +const allProperties = new Set([ + "attention", + "audible", + "discarded", + "favIconUrl", + "hidden", + "isArticle", + "mutedInfo", + "pinned", + "sharingState", + "status", + "title", + "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) { + 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("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.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; + } +}; |