summaryrefslogtreecommitdiffstats
path: root/mobile/android/components/extensions/ext-tabs.js
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/components/extensions/ext-tabs.js')
-rw-r--r--mobile/android/components/extensions/ext-tabs.js588
1 files changed, 588 insertions, 0 deletions
diff --git a/mobile/android/components/extensions/ext-tabs.js b/mobile/android/components/extensions/ext-tabs.js
new file mode 100644
index 0000000000..88d3d9e83a
--- /dev/null
+++ b/mobile/android/components/extensions/ext-tabs.js
@@ -0,0 +1,588 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs",
+ mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs",
+});
+
+const getBrowserWindow = window => {
+ return window.browsingContext.topChromeWindow;
+};
+
+const tabListener = {
+ tabReadyInitialized: false,
+ 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) {
+ const { tab } = browser.ownerGlobal;
+
+ // Ignore initial about:blank
+ if (!request && this.initializingTabs.has(tab)) {
+ return;
+ }
+
+ // Now we are certain that the first page in the tab was loaded.
+ this.initializingTabs.delete(tab);
+
+ // browser.innerWindowID is now set, resolve the promises if any.
+ const deferred = this.tabReadyPromises.get(tab);
+ if (deferred) {
+ deferred.resolve(tab);
+ this.tabReadyPromises.delete(tab);
+ }
+ }
+ },
+
+ /**
+ * 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 {NativeTab} nativeTab The native tab object.
+ * @returns {Promise} Resolves with the given tab once ready.
+ */
+ awaitTabReady(nativeTab) {
+ let deferred = this.tabReadyPromises.get(nativeTab);
+ if (!deferred) {
+ deferred = Promise.withResolvers();
+ if (
+ !this.initializingTabs.has(nativeTab) &&
+ (nativeTab.browser.innerWindowID ||
+ nativeTab.browser.currentURI.spec === "about:blank")
+ ) {
+ deferred.resolve(nativeTab);
+ } else {
+ this.initTabReady();
+ this.tabReadyPromises.set(nativeTab, deferred);
+ }
+ }
+ return deferred.promise;
+ },
+};
+
+this.tabs = class extends ExtensionAPIPersistent {
+ tabEventRegistrar({ event, listener }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ return ({ fire }) => {
+ const 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({ fire, context }, params) {
+ const listener = (eventName, event) => {
+ const { windowId, tabId, isPrivate } = event;
+ if (isPrivate && !context.privateBrowsingAllowed) {
+ return;
+ }
+ // In GeckoView each window has only one tab, so previousTabId is omitted.
+ fire.async({ windowId, tabId });
+ };
+
+ mobileWindowTracker.on("tab-activated", listener);
+ return {
+ unregister() {
+ mobileWindowTracker.off("tab-activated", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ onCreated: this.tabEventRegistrar({
+ event: "tab-created",
+ listener: (fire, event) => {
+ const { tabManager } = this.extension;
+ fire.async(tabManager.convert(event.nativeTab));
+ },
+ }),
+ onRemoved: this.tabEventRegistrar({
+ event: "tab-removed",
+ listener: (fire, event) => {
+ fire.async(event.tabId, {
+ windowId: event.windowId,
+ isWindowClosing: event.isWindowClosing,
+ });
+ },
+ }),
+ onUpdated({ fire }, params) {
+ const { tabManager } = this.extension;
+ const restricted = ["url", "favIconUrl", "title"];
+
+ function sanitize(tab, changeInfo) {
+ const result = {};
+ let nonempty = false;
+ for (const 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.
+ if (!restricted.includes(prop) || tab.hasTabPermission) {
+ nonempty = true;
+ result[prop] = changeInfo[prop];
+ }
+ }
+ return [nonempty, result];
+ }
+
+ const fireForTab = (tab, changed) => {
+ const [needed, changeInfo] = sanitize(tab, changed);
+ if (needed) {
+ fire.async(tab.id, changeInfo, tab.convert());
+ }
+ };
+
+ const listener = event => {
+ const needed = [];
+ let nativeTab;
+ switch (event.type) {
+ case "pagetitlechanged": {
+ const window = getBrowserWindow(event.target.ownerGlobal);
+ nativeTab = window.tab;
+
+ needed.push("title");
+ break;
+ }
+
+ case "DOMAudioPlaybackStarted":
+ case "DOMAudioPlaybackStopped": {
+ const window = event.target.ownerGlobal;
+ nativeTab = window.tab;
+ needed.push("audible");
+ break;
+ }
+ }
+
+ if (!nativeTab) {
+ return;
+ }
+
+ const tab = tabManager.getWrapper(nativeTab);
+ const changeInfo = {};
+ for (const prop of needed) {
+ changeInfo[prop] = tab[prop];
+ }
+
+ fireForTab(tab, changeInfo);
+ };
+
+ const statusListener = ({ browser, status, url }) => {
+ const { tab } = browser.ownerGlobal;
+ if (tab) {
+ const changed = { status };
+ if (url) {
+ changed.url = url;
+ }
+
+ fireForTab(tabManager.wrapTab(tab), changed);
+ }
+ };
+
+ windowTracker.addListener("status", statusListener);
+ windowTracker.addListener("pagetitlechanged", listener);
+
+ return {
+ unregister() {
+ windowTracker.removeListener("status", statusListener);
+ windowTracker.removeListener("pagetitlechanged", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ const { extension } = context;
+ const { tabManager } = extension;
+ const extensionApi = this;
+ const module = "tabs";
+
+ function getTabOrActive(tabId) {
+ if (tabId !== null) {
+ return tabTracker.getTab(tabId);
+ }
+ return tabTracker.activeTab;
+ }
+
+ 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 loadURIInTab(nativeTab, url) {
+ const { browser } = nativeTab;
+
+ let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+ let { principal } = context;
+ const isAboutUrl = url.startsWith("about:");
+ if (
+ isAboutUrl ||
+ (url.startsWith("moz-extension://") &&
+ !context.checkLoadURL(url, { dontReportErrors: true }))
+ ) {
+ // Falling back to content here as about: requires it, however is safe.
+ principal =
+ Services.scriptSecurityManager.getLoadContextContentPrincipal(
+ Services.io.newURI(url),
+ browser.loadContext
+ );
+ }
+ if (isAboutUrl) {
+ // Make sure things like about:blank and other about: URIs never
+ // inherit, and instead always get a NullPrincipal.
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL;
+ }
+
+ browser.fixupAndLoadURIString(url, {
+ flags,
+ triggeringPrincipal: principal,
+ });
+ }
+
+ return {
+ tabs: {
+ onActivated: new EventManager({
+ context,
+ module,
+ event: "onActivated",
+ extensionApi,
+ }).api(),
+
+ onCreated: new EventManager({
+ context,
+ module,
+ event: "onCreated",
+ extensionApi,
+ }).api(),
+
+ /**
+ * Since multiple tabs currently can't be highlighted, onHighlighted
+ * essentially acts an alias for tabs.onActivated but returns
+ * the tabId in an array to match the API.
+ *
+ * @see https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/Tabs/onHighlighted
+ */
+ onHighlighted: makeGlobalEvent(
+ context,
+ "tabs.onHighlighted",
+ "Tab:Selected",
+ (fire, data) => {
+ const tab = tabManager.get(data.id);
+
+ fire.async({ tabIds: [tab.id], windowId: tab.windowId });
+ }
+ ),
+
+ // Some events below are not be persisted because they are not implemented.
+ // They do not have an "extensionApi" property with an entry in
+ // PERSISTENT_EVENTS, but instead an empty "register" method.
+ onAttached: new EventManager({
+ context,
+ name: "tabs.onAttached",
+ register: fire => {
+ return () => {};
+ },
+ }).api(),
+
+ onDetached: new EventManager({
+ context,
+ name: "tabs.onDetached",
+ register: fire => {
+ return () => {};
+ },
+ }).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,
+ name: "tabs.onMoved",
+ register: fire => {
+ return () => {};
+ },
+ }).api(),
+
+ onUpdated: new EventManager({
+ context,
+ module,
+ event: "onUpdated",
+ extensionApi,
+ }).api(),
+
+ async create({
+ active,
+ cookieStoreId,
+ discarded,
+ index,
+ openInReaderMode,
+ pinned,
+ title,
+ url,
+ } = {}) {
+ if (active === null) {
+ active = true;
+ }
+
+ tabListener.initTabReady();
+
+ if (url !== null) {
+ url = context.uri.resolve(url);
+
+ if (
+ !url.startsWith("moz-extension://") &&
+ !context.checkLoadURL(url, { dontReportErrors: true })
+ ) {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+ }
+
+ if (cookieStoreId) {
+ cookieStoreId = getUserContextIdForCookieStoreId(
+ extension,
+ cookieStoreId,
+ false // TODO bug 1372178: support creation of private browsing tabs
+ );
+ }
+ cookieStoreId = cookieStoreId ? cookieStoreId.toString() : undefined;
+
+ const nativeTab = await GeckoViewTabBridge.createNewTab({
+ extensionId: context.extension.id,
+ createProperties: {
+ active,
+ cookieStoreId,
+ discarded,
+ index,
+ openInReaderMode,
+ pinned,
+ url,
+ },
+ });
+
+ // Make sure things like about:blank URIs never inherit,
+ // and instead always get a NullPrincipal.
+ if (url !== null) {
+ tabListener.initializingTabs.add(nativeTab);
+ } else {
+ url = "about:blank";
+ }
+
+ loadURIInTab(nativeTab, url);
+
+ if (active) {
+ const newWindow = nativeTab.browser.ownerGlobal;
+ mobileWindowTracker.setTabActive(newWindow, true);
+ }
+
+ return tabManager.convert(nativeTab);
+ },
+
+ async remove(tabs) {
+ if (!Array.isArray(tabs)) {
+ tabs = [tabs];
+ }
+
+ await Promise.all(
+ tabs.map(async tabId => {
+ const windowId = GeckoViewTabBridge.tabIdToWindowId(tabId);
+ const window = windowTracker.getWindow(windowId, context, false);
+ if (!window) {
+ throw new ExtensionError(`Invalid tab ID ${tabId}`);
+ }
+ await GeckoViewTabBridge.closeTab({
+ window,
+ extensionId: context.extension.id,
+ });
+ })
+ );
+ },
+
+ async update(
+ tabId,
+ { active, autoDiscardable, highlighted, muted, pinned, url } = {}
+ ) {
+ const nativeTab = getTabOrActive(tabId);
+ const window = nativeTab.browser.ownerGlobal;
+
+ if (url !== null) {
+ url = context.uri.resolve(url);
+
+ if (
+ !url.startsWith("moz-extension://") &&
+ !context.checkLoadURL(url, { dontReportErrors: true })
+ ) {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+ }
+
+ await GeckoViewTabBridge.updateTab({
+ window,
+ extensionId: context.extension.id,
+ updateProperties: {
+ active,
+ autoDiscardable,
+ highlighted,
+ muted,
+ pinned,
+ url,
+ },
+ });
+
+ if (url !== null) {
+ loadURIInTab(nativeTab, url);
+ }
+
+ // FIXME: openerTabId, successorTabId
+ if (active) {
+ mobileWindowTracker.setTabActive(window, true);
+ }
+
+ return tabManager.convert(nativeTab);
+ },
+
+ async reload(tabId, reloadProperties) {
+ const nativeTab = getTabOrActive(tabId);
+
+ let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+ if (reloadProperties && reloadProperties.bypassCache) {
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ }
+ nativeTab.browser.reloadWithFlags(flags);
+ },
+
+ async get(tabId) {
+ return tabManager.get(tabId).convert();
+ },
+
+ async getCurrent() {
+ if (context.tabId) {
+ return tabManager.get(context.tabId).convert();
+ }
+ },
+
+ async query(queryInfo) {
+ return Array.from(tabManager.query(queryInfo, context), tab =>
+ tab.convert()
+ );
+ },
+
+ async captureTab(tabId, options) {
+ const nativeTab = getTabOrActive(tabId);
+ await tabListener.awaitTabReady(nativeTab);
+
+ const { browser } = nativeTab;
+ const tab = tabManager.wrapTab(nativeTab);
+ return tab.capture(context, browser.fullZoom, options);
+ },
+
+ async captureVisibleTab(windowId, options) {
+ const window =
+ windowId == null
+ ? windowTracker.topWindow
+ : windowTracker.getWindow(windowId, context);
+
+ const tab = tabManager.wrapTab(window.tab);
+ await tabListener.awaitTabReady(tab.nativeTab);
+ const zoom = window.browsingContext.fullZoom;
+
+ return tab.capture(context, zoom, options);
+ },
+
+ async detectLanguage(tabId) {
+ const tab = await promiseTabWhenReady(tabId);
+ const results = await tab.queryContent("DetectLanguage", {});
+ return results[0];
+ },
+
+ async executeScript(tabId, details) {
+ const tab = await promiseTabWhenReady(tabId);
+
+ return tab.executeScript(context, details);
+ },
+
+ async insertCSS(tabId, details) {
+ const tab = await promiseTabWhenReady(tabId);
+
+ return tab.insertCSS(context, details);
+ },
+
+ async removeCSS(tabId, details) {
+ const tab = await promiseTabWhenReady(tabId);
+
+ return tab.removeCSS(context, details);
+ },
+
+ goForward(tabId) {
+ const { browser } = getTabOrActive(tabId);
+ browser.goForward();
+ },
+
+ goBack(tabId) {
+ const { browser } = getTabOrActive(tabId);
+ browser.goBack();
+ },
+ },
+ };
+ }
+};