Source code

Revision control

Copy as Markdown

Other Tools

/* -*- 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) {
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 }) {
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 }) {
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 loadFlags = 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.
loadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL;
}
browser.fixupAndLoadURIString(url, {
loadFlags,
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.
*
*/
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: () => {
return () => {};
},
}).api(),
onDetached: new EventManager({
context,
name: "tabs.onDetached",
register: () => {
return () => {};
},
}).api(),
onRemoved: new EventManager({
context,
module,
event: "onRemoved",
extensionApi,
}).api(),
onReplaced: new EventManager({
context,
name: "tabs.onReplaced",
register: () => {
return () => {};
},
}).api(),
onMoved: new EventManager({
context,
name: "tabs.onMoved",
register: () => {
return () => {};
},
}).api(),
onUpdated: new EventManager({
context,
module,
event: "onUpdated",
extensionApi,
}).api(),
async create({
active,
cookieStoreId,
discarded,
index,
openInReaderMode,
pinned,
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.getWrapper(window.tab);
if (
!extension.hasPermission("<all_urls>") &&
!tab.hasActiveTabPermission
) {
throw new ExtensionError("Missing activeTab permission");
}
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(false);
},
goBack(tabId) {
const { browser } = getTabOrActive(tabId);
browser.goBack(false);
},
},
};
}
};