/* -*- 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 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[ -> 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 . 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; } };