diff options
Diffstat (limited to 'comm/mail/components/extensions/parent/ext-tabs.js')
-rw-r--r-- | comm/mail/components/extensions/parent/ext-tabs.js | 822 |
1 files changed, 822 insertions, 0 deletions
diff --git a/comm/mail/components/extensions/parent/ext-tabs.js b/comm/mail/components/extensions/parent/ext-tabs.js new file mode 100644 index 0000000000..6327743afa --- /dev/null +++ b/comm/mail/components/extensions/parent/ext-tabs.js @@ -0,0 +1,822 @@ +/* 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/. */ + +ChromeUtils.defineESModuleGetters(this, { + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + this, + "MailE10SUtils", + "resource:///modules/MailE10SUtils.jsm" +); + +var { ExtensionError } = ExtensionUtils; + +/** + * A listener that allows waiting until tabs are fully loaded, e.g. off of about:blank. + */ +let tabListener = { + tabReadyInitialized: false, + tabReadyPromises: new WeakMap(), + initializingTabs: new WeakSet(), + + /** + * Initialize the progress listener for tab ready changes. + */ + initTabReady() { + if (!this.tabReadyInitialized) { + windowTracker.addListener("progress", this); + + this.tabReadyInitialized = true; + } + }, + + /** + * Web Progress listener method for the location change. + * + * @param {Element} browser - The browser element that caused the change + * @param {nsIWebProgress} webProgress - The web progress for the location change + * @param {nsIRequest} request - The xpcom request for this change + * @param {nsIURI} locationURI - The target uri + * @param {Integer} flags - The web progress flags for this change + */ + onLocationChange(browser, webProgress, request, locationURI, flags) { + if (webProgress && webProgress.isTopLevel) { + let window = browser.ownerGlobal.top; + let tabmail = window.document.getElementById("tabmail"); + let nativeTabInfo = tabmail ? tabmail.getTabForBrowser(browser) : window; + + // Now we are certain that the first page in the tab was loaded. + this.initializingTabs.delete(nativeTabInfo); + + // browser.innerWindowID is now set, resolve the promises if any. + let deferred = this.tabReadyPromises.get(nativeTabInfo); + if (deferred) { + deferred.resolve(nativeTabInfo); + this.tabReadyPromises.delete(nativeTabInfo); + } + } + }, + + /** + * Promise that the given tab completes loading. + * + * @param {NativeTabInfo} nativeTabInfo - the tabInfo describing the tab + * @returns {Promise<NativeTabInfo>} - resolves when the tab completes loading + */ + awaitTabReady(nativeTabInfo) { + let deferred = this.tabReadyPromises.get(nativeTabInfo); + if (!deferred) { + deferred = PromiseUtils.defer(); + let browser = getTabBrowser(nativeTabInfo); + if ( + !this.initializingTabs.has(nativeTabInfo) && + (browser.innerWindowID || + ["about:blank", "about:blank?compose"].includes( + browser.currentURI.spec + )) + ) { + deferred.resolve(nativeTabInfo); + } else { + this.initTabReady(); + this.tabReadyPromises.set(nativeTabInfo, deferred); + } + } + return deferred.promise; + }, +}; + +let hasWebHandlerApp = protocol => { + let protoInfo = Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .getProtocolHandlerInfo(protocol); + let appHandlers = protoInfo.possibleApplicationHandlers; + for (let i = 0; i < appHandlers.length; i++) { + let handler = appHandlers.queryElementAt(i, Ci.nsISupports); + if (handler instanceof Ci.nsIWebHandlerApp) { + return true; + } + } + return false; +}; + +// Attributes and properties used in the TabsUpdateFilterManager. +const allAttrs = new Set(["favIconUrl", "title"]); +const allProperties = new Set(["favIconUrl", "status", "title"]); +const restricted = new Set(["url", "favIconUrl", "title"]); + +this.tabs = class extends ExtensionAPIPersistent { + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; + } + for (let window of Services.wm.getEnumerator("mail:3pane")) { + let tabmail = window.document.getElementById("tabmail"); + for (let i = tabmail.tabInfo.length; i > 0; i--) { + let nativeTabInfo = tabmail.tabInfo[i - 1]; + let uri = nativeTabInfo.browser?.browsingContext.currentURI; + if ( + uri && + uri.scheme == "moz-extension" && + uri.host == this.extension.uuid + ) { + tabmail.closeTab(nativeTabInfo); + } + } + } + } + + tabEventRegistrar({ tabEvent, listener }) { + let { extension } = this; + let { tabManager } = extension; + return ({ context, fire }) => { + let listener2 = async (eventName, event, ...args) => { + if (!tabManager.canAccessTab(event.nativeTab)) { + return; + } + if (fire.wakeup) { + await fire.wakeup(); + } + listener({ context, fire, event }, ...args); + }; + tabTracker.on(tabEvent, listener2); + return { + unregister() { + tabTracker.off(tabEvent, listener2); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }; + } + + PERSISTENT_EVENTS = { + // For primed persistent events (deactivated background), the context is only + // available after fire.wakeup() has fulfilled (ensuring the convert() function + // has been called) (handled by tabEventRegistrar). + + onActivated: this.tabEventRegistrar({ + tabEvent: "tab-activated", + listener: ({ context, fire, event }) => { + let { tabId, windowId, previousTabId } = event; + fire.async({ tabId, windowId, previousTabId }); + }, + }), + + onCreated: this.tabEventRegistrar({ + tabEvent: "tab-created", + listener: ({ context, fire, event }) => { + let { extension } = this; + let { tabManager } = extension; + fire.async(tabManager.convert(event.nativeTabInfo, event.currentTab)); + }, + }), + + onAttached: this.tabEventRegistrar({ + tabEvent: "tab-attached", + listener: ({ context, fire, event }) => { + fire.async(event.tabId, { + newWindowId: event.newWindowId, + newPosition: event.newPosition, + }); + }, + }), + + onDetached: this.tabEventRegistrar({ + tabEvent: "tab-detached", + listener: ({ context, fire, event }) => { + fire.async(event.tabId, { + oldWindowId: event.oldWindowId, + oldPosition: event.oldPosition, + }); + }, + }), + + onRemoved: this.tabEventRegistrar({ + tabEvent: "tab-removed", + listener: ({ context, fire, event }) => { + fire.async(event.tabId, { + windowId: event.windowId, + isWindowClosing: event.isWindowClosing, + }); + }, + }), + + onMoved({ context, fire }) { + let { tabManager } = this.extension; + let moveListener = async event => { + let nativeTab = event.target; + let nativeTabInfo = event.detail.tabInfo; + let tabmail = nativeTab.ownerDocument.getElementById("tabmail"); + if (tabManager.canAccessTab(nativeTab)) { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.async(tabTracker.getId(nativeTabInfo), { + windowId: windowTracker.getId(nativeTab.ownerGlobal), + fromIndex: event.detail.idx, + toIndex: tabmail.tabInfo.indexOf(nativeTabInfo), + }); + } + }; + + windowTracker.addListener("TabMove", moveListener); + return { + unregister() { + windowTracker.removeListener("TabMove", moveListener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + + onUpdated({ context, fire }, [filterProps]) { + let filter = { ...filterProps }; + let scheduledEvents = []; + + if ( + filter && + filter.urls && + !this.extension.hasPermission("tabs") && + !this.extension.hasPermission("activeTab") + ) { + console.error( + 'Url filtering in tabs.onUpdated requires "tabs" or "activeTab" permission.' + ); + return false; + } + + if (filter.urls) { + // TODO: Consider following M-C + // Use additional parameter { restrictSchemes: false }. + filter.urls = new MatchPatternSet(filter.urls); + } + let needsModified = true; + if (filter.properties) { + // Default is to listen for all events. + needsModified = filter.properties.some(prop => allAttrs.has(prop)); + 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 (tab.hasTabPermission || !restricted.has(prop)) { + nonempty = true; + result[prop] = changeInfo[prop]; + } + } + return nonempty && result; + } + + function getWindowID(windowId) { + if (windowId === WindowBase.WINDOW_ID_CURRENT) { + // TODO: Consider following M-C + // Use windowTracker.getTopWindow(context). + return windowTracker.getId(windowTracker.topWindow); + } + 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) { + // We check permission first because tab.uri is null if !hasTabPermission. + return tab.hasTabPermission && filter.urls.matches(tab.uri); + } + return true; + } + + let fireForTab = async (tab, changed) => { + if (!matchFilters(tab, changed)) { + return; + } + + let changeInfo = sanitize(tab, changed); + if (changeInfo) { + let tabInfo = tab.convert(); + // TODO: Consider following M-C + // Use tabTracker.maybeWaitForTabOpen(nativeTab).then(() => {}). + + // Using a FIFO to keep order of events, in case the last one + // gets through without being placed on the async callback stack. + scheduledEvents.push([tab.id, changeInfo, tabInfo]); + if (fire.wakeup) { + await fire.wakeup(); + } + fire.async(...scheduledEvents.shift()); + } + }; + + let listener = event => { + /* TODO: Consider following M-C + // 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 changeInfo = {}; + let { extension } = this; + let { tabManager } = extension; + let tab = tabManager.getWrapper(event.detail.tabInfo); + let changed = event.detail.changed; + if ( + changed.includes("favIconUrl") && + filter.properties.has("favIconUrl") + ) { + changeInfo.favIconUrl = tab.favIconUrl; + } + if (changed.includes("label") && filter.properties.has("title")) { + changeInfo.title = tab.title; + } + + fireForTab(tab, changeInfo); + }; + + let statusListener = ({ browser, status, url }) => { + let { extension } = this; + let { tabManager } = extension; + let tabId = tabTracker.getBrowserTabId(browser); + if (tabId != -1) { + let changed = { status }; + if (url) { + changed.url = url; + } + fireForTab(tabManager.get(tabId), changed); + } + }; + + if (needsModified) { + windowTracker.addListener("TabAttrModified", listener); + } + + if (filter.properties.has("status")) { + windowTracker.addListener("status", statusListener); + } + + return { + unregister() { + if (needsModified) { + windowTracker.removeListener("TabAttrModified", listener); + } + if (filter.properties.has("status")) { + windowTracker.removeListener("status", statusListener); + } + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + }; + + getAPI(context) { + let { extension } = context; + let { tabManager } = extension; + + /** + * Gets the tab for the given tab id, or the active tab if the id is null. + * + * @param {?Integer} tabId - The tab id to get + * @returns {Tab} The matching tab, or the active tab + */ + function getTabOrActive(tabId) { + if (tabId) { + return tabTracker.getTab(tabId); + } + return tabTracker.activeTab; + } + + /** + * Promise that the tab with the given tab id is ready. + * + * @param {Integer} tabId - The tab id to check + * @returns {Promise<NativeTabInfo>} Resolved when the loading is complete + */ + async function promiseTabWhenReady(tabId) { + let tab; + if (tabId === null) { + tab = tabManager.getWrapper(tabTracker.activeTab); + } else { + tab = tabManager.get(tabId); + } + + await tabListener.awaitTabReady(tab.nativeTab); + + return tab; + } + + return { + tabs: { + onActivated: new EventManager({ + context, + module: "tabs", + event: "onActivated", + extensionApi: this, + }).api(), + + onCreated: new EventManager({ + context, + module: "tabs", + event: "onCreated", + extensionApi: this, + }).api(), + + onAttached: new EventManager({ + context, + module: "tabs", + event: "onAttached", + extensionApi: this, + }).api(), + + onDetached: new EventManager({ + context, + module: "tabs", + event: "onDetached", + extensionApi: this, + }).api(), + + onRemoved: new EventManager({ + context, + module: "tabs", + event: "onRemoved", + extensionApi: this, + }).api(), + + onMoved: new EventManager({ + context, + module: "tabs", + event: "onMoved", + extensionApi: this, + }).api(), + + onUpdated: new EventManager({ + context, + module: "tabs", + event: "onUpdated", + extensionApi: this, + }).api(), + + async create(createProperties) { + let window = await getNormalWindowReady( + context, + createProperties.windowId + ); + let tabmail = window.document.getElementById("tabmail"); + let url; + if (createProperties.url) { + url = context.uri.resolve(createProperties.url); + + if (!context.checkLoadURL(url, { dontReportErrors: true })) { + return Promise.reject({ message: `Illegal URL: ${url}` }); + } + } + + let userContextId = + Services.scriptSecurityManager.DEFAULT_USER_CONTEXT_ID; + if (createProperties.cookieStoreId) { + userContextId = getUserContextIdForCookieStoreId( + extension, + createProperties.cookieStoreId + ); + } + + let currentTab = tabmail.selectedTab; + let active = createProperties.active ?? true; + tabListener.initTabReady(); + + let nativeTabInfo = tabmail.openTab("contentTab", { + url: url || "about:blank", + linkHandler: "single-site", + background: !active, + initialBrowsingContextGroupId: + context.extension.policy.browsingContextGroupId, + principal: context.extension.principal, + duplicate: true, + userContextId, + }); + + if (createProperties.index) { + tabmail.moveTabTo(nativeTabInfo, createProperties.index); + tabmail.updateCurrentTab(); + } + + if (createProperties.url && createProperties.url !== "about:blank") { + // Mark tabs as initializing, so operations like `executeScript` wait until the + // requested URL is loaded. + tabListener.initializingTabs.add(nativeTabInfo); + } + return tabManager.convert(nativeTabInfo, currentTab); + }, + + async remove(tabs) { + if (!Array.isArray(tabs)) { + tabs = [tabs]; + } + + for (let tabId of tabs) { + let nativeTabInfo = tabTracker.getTab(tabId); + if (nativeTabInfo instanceof Ci.nsIDOMWindow) { + nativeTabInfo.close(); + continue; + } + let tabmail = getTabTabmail(nativeTabInfo); + tabmail.closeTab(nativeTabInfo); + } + }, + + async update(tabId, updateProperties) { + let nativeTabInfo = getTabOrActive(tabId); + let tab = tabManager.getWrapper(nativeTabInfo); + let tabmail = getTabTabmail(nativeTabInfo); + + if (updateProperties.url) { + let url = context.uri.resolve(updateProperties.url); + if (!context.checkLoadURL(url, { dontReportErrors: true })) { + return Promise.reject({ message: `Illegal URL: ${url}` }); + } + + let uri; + try { + uri = Services.io.newURI(url); + } catch (e) { + throw new ExtensionError(`Url "${url}" seems to be malformed.`); + } + + // http(s): urls, moz-extension: urls and self-registered protocol + // handlers are actually loaded into the tab (and change its url). + // All other urls are forwarded to the external protocol handler and + // do not change the current tab. + let isContentUrl = + /((^blob:)|(^https:)|(^http:)|(^moz-extension:))/i.test(url); + let isWebExtProtocolUrl = + /((^ext\+[a-z]+:)|(^web\+[a-z]+:))/i.test(url) && + hasWebHandlerApp(uri.scheme); + + if (isContentUrl || isWebExtProtocolUrl) { + if (tab.type != "content" && tab.type != "mail") { + throw new ExtensionError( + isContentUrl + ? "Loading a content url is only supported for content tabs and mail tabs." + : "Loading a registered WebExtension protocol handler url is only supported for content tabs and mail tabs." + ); + } + + let options = { + flags: updateProperties.loadReplace + ? Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY + : Ci.nsIWebNavigation.LOAD_FLAGS_NONE, + triggeringPrincipal: context.principal, + }; + + if (tab.type == "mail") { + // The content browser in about:3pane. + nativeTabInfo.chromeBrowser.contentWindow.messagePane.displayWebPage( + url, + options + ); + } else { + let browser = getTabBrowser(nativeTabInfo); + if (!browser) { + throw new ExtensionError("Cannot set a URL for this tab."); + } + MailE10SUtils.loadURI(browser, url, options); + } + } else { + // Send unknown URLs schema to the external protocol handler. + // This does not change the current tab. + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(uri); + } + } + + // A tab can only be set to be active. To set it inactive, another tab + // has to be set as active. + if (tabmail && updateProperties.active) { + tabmail.selectedTab = nativeTabInfo; + } + + return tabManager.convert(nativeTabInfo); + }, + + async reload(tabId, reloadProperties) { + let nativeTabInfo = getTabOrActive(tabId); + let tab = tabManager.getWrapper(nativeTabInfo); + + let isContentMailTab = + tab.type == "mail" && + !nativeTabInfo.chromeBrowser.contentWindow.webBrowser.hidden; + if (tab.type != "content" && !isContentMailTab) { + throw new ExtensionError( + "Reloading is only supported for tabs displaying a content page." + ); + } + + let browser = getTabBrowser(nativeTabInfo); + + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + if (reloadProperties && reloadProperties.bypassCache) { + flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + } + browser.reloadWithFlags(flags); + }, + + 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) { + if (!extension.hasPermission("tabs")) { + if (queryInfo.url !== null || queryInfo.title !== null) { + return Promise.reject({ + message: + 'The "tabs" permission is required to use the query API with the "url" or "title" parameters', + }); + } + } + + // Make ext-tabs-base happy since it does a strict check. + queryInfo.screen = null; + + return Array.from(tabManager.query(queryInfo, context), tab => + tab.convert() + ); + }, + + 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 = await getNormalWindowReady( + context, + moveProperties.windowId + ); + } + + /* + Indexes are maintained on a per window basis so that a call to + move([tabA, tabB], {index: 0}) + -> tabA to 0, tabB to 1 if tabA and tabB are in the same window + move([tabA, tabB], {index: 0}) + -> tabA to 0, tabB to 0 if tabA and tabB are in different windows + */ + let indexMap = new Map(); + let lastInsertion = new Map(); + + let tabs = tabIds.map(tabId => ({ + nativeTabInfo: tabTracker.getTab(tabId), + tabId, + })); + for (let { nativeTabInfo, tabId } of tabs) { + if (nativeTabInfo instanceof Ci.nsIDOMWindow) { + return Promise.reject({ + message: `Tab with ID ${tabId} does not belong to a normal window`, + }); + } + + // If the window is not specified, use the window from the tab. + let browser = getTabBrowser(nativeTabInfo); + + let srcwindow = browser.ownerGlobal; + let tgtwindow = destinationWindow || browser.ownerGlobal; + let tgttabmail = tgtwindow.document.getElementById("tabmail"); + let srctabmail = srcwindow.document.getElementById("tabmail"); + + // If we are not moving the tab to a different window, and the window + // only has one tab, do nothing. + if (srcwindow == tgtwindow && srctabmail.tabInfo.length === 1) { + continue; + } + + let insertionPoint = + indexMap.get(tgtwindow) || moveProperties.index; + // If the index is -1 it should go to the end of the tabs. + if (insertionPoint == -1) { + insertionPoint = tgttabmail.tabInfo.length; + } + + let tabPosition = srctabmail.tabInfo.indexOf(nativeTabInfo); + + // If this is not the first tab to be inserted into this window and + // the insertion point is the same as the last insertion and + // the tab is further to the right than the current insertion point + // then you need to bump up the insertion point. See bug 1323311. + if ( + lastInsertion.has(tgtwindow) && + lastInsertion.get(tgtwindow) === insertionPoint && + tabPosition > insertionPoint + ) { + insertionPoint++; + indexMap.set(tgtwindow, insertionPoint); + } + + if (srcwindow == tgtwindow) { + // If the window we are moving is the same, just move the tab. + tgttabmail.moveTabTo(nativeTabInfo, insertionPoint); + } else { + // If the window we are moving the tab in is different, then move the tab + // to the new window. + srctabmail.replaceTabWithWindow( + nativeTabInfo, + tgtwindow, + insertionPoint + ); + nativeTabInfo = + tgttabmail.tabInfo[insertionPoint] || + tgttabmail.tabInfo[tgttabmail.tabInfo.length - 1]; + } + lastInsertion.set(tgtwindow, tabPosition); + tabsMoved.push(nativeTabInfo); + } + + return tabsMoved.map(nativeTabInfo => + tabManager.convert(nativeTabInfo) + ); + }, + + duplicate(tabId) { + let nativeTabInfo = tabTracker.getTab(tabId); + if (nativeTabInfo instanceof Ci.nsIDOMWindow) { + throw new ExtensionError( + "tabs.duplicate is not applicable to this tab." + ); + } + let browser = getTabBrowser(nativeTabInfo); + let tabmail = browser.ownerDocument.getElementById("tabmail"); + + // This is our best approximation of duplicating tabs. It might produce unreliable results + let state = tabmail.persistTab(nativeTabInfo); + let mode = tabmail.tabModes[state.mode]; + state.state.duplicate = true; + + if (mode.tabs.length && mode.tabs.length == mode.maxTabs) { + throw new ExtensionError( + `Maximum number of ${state.mode} tabs reached.` + ); + } else { + tabmail.restoreTab(state); + return tabManager.convert(mode.tabs[mode.tabs.length - 1]); + } + }, + }, + }; + } +}; |