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
"use strict";
/**
* This module contains code for managing APIs that need to run in the
* parent process, and handles the parent side of operations that need
* to be proxied from ExtensionChild.jsm.
*/
/* exported ExtensionParent */
var EXPORTED_SYMBOLS = ["ExtensionParent"];
const { XPCOMUtils } = ChromeUtils.importESModule(
);
const { AppConstants } = ChromeUtils.importESModule(
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
XPCOMUtils.defineLazyModuleGetters(lazy, {
});
XPCOMUtils.defineLazyServiceGetters(lazy, {
aomStartup: [
"@mozilla.org/addons/addon-manager-startup;1",
"amIAddonManagerStartup",
],
});
// We're using the pref to avoid loading PerformanceCounters.jsm for nothing.
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"gTimingEnabled",
"extensions.webextensions.enablePerformanceCounters",
false
);
const { ExtensionCommon } = ChromeUtils.import(
);
const { ExtensionUtils } = ChromeUtils.import(
);
const DUMMY_PAGE_URI = Services.io.newURI(
);
var { BaseContext, CanOfAPIs, SchemaAPIManager, SpreadArgs, defineLazyGetter } =
ExtensionCommon;
var {
DefaultMap,
DefaultWeakMap,
ExtensionError,
promiseDocumentLoaded,
promiseEvent,
promiseObserved,
} = ExtensionUtils;
const ERROR_NO_RECEIVERS =
"Could not establish connection. Receiving end does not exist.";
const CATEGORY_EXTENSION_MODULES = "webextension-modules";
const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas";
const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts";
let schemaURLs = new Set();
let GlobalManager;
let ParentAPIManager;
let StartupCache;
function verifyActorForContext(actor, context) {
if (JSWindowActorParent.isInstance(actor)) {
let target = actor.browsingContext.top.embedderElement;
if (context.parentMessageManager !== target.messageManager) {
throw new Error("Got message on unexpected message manager");
}
} else if (JSProcessActorParent.isInstance(actor)) {
if (actor.manager.remoteType !== context.extension.remoteType) {
throw new Error("Got message from unexpected process");
}
}
}
// This object loads the ext-*.js scripts that define the extension API.
let apiManager = new (class extends SchemaAPIManager {
constructor() {
super("main", lazy.Schemas);
this.initialized = null;
/* eslint-disable mozilla/balanced-listeners */
this.on("startup", (e, extension) => {
return extension.apiManager.onStartup(extension);
});
this.on("update", async (e, { id, resourceURI, isPrivileged }) => {
let modules = this.eventModules.get("update");
if (modules.size == 0) {
return;
}
let extension = new lazy.ExtensionData(resourceURI, isPrivileged);
await extension.loadManifest();
return Promise.all(
Array.from(modules).map(async apiName => {
let module = await this.asyncLoadModule(apiName);
module.onUpdate(id, extension.manifest);
})
);
});
this.on("uninstall", (e, { id }) => {
let modules = this.eventModules.get("uninstall");
return Promise.all(
Array.from(modules).map(async apiName => {
let module = await this.asyncLoadModule(apiName);
return module.onUninstall(id);
})
);
});
/* eslint-enable mozilla/balanced-listeners */
// Handle any changes that happened during startup
let disabledIds = lazy.AddonManager.getStartupChanges(
lazy.AddonManager.STARTUP_CHANGE_DISABLED
);
if (disabledIds.length) {
this._callHandlers(disabledIds, "disable", "onDisable");
}
let uninstalledIds = lazy.AddonManager.getStartupChanges(
lazy.AddonManager.STARTUP_CHANGE_UNINSTALLED
);
if (uninstalledIds.length) {
this._callHandlers(uninstalledIds, "uninstall", "onUninstall");
}
}
getModuleJSONURLs() {
return Array.from(
Services.catMan.enumerateCategory(CATEGORY_EXTENSION_MODULES),
({ value }) => value
);
}
// Loads all the ext-*.js scripts currently registered.
lazyInit() {
if (this.initialized) {
return this.initialized;
}
let modulesPromise = StartupCache.other.get(["parentModules"], () =>
this.loadModuleJSON(this.getModuleJSONURLs())
);
let scriptURLs = [];
for (let { value } of Services.catMan.enumerateCategory(
CATEGORY_EXTENSION_SCRIPTS
)) {
scriptURLs.push(value);
}
let promise = (async () => {
let scripts = await Promise.all(
scriptURLs.map(url => ChromeUtils.compileScript(url))
);
this.initModuleData(await modulesPromise);
this.initGlobal();
for (let script of scripts) {
script.executeInGlobal(this.global);
}
// Load order matters here. The base manifest defines types which are
// extended by other schemas, so needs to be loaded first.
return lazy.Schemas.load(BASE_SCHEMA).then(() => {
let promises = [];
for (let { value } of Services.catMan.enumerateCategory(
CATEGORY_EXTENSION_SCHEMAS
)) {
promises.push(lazy.Schemas.load(value));
}
for (let [url, { content }] of this.schemaURLs) {
promises.push(lazy.Schemas.load(url, content));
}
for (let url of schemaURLs) {
promises.push(lazy.Schemas.load(url));
}
return Promise.all(promises).then(() => {
lazy.Schemas.updateSharedSchemas();
});
});
})();
Services.mm.addMessageListener("Extension:GetFrameData", this);
this.initialized = promise;
return this.initialized;
}
receiveMessage({ target }) {
let data = GlobalManager.frameData.get(target) || {};
Object.assign(data, this.global.tabTracker.getBrowserData(target));
return data;
}
// Call static handlers for the given event on the given extension ids,
// and set up a shutdown blocker to ensure they all complete.
_callHandlers(ids, event, method) {
let promises = Array.from(this.eventModules.get(event))
.map(async modName => {
let module = await this.asyncLoadModule(modName);
return ids.map(id => module[method](id));
})
.flat();
if (event === "disable") {
promises.push(...ids.map(id => this.emit("disable", id)));
}
if (event === "enabling") {
promises.push(...ids.map(id => this.emit("enabling", id)));
}
lazy.AsyncShutdown.profileBeforeChange.addBlocker(
`Extension API ${event} handlers for ${ids.join(",")}`,
Promise.all(promises)
);
}
})();
// Receives messages related to the extension messaging API and forwards them
// to relevant child messengers. Also handles Native messaging and GeckoView.
const ProxyMessenger = {
/**
* @typedef {object} ParentPort
* @property {function(StructuredCloneHolder)} onPortMessage
* @property {function()} onPortDisconnect
*/
/** @type {Map<number, ParentPort>} */
ports: new Map(),
init() {
this.conduit = new lazy.BroadcastConduit(ProxyMessenger, {
id: "ProxyMessenger",
reportOnClosed: "portId",
recv: ["PortConnect", "PortMessage", "NativeMessage", "RuntimeMessage"],
cast: ["PortConnect", "PortMessage", "PortDisconnect", "RuntimeMessage"],
});
},
openNative(nativeApp, sender) {
let context = ParentAPIManager.getContextById(sender.childId);
if (context.extension.hasPermission("geckoViewAddons")) {
return new lazy.GeckoViewConnection(
this.getSender(context.extension, sender),
sender.actor.browsingContext.top.embedderElement,
nativeApp,
context.extension.hasPermission("nativeMessagingFromContent")
);
} else if (sender.verified) {
return new lazy.NativeApp(context, nativeApp);
}
sender = this.getSender(context.extension, sender);
throw new Error(`Native messaging not allowed: ${JSON.stringify(sender)}`);
},
recvNativeMessage({ nativeApp, holder }, { sender }) {
const app = this.openNative(nativeApp, sender);
// Track in-flight NativeApp sendMessage requests as
// a NativeApp port destroyed when the request
// has been handled.
const promiseSendMessage = app.sendMessage(holder);
const sendMessagePort = {
native: true,
senderChildId: sender.childId,
};
this.trackNativeAppPort(sendMessagePort);
const untrackSendMessage = () => this.untrackNativeAppPort(sendMessagePort);
promiseSendMessage.then(untrackSendMessage, untrackSendMessage);
return promiseSendMessage;
},
getSender(extension, source) {
let sender = {
contextId: source.id,
id: source.extensionId,
envType: source.envType,
url: source.url,
};
if (JSWindowActorParent.isInstance(source.actor)) {
let browser = source.actor.browsingContext.top.embedderElement;
let data =
browser && apiManager.global.tabTracker.getBrowserData(browser);
if (data?.tabId > 0) {
sender.tab = extension.tabManager.get(data.tabId, null)?.convert();
// frameId is documented to only be set if sender.tab is set.
sender.frameId = source.frameId;
}
}
return sender;
},
getTopBrowsingContextId(tabId) {
// If a tab alredy has content scripts, no need to check private browsing.
let tab = apiManager.global.tabTracker.getTab(tabId, null);
if (!tab || (tab.browser || tab).getAttribute("pending") === "true") {
// No receivers in discarded tabs, so bail early to keep the browser lazy.
throw new ExtensionError(ERROR_NO_RECEIVERS);
}
let browser = tab.linkedBrowser || tab.browser;
return browser.browsingContext.id;
},
async normalizeArgs(arg, sender) {
arg.extensionId = arg.extensionId || sender.extensionId;
let extension = GlobalManager.extensionMap.get(arg.extensionId);
if (!extension) {
return Promise.reject({ message: ERROR_NO_RECEIVERS });
}
await extension.wakeupBackground?.();
arg.sender = this.getSender(extension, sender);
arg.topBC = arg.tabId && this.getTopBrowsingContextId(arg.tabId);
return arg.tabId ? "tab" : "messenger";
},
async recvRuntimeMessage(arg, { sender }) {
arg.firstResponse = true;
let kind = await this.normalizeArgs(arg, sender);
let result = await this.conduit.castRuntimeMessage(kind, arg);
if (!result) {
// "throw new ExtensionError" cannot be used because then the stack of the
// sendMessage call would not be added to the error object generated by
// context.normalizeError. Test coverage by test_ext_error_location.js.
return Promise.reject({ message: ERROR_NO_RECEIVERS });
}
return result.value;
},
async recvPortConnect(arg, { sender }) {
if (arg.native) {
let port = this.openNative(arg.name, sender).onConnect(arg.portId, this);
port.senderChildId = sender.childId;
port.native = true;
this.ports.set(arg.portId, port);
this.trackNativeAppPort(port);
return;
}
// PortMessages that follow will need to wait for the port to be opened.
let resolvePort;
this.ports.set(arg.portId, new Promise(res => (resolvePort = res)));
let kind = await this.normalizeArgs(arg, sender);
let all = await this.conduit.castPortConnect(kind, arg);
resolvePort();
// If there are no active onConnect listeners.
if (!all.some(x => x.value)) {
throw new ExtensionError(ERROR_NO_RECEIVERS);
}
},
async recvPortMessage({ holder }, { sender }) {
if (sender.native) {
// If the nativeApp port connect fails (e.g. if triggered by a content
// script), the portId may not be in the map (because it did throw in
// the openNative method).
return this.ports.get(sender.portId)?.onPortMessage(holder);
}
// NOTE: the following await make sure we await for promised ports
// (ports that were not yet open when added to the Map,
// see recvPortConnect).
await this.ports.get(sender.portId);
this.sendPortMessage(sender.portId, holder, !sender.source);
},
recvConduitClosed(sender) {
let app = this.ports.get(sender.portId);
if (this.ports.delete(sender.portId) && sender.native) {
this.untrackNativeAppPort(app);
return app.onPortDisconnect();
}
this.sendPortDisconnect(sender.portId, null, !sender.source);
},
sendPortMessage(portId, holder, source = true) {
this.conduit.castPortMessage("port", { portId, source, holder });
},
sendPortDisconnect(portId, error, source = true) {
let port = this.ports.get(portId);
this.untrackNativeAppPort(port);
this.conduit.castPortDisconnect("port", { portId, source, error });
this.ports.delete(portId);
},
trackNativeAppPort(port) {
if (!port?.native) {
return;
}
try {
let context = ParentAPIManager.getContextById(port.senderChildId);
context?.trackNativeAppPort(port);
} catch {
// getContextById will throw if the context has been destroyed
// in the meantime.
}
},
untrackNativeAppPort(port) {
if (!port?.native) {
return;
}
try {
let context = ParentAPIManager.getContextById(port.senderChildId);
context?.untrackNativeAppPort(port);
} catch {
// getContextById will throw if the context has been destroyed
// in the meantime.
}
},
};
ProxyMessenger.init();
// Responsible for loading extension APIs into the right globals.
GlobalManager = {
// Map[extension ID -> Extension]. Determines which extension is
// responsible for content under a particular extension ID.
extensionMap: new Map(),
initialized: false,
/** @type {WeakMap<Browser, object>} Extension Context init data. */
frameData: new WeakMap(),
init(extension) {
if (this.extensionMap.size == 0) {
apiManager.on("extension-browser-inserted", this._onExtensionBrowser);
this.initialized = true;
Services.ppmm.addMessageListener(
"Extension:SendPerformanceCounter",
this
);
}
this.extensionMap.set(extension.id, extension);
},
uninit(extension) {
this.extensionMap.delete(extension.id);
if (this.extensionMap.size == 0 && this.initialized) {
apiManager.off("extension-browser-inserted", this._onExtensionBrowser);
this.initialized = false;
Services.ppmm.removeMessageListener(
"Extension:SendPerformanceCounter",
this
);
}
},
async receiveMessage({ name, data }) {
switch (name) {
case "Extension:SendPerformanceCounter":
lazy.PerformanceCounters.merge(data.counters);
break;
}
},
_onExtensionBrowser(type, browser, data = {}) {
data.viewType = browser.getAttribute("webextension-view-type");
if (data.viewType) {
GlobalManager.frameData.set(browser, data);
}
},
getExtension(extensionId) {
return this.extensionMap.get(extensionId);
},
};
/**
* The proxied parent side of a context in ExtensionChild.jsm, for the
* parent side of a proxied API.
*/
class ProxyContextParent extends BaseContext {
constructor(envType, extension, params, xulBrowser, principal) {
super(envType, extension);
this.childId = params.childId;
this.uri = Services.io.newURI(params.url);
this.incognito = params.incognito;
this.listenerPromises = new Set();
// This message manager is used by ParentAPIManager to send messages and to
// close the ProxyContext if the underlying message manager closes. This
// message manager object may change when `xulBrowser` swaps docshells, e.g.
// when a tab is moved to a different window.
this.messageManagerProxy =
xulBrowser && new lazy.MessageManagerProxy(xulBrowser);
Object.defineProperty(this, "principal", {
value: principal,
enumerable: true,
configurable: true,
});
this.listenerProxies = new Map();
this.pendingEventBrowser = null;
this.callContextData = null;
// Set of active NativeApp ports.
this.activeNativePorts = new WeakSet();
// Set of pending queryRunListener promises.
this.runListenerPromises = new Set();
apiManager.emit("proxy-context-load", this);
}
get isProxyContextParent() {
return true;
}
trackRunListenerPromise(runListenerPromise) {
if (
// The extension was already shutdown.
!this.extension ||
// Not a non persistent background script context.
!this.isBackgroundContext ||
this.extension.persistentBackground
) {
return;
}
const clearFromSet = () =>
this.runListenerPromises.delete(runListenerPromise);
runListenerPromise.then(clearFromSet, clearFromSet);
this.runListenerPromises.add(runListenerPromise);
}
clearPendingRunListenerPromises() {
this.runListenerPromises.clear();
}
get pendingRunListenerPromisesCount() {
return this.runListenerPromises.size;
}
trackNativeAppPort(port) {
if (
// Not a native port.
!port?.native ||
// Not a non persistent background script context.
!this.isBackgroundContext ||
this.extension?.persistentBackground ||
// The extension was already shutdown.
!this.extension
) {
return;
}
this.activeNativePorts.add(port);
}
untrackNativeAppPort(port) {
this.activeNativePorts.delete(port);
}
get hasActiveNativeAppPorts() {
return !!ChromeUtils.nondeterministicGetWeakSetKeys(this.activeNativePorts)
.length;
}
/**
* Call the `callable` parameter with `context.callContextData` set to the value passed
* as the first parameter of this method.
*
* `context.callContextData` is expected to:
* - don't be set when context.withCallContextData is being called
* - be set back to null right after calling the `callable` function, without
* awaiting on any async code that the function may be running internally
*
* The callable method itself is responsabile of eventually retrieve the value initially set
* on the `context.callContextData` before any code executed asynchronously (e.g. from a
* callback or after awaiting internally on a promise if the `callable` function was async).
*
* @param {object} callContextData
* @param {boolean} callContextData.isHandlingUserInput
* @param {Function} callable
*
* @returns {any} Returns the value returned by calling the `callable` method.
*/
withCallContextData({ isHandlingUserInput }, callable) {
if (this.callContextData) {
Cu.reportError(
`Unexpected pre-existing callContextData on "${this.extension?.policy.debugName}" contextId ${this.contextId}`
);
}
try {
this.callContextData = {
isHandlingUserInput,
};
return callable();
} finally {
this.callContextData = null;
}
}
async withPendingBrowser(browser, callable) {
let savedBrowser = this.pendingEventBrowser;
this.pendingEventBrowser = browser;
try {
let result = await callable();
return result;
} finally {
this.pendingEventBrowser = savedBrowser;
}
}
logActivity(type, name, data) {
// The base class will throw so we catch any subclasses that do not implement.
// We do not want to throw here, but we also do not log here.
}
get cloneScope() {
return this.sandbox;
}
applySafe(callback, args) {
// There's no need to clone when calling listeners for a proxied
// context.
return this.applySafeWithoutClone(callback, args);
}
get xulBrowser() {
return this.messageManagerProxy?.eventTarget;
}
get parentMessageManager() {
return this.messageManagerProxy?.messageManager;
}
shutdown() {
this.unload();
}
unload() {
if (this.unloaded) {
return;
}
this.messageManagerProxy?.dispose();
super.unload();
apiManager.emit("proxy-context-unload", this);
}
}
defineLazyGetter(ProxyContextParent.prototype, "apiCan", function () {
let obj = {};
let can = new CanOfAPIs(this, this.extension.apiManager, obj);
return can;
});
defineLazyGetter(ProxyContextParent.prototype, "apiObj", function () {
return this.apiCan.root;
});
defineLazyGetter(ProxyContextParent.prototype, "sandbox", function () {
// NOTE: the required Blob and URL globals are used in the ext-registerContentScript.js
// API module to convert JS and CSS data into blob URLs.
return Cu.Sandbox(this.principal, {
sandboxName: this.uri.spec,
wantGlobalProperties: ["Blob", "URL"],
});
});
/**
* The parent side of proxied API context for extension content script
* running in ExtensionContent.jsm.
*/
class ContentScriptContextParent extends ProxyContextParent {}
/**
* The parent side of proxied API context for extension page, such as a
* background script, a tab page, or a popup, running in
* ExtensionChild.jsm.
*/
class ExtensionPageContextParent extends ProxyContextParent {
constructor(envType, extension, params, xulBrowser) {
super(envType, extension, params, xulBrowser, extension.principal);
this.viewType = params.viewType;
this.extension.views.add(this);
extension.emit("extension-proxy-context-load", this);
}
// The window that contains this context. This may change due to moving tabs.
get appWindow() {
let win = this.xulBrowser.ownerGlobal;
return win.browsingContext.topChromeWindow;
}
get currentWindow() {
if (this.viewType !== "background") {
return this.appWindow;
}
}
get tabId() {
let { tabTracker } = apiManager.global;
let data = tabTracker.getBrowserData(this.xulBrowser);
if (data.tabId >= 0) {
return data.tabId;
}
}
onBrowserChange(browser) {
super.onBrowserChange(browser);
this.xulBrowser = browser;
}
unload() {
super.unload();
this.extension.views.delete(this);
}
shutdown() {
apiManager.emit("page-shutdown", this);
super.shutdown();
}
}
/**
* The parent side of proxied API context for devtools extension page, such as a
* devtools pages and panels running in ExtensionChild.jsm.
*/
class DevToolsExtensionPageContextParent extends ExtensionPageContextParent {
constructor(...params) {
super(...params);
// Set all attributes that are lazily defined to `null` here.
//
// Note that we can't do that for `this._devToolsToolbox` because it will
// be defined when calling our parent constructor and so would override it back to `null`.
this._devToolsCommands = null;
this._onNavigatedListeners = null;
this._onResourceAvailable = this._onResourceAvailable.bind(this);
}
set devToolsToolbox(toolbox) {
if (this._devToolsToolbox) {
throw new Error("Cannot set the context DevTools toolbox twice");
}
this._devToolsToolbox = toolbox;
}
get devToolsToolbox() {
return this._devToolsToolbox;
}
async addOnNavigatedListener(listener) {
if (!this._onNavigatedListeners) {
this._onNavigatedListeners = new Set();
await this.devToolsToolbox.resourceCommand.watchResources(
[this.devToolsToolbox.resourceCommand.TYPES.DOCUMENT_EVENT],
{
onAvailable: this._onResourceAvailable,
ignoreExistingResources: true,
}
);
}
this._onNavigatedListeners.add(listener);
}
removeOnNavigatedListener(listener) {
if (this._onNavigatedListeners) {
this._onNavigatedListeners.delete(listener);
}
}
/**
* The returned "commands" object, exposing modules implemented from devtools/shared/commands.
* Each attribute being a static interface to communicate with the server backend.
*
* @returns {Promise<object>}
*/
async getDevToolsCommands() {
// Ensure that we try to instantiate a commands only once,
// even if createCommandsForTabForWebExtension is async.
if (this._devToolsCommandsPromise) {
return this._devToolsCommandsPromise;
}
if (this._devToolsCommands) {
return this._devToolsCommands;
}
this._devToolsCommandsPromise = (async () => {
const commands =
await lazy.DevToolsShim.createCommandsForTabForWebExtension(
this.devToolsToolbox.commands.descriptorFront.localTab
);
await commands.targetCommand.startListening();
this._devToolsCommands = commands;
this._devToolsCommandsPromise = null;
return commands;
})();
return this._devToolsCommandsPromise;
}
unload() {
// Bail if the toolbox reference was already cleared.
if (!this.devToolsToolbox) {
return;
}
if (this._onNavigatedListeners) {
this.devToolsToolbox.resourceCommand.unwatchResources(
[this.devToolsToolbox.resourceCommand.TYPES.DOCUMENT_EVENT],
{ onAvailable: this._onResourceAvailable }
);
}
if (this._devToolsCommands) {
this._devToolsCommands.destroy();
this._devToolsCommands = null;
}
if (this._onNavigatedListeners) {
this._onNavigatedListeners.clear();
this._onNavigatedListeners = null;
}
this._devToolsToolbox = null;
super.unload();
}
async _onResourceAvailable(resources) {
for (const resource of resources) {
const { targetFront } = resource;
if (targetFront.isTopLevel && resource.name === "dom-complete") {
for (const listener of this._onNavigatedListeners) {
listener(targetFront.url);
}
}
}
}
}
/**
* The parent side of proxied API context for extension background service
* worker script.
*/
class BackgroundWorkerContextParent extends ProxyContextParent {
constructor(envType, extension, params) {
// TODO: split out from ProxyContextParent a base class that
// doesn't expect a xulBrowser and one for contexts that are
// expected to have a xulBrowser associated.
super(envType, extension, params, null, extension.principal);
this.viewType = params.viewType;
this.workerDescriptorId = params.workerDescriptorId;
this.extension.views.add(this);
extension.emit("extension-proxy-context-load", this);
}
}
ParentAPIManager = {
proxyContexts: new Map(),
init() {
Services.obs.addObserver(this, "message-manager-close");
this.conduit = new lazy.BroadcastConduit(this, {
id: "ParentAPIManager",
reportOnClosed: "childId",
recv: [
"CreateProxyContext",
"ContextLoaded",
"APICall",
"AddListener",
"RemoveListener",
],
send: ["CallResult"],
query: ["RunListener", "StreamFilterSuspendCancel"],
});
},
attachMessageManager(extension, processMessageManager) {
extension.parentMessageManager = processMessageManager;
},
async observe(subject, topic, data) {
if (topic === "message-manager-close") {
let mm = subject;
for (let [childId, context] of this.proxyContexts) {
if (context.parentMessageManager === mm) {
this.closeProxyContext(childId);
}
}
// Reset extension message managers when their child processes shut down.
for (let extension of GlobalManager.extensionMap.values()) {
if (extension.parentMessageManager === mm) {
extension.parentMessageManager = null;
}
}
}
},
shutdownExtension(extensionId, reason) {
if (["ADDON_DISABLE", "ADDON_UNINSTALL"].includes(reason)) {
apiManager._callHandlers([extensionId], "disable", "onDisable");
}
for (let [childId, context] of this.proxyContexts) {
if (context.extension.id == extensionId) {
context.shutdown();
this.proxyContexts.delete(childId);
}
}
},
queryStreamFilterSuspendCancel(childId) {
return this.conduit.queryStreamFilterSuspendCancel(childId);
},
recvCreateProxyContext(data, { actor, sender }) {
let { envType, extensionId, childId, principal } = data;
let target = actor.browsingContext?.top.embedderElement;
if (this.proxyContexts.has(childId)) {
throw new Error(
"A WebExtension context with the given ID already exists!"
);
}
let extension = GlobalManager.getExtension(extensionId);
if (!extension) {
throw new Error(`No WebExtension found with ID ${extensionId}`);
}
let context;
if (envType == "addon_parent" || envType == "devtools_parent") {
if (!sender.verified) {
throw new Error(`Bad sender context envType: ${sender.envType}`);
}
if (JSWindowActorParent.isInstance(actor)) {
let processMessageManager =
target.messageManager.processMessageManager ||
Services.ppmm.getChildAt(0);
if (!extension.parentMessageManager) {
if (target.remoteType === extension.remoteType) {
this.attachMessageManager(extension, processMessageManager);
}
}
if (processMessageManager !== extension.parentMessageManager) {
throw new Error(
"Attempt to create privileged extension parent from incorrect child process"
);
}
} else if (JSProcessActorParent.isInstance(actor)) {
if (actor.manager.remoteType !== extension.remoteType) {
throw new Error(
"Attempt to create privileged extension parent from incorrect child process"
);
}
if (envType !== "addon_parent") {
throw new Error(
`Unexpected envType ${envType} on an extension process actor`
);
}
}
if (envType == "addon_parent" && data.viewType === "background_worker") {
context = new BackgroundWorkerContextParent(envType, extension, data);
} else if (envType == "addon_parent") {
context = new ExtensionPageContextParent(
envType,
extension,
data,
target
);
} else if (envType == "devtools_parent") {
context = new DevToolsExtensionPageContextParent(
envType,
extension,
data,
target
);
}
} else if (envType == "content_parent") {
context = new ContentScriptContextParent(
envType,
extension,
data,
target,
principal
);
} else {
throw new Error(`Invalid WebExtension context envType: ${envType}`);
}
this.proxyContexts.set(childId, context);
},
recvContextLoaded(data, { actor, sender }) {
let context = this.getContextById(data.childId);
verifyActorForContext(actor, context);
const { extension } = context;
extension.emit("extension-proxy-context-load:completed", context);
},
recvConduitClosed(sender) {
this.closeProxyContext(sender.id);
},
closeProxyContext(childId) {
let context = this.proxyContexts.get(childId);
if (context) {
context.unload();
this.proxyContexts.delete(childId);
}
},
async retrievePerformanceCounters() {
// getting the parent counters
return lazy.PerformanceCounters.getData();
},
/**
* Call the given function and also log the call as appropriate
* (i.e., with PerformanceCounters and/or activity logging)
*
* @param {BaseContext} context The context making this call.
* @param {object} data Additional data about the call.
* @param {Function} callable The actual implementation to invoke.
*/
async callAndLog(context, data, callable) {
let { id } = context.extension;
// If we were called via callParentAsyncFunction we don't want
// to log again, check for the flag.
const { alreadyLogged } = data.options || {};
if (!alreadyLogged) {
lazy.ExtensionActivityLog.log(
id,
context.viewType,
"api_call",
data.path,
{
args: data.args,
}
);
}
let start = Cu.now();
try {
return callable();
} finally {
ChromeUtils.addProfilerMarker(
"ExtensionParent",
{ startTime: start },
`${id}, api_call: ${data.path}`
);
if (lazy.gTimingEnabled) {
let end = Cu.now() * 1000;
lazy.PerformanceCounters.storeExecutionTime(
id,
data.path,
end - start * 1000
);
}
}
},
async recvAPICall(data, { actor }) {
let context = this.getContextById(data.childId);
let target = actor.browsingContext?.top.embedderElement;
verifyActorForContext(actor, context);
let reply = result => {
if (target && !context.parentMessageManager) {
Services.console.logStringMessage(
"Cannot send function call result: other side closed connection " +
`(call data: ${uneval({ path: data.path, args: data.args })})`
);
return;
}
this.conduit.sendCallResult(data.childId, {
childId: data.childId,
callId: data.callId,
path: data.path,
...result,
});
};
try {
let args = data.args;
let { isHandlingUserInput = false } = data.options || {};
let pendingBrowser = context.pendingEventBrowser;
let fun = await context.apiCan.asyncFindAPIPath(data.path);
let result = this.callAndLog(context, data, () => {
return context.withPendingBrowser(pendingBrowser, () =>
context.withCallContextData({ isHandlingUserInput }, () =>
fun(...args)
)
);
});
if (data.callId) {
result = result || Promise.resolve();
result.then(
result => {
result = result instanceof SpreadArgs ? [...result] : [result];
let holder = new StructuredCloneHolder(
`ExtensionParent/${context.extension.id}/recvAPICall/${data.path}`,
null,
result
);
reply({ result: holder });
},
error => {
error = context.normalizeError(error);
reply({
error: { message: error.message, fileName: error.fileName },
});
}
);
}
} catch (e) {
if (data.callId) {
let error = context.normalizeError(e);
reply({ error: { message: error.message } });
} else {
Cu.reportError(e);
}
}
},
async recvAddListener(data, { actor }) {
let context = this.getContextById(data.childId);
verifyActorForContext(actor, context);
let { childId, alreadyLogged = false } = data;
let handlingUserInput = false;
let listener = async (...listenerArgs) => {
let startTime = Cu.now();
// Extract urgentSend flag to avoid deserializing args holder later.
let urgentSend = false;
if (listenerArgs[0] && data.path.startsWith("webRequest.")) {
urgentSend = listenerArgs[0].urgentSend;
delete listenerArgs[0].urgentSend;
}
let runListenerPromise = this.conduit.queryRunListener(childId, {
childId,
handlingUserInput,
listenerId: data.listenerId,
path: data.path,
urgentSend,
get args() {
return new StructuredCloneHolder(
`ExtensionParent/${context.extension.id}/recvAddListener/${data.path}`,
null,
listenerArgs
);
},
});
context.trackRunListenerPromise(runListenerPromise);
const result = await runListenerPromise;
let rv = result && result.deserialize(globalThis);
ChromeUtils.addProfilerMarker(
"ExtensionParent",
{ startTime },
`${context.extension.id}, api_event: ${data.path}`
);
lazy.ExtensionActivityLog.log(
context.extension.id,
context.viewType,
"api_event",
data.path,
{ args: listenerArgs, result: rv }
);
return rv;
};
context.listenerProxies.set(data.listenerId, listener);
let args = data.args;
let promise = context.apiCan.asyncFindAPIPath(data.path);
// Store pending listener additions so we can be sure they're all
// fully initialize before we consider extension startup complete.
if (context.isBackgroundContext && context.listenerPromises) {
const { listenerPromises } = context;
listenerPromises.add(promise);
let remove = () => {
listenerPromises.delete(promise);
};
promise.then(remove, remove);
}
let handler = await promise;
if (handler.setUserInput) {
handlingUserInput = true;
}
handler.addListener(listener, ...args);
if (!alreadyLogged) {
lazy.ExtensionActivityLog.log(
context.extension.id,
context.viewType,
"api_call",
`${data.path}.addListener`,
{ args }
);
}
},
async recvRemoveListener(data) {
let context = this.getContextById(data.childId);
let listener = context.listenerProxies.get(data.listenerId);
let handler = await context.apiCan.asyncFindAPIPath(data.path);
handler.removeListener(listener);
let { alreadyLogged = false } = data;
if (!alreadyLogged) {
lazy.ExtensionActivityLog.log(
context.extension.id,
context.viewType,
"api_call",
`${data.path}.removeListener`,
{ args: [] }
);
}
},
getContextById(childId) {
let context = this.proxyContexts.get(childId);
if (!context) {
throw new Error("WebExtension context not found!");
}
return context;
},
};
ParentAPIManager.init();
/**
* A hidden window which contains the extension pages that are not visible
* (i.e., background pages and devtools pages), and is also used by
* ExtensionDebuggingUtils to contain the browser elements used by the
* addon debugger to connect to the devtools actors running in the same
* process of the target extension (and be able to stay connected across
* the addon reloads).
*/
class HiddenXULWindow {
constructor() {
this._windowlessBrowser = null;
this.unloaded = false;
this.waitInitialized = this.initWindowlessBrowser();
}
shutdown() {
if (this.unloaded) {
throw new Error(
"Unable to shutdown an unloaded HiddenXULWindow instance"
);
}
this.unloaded = true;
this.waitInitialized = null;
if (!this._windowlessBrowser) {
Cu.reportError("HiddenXULWindow was shut down while it was loading.");
// initWindowlessBrowser will close windowlessBrowser when possible.
return;
}
this._windowlessBrowser.close();
this._windowlessBrowser = null;
}
get chromeDocument() {
return this._windowlessBrowser.document;
}
/**
* Private helper that create a HTMLDocument in a windowless browser.
*
* @returns {Promise<void>}
* A promise which resolves when the windowless browser is ready.
*/
async initWindowlessBrowser() {
if (this.waitInitialized) {
throw new Error("HiddenXULWindow already initialized");
}
// The invisible page is currently wrapped in a XUL window to fix an issue
let windowlessBrowser = Services.appShell.createWindowlessBrowser(true);
// The windowless browser is a thin wrapper around a docShell that keeps
// its related resources alive. It implements nsIWebNavigation and
// forwards its methods to the underlying docShell. That .docShell
// needs `QueryInterface(nsIWebNavigation)` to give us access to the
// webNav methods that are already available on the windowless browser.
let chromeShell = windowlessBrowser.docShell;
chromeShell.QueryInterface(Ci.nsIWebNavigation);
if (lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) {
let attrs = chromeShell.getOriginAttributes();
attrs.privateBrowsingId = 1;
chromeShell.setOriginAttributes(attrs);
}
windowlessBrowser.browsingContext.useGlobalHistory = false;
chromeShell.loadURI(DUMMY_PAGE_URI, {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
});
await promiseObserved(
"chrome-document-global-created",
win => win.document == chromeShell.document
);
await promiseDocumentLoaded(windowlessBrowser.document);
if (this.unloaded) {
windowlessBrowser.close();
return;
}
this._windowlessBrowser = windowlessBrowser;
}
/**
* Creates the browser XUL element that will contain the WebExtension Page.
*
* @param {object} xulAttributes
* An object that contains the xul attributes to set of the newly
* created browser XUL element.
*
* @returns {Promise<XULElement>}
* A Promise which resolves to the newly created browser XUL element.
*/
async createBrowserElement(xulAttributes) {
if (!xulAttributes || Object.keys(xulAttributes).length === 0) {
throw new Error("missing mandatory xulAttributes parameter");
}
await this.waitInitialized;
const chromeDoc = this.chromeDocument;
const browser = chromeDoc.createXULElement("browser");
browser.setAttribute("type", "content");
browser.setAttribute("disableglobalhistory", "true");
browser.setAttribute("messagemanagergroup", "webext-browsers");
for (const [name, value] of Object.entries(xulAttributes)) {
if (value != null) {
browser.setAttribute(name, value);
}
}
let awaitFrameLoader = Promise.resolve();
if (browser.getAttribute("remote") === "true") {
awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
}
chromeDoc.documentElement.appendChild(browser);
// Forcibly flush layout so that we get a pres shell soon enough, see
browser.getBoundingClientRect();
await awaitFrameLoader;
return browser;
}
}
const SharedWindow = {
_window: null,
_count: 0,
acquire() {
if (this._window == null) {
if (this._count != 0) {
throw new Error(
`Shared window already exists with count ${this._count}`
);
}
this._window = new HiddenXULWindow();
}
this._count++;
return this._window;
},
release() {
if (this._count < 1) {
throw new Error(`Releasing shared window with count ${this._count}`);
}
this._count--;
if (this._count == 0) {
this._window.shutdown();
this._window = null;
}
},
};
/**
* This is a base class used by the ext-backgroundPage and ext-devtools API implementations
* to inherits the shared boilerplate code needed to create a parent document for the hidden
* extension pages (e.g. the background page, the devtools page) in the BackgroundPage and
* DevToolsPage classes.
*
* @param {Extension} extension
* The Extension which owns the hidden extension page created (used to decide
* if the hidden extension page parent doc is going to be a windowlessBrowser or
* a visible XUL window).
* @param {string} viewType
* The viewType of the WebExtension page that is going to be loaded
* in the created browser element (e.g. "background" or "devtools_page").
*/
class HiddenExtensionPage {
constructor(extension, viewType) {
if (!extension || !viewType) {
throw new Error("extension and viewType parameters are mandatory");
}
this.extension = extension;
this.viewType = viewType;
this.browser = null;
this.unloaded = false;
}
/**
* Destroy the created parent document.
*/
shutdown() {
if (this.unloaded) {
throw new Error(
"Unable to shutdown an unloaded HiddenExtensionPage instance"
);
}
this.unloaded = true;
if (this.browser) {
this._releaseBrowser();
}
}
_releaseBrowser() {
this.browser.remove();
this.browser = null;
SharedWindow.release();
}
/**
* Creates the browser XUL element that will contain the WebExtension Page.
*
* @returns {Promise<XULElement>}
* A Promise which resolves to the newly created browser XUL element.
*/
async createBrowserElement() {
if (this.browser) {
throw new Error("createBrowserElement called twice");
}
let window = SharedWindow.acquire();
try {
this.browser = await window.createBrowserElement({
"webextension-view-type": this.viewType,
remote: this.extension.remote ? "true" : null,
remoteType: this.extension.remoteType,
initialBrowsingContextGroupId: this.extension.browsingContextGroupId,
});
} catch (e) {
SharedWindow.release();
throw e;
}
if (this.unloaded) {
this._releaseBrowser();
throw new Error("Extension shut down before browser element was created");
}
return this.browser;
}
}
/**
* This object provides utility functions needed by the devtools actors to
* be able to connect and debug an extension (which can run in the main or in
* a child extension process).
*/
const DebugUtils = {
// A lazily created hidden XUL window, which contains the browser elements
// which are used to connect the webextension patent actor to the extension process.
hiddenXULWindow: null,
// Map<extensionId, Promise<XULElement>>
debugBrowserPromises: new Map(),
// DefaultWeakMap<Promise<browser XULElement>, Set<WebExtensionParentActor>>
debugActors: new DefaultWeakMap(() => new Set()),
_extensionUpdatedWatcher: null,
watchExtensionUpdated() {
if (!this._extensionUpdatedWatcher) {
// Watch the updated extension objects.
this._extensionUpdatedWatcher = async (evt, extension) => {
const browserPromise = this.debugBrowserPromises.get(extension.id);
if (browserPromise) {
const browser = await browserPromise;
if (
browser.isRemoteBrowser !== extension.remote &&
this.debugBrowserPromises.get(extension.id) === browserPromise
) {
// If the cached browser element is not anymore of the same
// remote type of the extension, remove it.
this.debugBrowserPromises.delete(extension.id);
browser.remove();
}
}
};
apiManager.on("ready", this._extensionUpdatedWatcher);
}
},
unwatchExtensionUpdated() {
if (this._extensionUpdatedWatcher) {
apiManager.off("ready", this._extensionUpdatedWatcher);
delete this._extensionUpdatedWatcher;
}
},
getExtensionManifestWarnings(id) {
const addon = GlobalManager.extensionMap.get(id);
if (addon) {
return addon.warnings;
}
return [];
},
/**
* Determine if the extension does have a non-persistent background script
* (either an event page or a background service worker):
*
* Based on this the DevTools client will determine if this extension should provide
* to the extension developers a button to forcefully terminate the background
* script.
*
* @param {string} addonId
* The id of the addon
*
* @returns {void|boolean}
* - undefined => does not apply (no background script in the manifest)
* - true => the background script is persistent.
* - false => the background script is an event page or a service worker.
*/
hasPersistentBackgroundScript(addonId) {
const policy = WebExtensionPolicy.getByID(addonId);
// The addon doesn't have any background script or we
// can't be sure yet.
if (
policy?.extension?.type !== "extension" ||
!policy?.extension?.manifest?.background
) {
return undefined;
}
return policy.extension.persistentBackground;
},
/**
* Determine if the extension background page is running.
*
* Based on this the DevTools client will show the status of the background
* script in about:debugging.
*
* @param {string} addonId
* The id of the addon
*
* @returns {void|boolean}
* - undefined => does not apply (no background script in the manifest)
* - true => the background script is running.
* - false => the background script is stopped.
*/
isBackgroundScriptRunning(addonId) {
const policy = WebExtensionPolicy.getByID(addonId);
// The addon doesn't have any background script or we
// can't be sure yet.
if (!(this.hasPersistentBackgroundScript(addonId) === false)) {
return undefined;
}
const views = policy?.extension?.views || [];
for (const view of views) {
if (
view.viewType === "background" ||
(view.viewType === "background_worker" && !view.unloaded)
) {
return true;
}
}
return false;
},
async terminateBackgroundScript(addonId) {