summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/ExtensionParent.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/extensions/ExtensionParent.sys.mjs
parentInitial commit. (diff)
downloadfirefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz
firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/extensions/ExtensionParent.sys.mjs')
-rw-r--r--toolkit/components/extensions/ExtensionParent.sys.mjs2331
1 files changed, 2331 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ExtensionParent.sys.mjs b/toolkit/components/extensions/ExtensionParent.sys.mjs
new file mode 100644
index 0000000000..13125f7a4b
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionParent.sys.mjs
@@ -0,0 +1,2331 @@
+/* -*- 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/. */
+
+/**
+ * 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.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ BroadcastConduit: "resource://gre/modules/ConduitsParent.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs",
+ ExtensionActivityLog: "resource://gre/modules/ExtensionActivityLog.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ GeckoViewConnection: "resource://gre/modules/GeckoViewWebExtension.sys.mjs",
+ MessageManagerProxy: "resource://gre/modules/MessageManagerProxy.sys.mjs",
+ NativeApp: "resource://gre/modules/NativeMessaging.sys.mjs",
+ PerformanceCounters: "resource://gre/modules/PerformanceCounters.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ Schemas: "resource://gre/modules/Schemas.sys.mjs",
+ getErrorNameForTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
+});
+
+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
+);
+import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs";
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+
+const DUMMY_PAGE_URI = Services.io.newURI(
+ "chrome://extensions/content/dummy.xhtml"
+);
+
+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 BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
+const CATEGORY_EXTENSION_MODULES = "webextension-modules";
+const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas";
+const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts";
+
+let schemaURLs = new Set();
+
+schemaURLs.add("chrome://extensions/content/schemas/experiments.json");
+
+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;
+ },
+
+ // TODO: Rework/simplify this and getSender/getTopBC after bug 1580766.
+ 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() {
+ // TODO: Bug 1595186 - remove/replace all usage of MessageManager below.
+ 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
+ // with using the canvas API from a background page (See Bug 1274775).
+ 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
+ // bug 1274775.
+ 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) {
+ // Terminate the background if the extension does have
+ // a non-persistent background script (event page or background
+ // service worker).
+ if (this.hasPersistentBackgroundScript(addonId) === false) {
+ const policy = WebExtensionPolicy.getByID(addonId);
+ // When the event page is being terminated through the Devtools
+ // action, we should terminate it even if there are DevTools
+ // toolboxes attached to the extension.
+ return policy.extension.terminateBackground({
+ ignoreDevToolsAttached: true,
+ });
+ }
+ throw Error(`Unable to terminate background script for ${addonId}`);
+ },
+
+ /**
+ * Determine whether a devtools toolbox attached to the extension.
+ *
+ * This method is called by the background page idle timeout handler,
+ * to inhibit terminating the event page when idle while the extension
+ * developer is debugging the extension through the Addon Debugging window
+ * (similarly to how service workers are kept alive while the devtools are
+ * attached).
+ *
+ * @param {string} id
+ * The id of the extension.
+ *
+ * @returns {boolean}
+ * true when a devtools toolbox is attached to an extension with
+ * the given id, false otherwise.
+ */
+ hasDevToolsAttached(id) {
+ return this.debugBrowserPromises.has(id);
+ },
+
+ /**
+ * Retrieve a XUL browser element which has been configured to be able to connect
+ * the devtools actor with the process where the extension is running.
+ *
+ * @param {WebExtensionParentActor} webExtensionParentActor
+ * The devtools actor that is retrieving the browser element.
+ *
+ * @returns {Promise<XULElement>}
+ * A promise which resolves to the configured browser XUL element.
+ */
+ async getExtensionProcessBrowser(webExtensionParentActor) {
+ const extensionId = webExtensionParentActor.addonId;
+ const extension = GlobalManager.getExtension(extensionId);
+ if (!extension) {
+ throw new Error(`Extension not found: ${extensionId}`);
+ }
+
+ const createBrowser = () => {
+ if (!this.hiddenXULWindow) {
+ this.hiddenXULWindow = new HiddenXULWindow();
+ this.watchExtensionUpdated();
+ }
+
+ return this.hiddenXULWindow.createBrowserElement({
+ "webextension-addon-debug-target": extensionId,
+ remote: extension.remote ? "true" : null,
+ remoteType: extension.remoteType,
+ initialBrowsingContextGroupId: extension.browsingContextGroupId,
+ });
+ };
+
+ let browserPromise = this.debugBrowserPromises.get(extensionId);
+
+ // Create a new promise if there is no cached one in the map.
+ if (!browserPromise) {
+ browserPromise = createBrowser();
+ this.debugBrowserPromises.set(extensionId, browserPromise);
+ browserPromise.then(browser => {
+ browserPromise.browser = browser;
+ });
+ browserPromise.catch(e => {
+ Cu.reportError(e);
+ this.debugBrowserPromises.delete(extensionId);
+ });
+ }
+
+ this.debugActors.get(browserPromise).add(webExtensionParentActor);
+
+ return browserPromise;
+ },
+
+ getFrameLoader(extensionId) {
+ let promise = this.debugBrowserPromises.get(extensionId);
+ return promise && promise.browser && promise.browser.frameLoader;
+ },
+
+ /**
+ * Given the devtools actor that has retrieved an addon debug browser element,
+ * it destroys the XUL browser element, and it also destroy the hidden XUL window
+ * if it is not currently needed.
+ *
+ * @param {WebExtensionParentActor} webExtensionParentActor
+ * The devtools actor that has retrieved an addon debug browser element.
+ */
+ async releaseExtensionProcessBrowser(webExtensionParentActor) {
+ const extensionId = webExtensionParentActor.addonId;
+ const browserPromise = this.debugBrowserPromises.get(extensionId);
+
+ if (browserPromise) {
+ const actorsSet = this.debugActors.get(browserPromise);
+ actorsSet.delete(webExtensionParentActor);
+ if (actorsSet.size === 0) {
+ this.debugActors.delete(browserPromise);
+ this.debugBrowserPromises.delete(extensionId);
+ await browserPromise.then(browser => browser.remove());
+ }
+ }
+
+ if (this.debugBrowserPromises.size === 0 && this.hiddenXULWindow) {
+ this.hiddenXULWindow.shutdown();
+ this.hiddenXULWindow = null;
+ this.unwatchExtensionUpdated();
+ }
+ },
+};
+
+/**
+ * Returns a Promise which resolves with the message data when the given message
+ * was received by the message manager. The promise is rejected if the message
+ * manager was closed before a message was received.
+ *
+ * @param {MessageListenerManager} messageManager
+ * The message manager on which to listen for messages.
+ * @param {string} messageName
+ * The message to listen for.
+ * @returns {Promise<*>}
+ */
+function promiseMessageFromChild(messageManager, messageName) {
+ return new Promise((resolve, reject) => {
+ let unregister;
+ function listener(message) {
+ unregister();
+ resolve(message.data);
+ }
+ function observer(subject, topic, data) {
+ if (subject === messageManager) {
+ unregister();
+ reject(
+ new Error(
+ `Message manager was disconnected before receiving ${messageName}`
+ )
+ );
+ }
+ }
+ unregister = () => {
+ Services.obs.removeObserver(observer, "message-manager-close");
+ messageManager.removeMessageListener(messageName, listener);
+ };
+ messageManager.addMessageListener(messageName, listener);
+ Services.obs.addObserver(observer, "message-manager-close");
+ });
+}
+
+// This should be called before browser.loadURI is invoked.
+async function promiseExtensionViewLoaded(browser) {
+ let { childId } = await promiseMessageFromChild(
+ browser.messageManager,
+ "Extension:ExtensionViewLoaded"
+ );
+ if (childId) {
+ return ParentAPIManager.getContextById(childId);
+ }
+}
+
+/**
+ * This helper is used to subscribe a listener (e.g. in the ext-devtools API implementation)
+ * to be called for every ExtensionProxyContext created for an extension page given
+ * its related extension, viewType and browser element (both the top level context and any context
+ * created for the extension urls running into its iframe descendants).
+ *
+ * @param {object} params
+ * @param {object} params.extension
+ * The Extension on which we are going to listen for the newly created ExtensionProxyContext.
+ * @param {string} params.viewType
+ * The viewType of the WebExtension page that we are watching (e.g. "background" or
+ * "devtools_page").
+ * @param {XULElement} params.browser
+ * The browser element of the WebExtension page that we are watching.
+ * @param {Function} onExtensionProxyContextLoaded
+ * The callback that is called when a new context has been loaded (as `callback(context)`);
+ *
+ * @returns {Function}
+ * Unsubscribe the listener.
+ */
+function watchExtensionProxyContextLoad(
+ { extension, viewType, browser },
+ onExtensionProxyContextLoaded
+) {
+ if (typeof onExtensionProxyContextLoaded !== "function") {
+ throw new Error("Missing onExtensionProxyContextLoaded handler");
+ }
+
+ const listener = (event, context) => {
+ if (context.viewType == viewType && context.xulBrowser == browser) {
+ onExtensionProxyContextLoaded(context);
+ }
+ };
+
+ extension.on("extension-proxy-context-load", listener);
+
+ return () => {
+ extension.off("extension-proxy-context-load", listener);
+ };
+}
+
+/**
+ * This helper is used to subscribe a listener (e.g. in the ext-backgroundPage)
+ * to be called for every ExtensionProxyContext created for an extension
+ * background service worker given its related extension.
+ *
+ * @param {object} params
+ * @param {object} params.extension
+ * The Extension on which we are going to listen for the newly created ExtensionProxyContext.
+ * @param {Function} onExtensionWorkerContextLoaded
+ * The callback that is called when the worker script has been fully loaded (as `callback(context)`);
+ *
+ * @returns {Function}
+ * Unsubscribe the listener.
+ */
+function watchExtensionWorkerContextLoaded(
+ { extension },
+ onExtensionWorkerContextLoaded
+) {
+ if (typeof onExtensionWorkerContextLoaded !== "function") {
+ throw new Error("Missing onExtensionWorkerContextLoaded handler");
+ }
+
+ const listener = (event, context) => {
+ if (context.viewType == "background_worker") {
+ onExtensionWorkerContextLoaded(context);
+ }
+ };
+
+ extension.on("extension-proxy-context-load:completed", listener);
+
+ return () => {
+ extension.off("extension-proxy-context-load:completed", listener);
+ };
+}
+
+// Manages icon details for toolbar buttons in the |pageAction| and
+// |browserAction| APIs.
+let IconDetails = {
+ DEFAULT_ICON: "chrome://mozapps/skin/extensions/extensionGeneric.svg",
+
+ // WeakMap<Extension -> Map<url-string -> Map<iconType-string -> object>>>
+ iconCache: new DefaultWeakMap(() => {
+ return new DefaultMap(() => new DefaultMap(() => new Map()));
+ }),
+
+ // Normalizes the various acceptable input formats into an object
+ // with icon size as key and icon URL as value.
+ //
+ // If a context is specified (function is called from an extension):
+ // Throws an error if an invalid icon size was provided or the
+ // extension is not allowed to load the specified resources.
+ //
+ // If no context is specified, instead of throwing an error, this
+ // function simply logs a warning message.
+ normalize(details, extension, context = null) {
+ if (!details.imageData && details.path != null) {
+ // Pick a cache key for the icon paths. If the path is a string,
+ // use it directly. Otherwise, stringify the path object.
+ let key = details.path;
+ if (typeof key !== "string") {
+ key = uneval(key);
+ }
+
+ let icons = this.iconCache
+ .get(extension)
+ .get(context && context.uri.spec)
+ .get(details.iconType);
+
+ let icon = icons.get(key);
+ if (!icon) {
+ icon = this._normalize(details, extension, context);
+ icons.set(key, icon);
+ }
+ return icon;
+ }
+
+ return this._normalize(details, extension, context);
+ },
+
+ _normalize(details, extension, context = null) {
+ let result = {};
+
+ try {
+ let { imageData, path, themeIcons } = details;
+
+ if (imageData) {
+ if (typeof imageData == "string") {
+ imageData = { 19: imageData };
+ }
+
+ for (let size of Object.keys(imageData)) {
+ result[size] = imageData[size];
+ }
+ }
+
+ let baseURI = context ? context.uri : extension.baseURI;
+
+ if (path != null) {
+ if (typeof path != "object") {
+ path = { 19: path };
+ }
+
+ for (let size of Object.keys(path)) {
+ let url = path[size];
+ if (url) {
+ url = baseURI.resolve(path[size]);
+
+ // The Chrome documentation specifies these parameters as
+ // relative paths. We currently accept absolute URLs as well,
+ // which means we need to check that the extension is allowed
+ // to load them. This will throw an error if it's not allowed.
+ this._checkURL(url, extension);
+ }
+ result[size] = url || this.DEFAULT_ICON;
+ }
+ }
+
+ if (themeIcons) {
+ themeIcons.forEach(({ size, light, dark }) => {
+ let lightURL = baseURI.resolve(light);
+ let darkURL = baseURI.resolve(dark);
+
+ this._checkURL(lightURL, extension);
+ this._checkURL(darkURL, extension);
+
+ let defaultURL = result[size] || result[19]; // always fallback to default first
+ result[size] = {
+ default: defaultURL || darkURL, // Fallback to the dark url if no default is specified.
+ light: lightURL,
+ dark: darkURL,
+ };
+ });
+ }
+ } catch (e) {
+ // Function is called from extension code, delegate error.
+ if (context) {
+ throw e;
+ }
+ // If there's no context, it's because we're handling this
+ // as a manifest directive. Log a warning rather than
+ // raising an error.
+ extension.manifestError(`Invalid icon data: ${e}`);
+ }
+
+ return result;
+ },
+
+ // Checks if the extension is allowed to load the given URL with the specified principal.
+ // This will throw an error if the URL is not allowed.
+ _checkURL(url, extension) {
+ if (!extension.checkLoadURL(url, { allowInheritsPrincipal: true })) {
+ throw new ExtensionError(`Illegal URL ${url}`);
+ }
+ },
+
+ // Returns the appropriate icon URL for the given icons object and the
+ // screen resolution of the given window.
+ getPreferredIcon(icons, extension = null, size = 16) {
+ const DEFAULT = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+ let bestSize = null;
+ if (icons[size]) {
+ bestSize = size;
+ } else if (icons[2 * size]) {
+ bestSize = 2 * size;
+ } else {
+ let sizes = Object.keys(icons)
+ .map(key => parseInt(key, 10))
+ .sort((a, b) => a - b);
+
+ bestSize = sizes.find(candidate => candidate > size) || sizes.pop();
+ }
+
+ if (bestSize) {
+ return { size: bestSize, icon: icons[bestSize] || DEFAULT };
+ }
+
+ return { size, icon: DEFAULT };
+ },
+
+ // These URLs should already be properly escaped, but make doubly sure CSS
+ // string escape characters are escaped here, since they could lead to a
+ // sandbox break.
+ escapeUrl(url) {
+ return url.replace(/[\\\s"]/g, encodeURIComponent);
+ },
+};
+
+// A cache to support faster initialization of extensions at browser startup.
+// All cached data is removed when the browser is updated.
+// Extension-specific data is removed when the add-on is updated.
+StartupCache = {
+ STORE_NAMES: Object.freeze([
+ "general",
+ "locales",
+ "manifests",
+ "other",
+ "permissions",
+ "schemas",
+ "menus",
+ ]),
+
+ _ensureDirectoryPromise: null,
+ _saveTask: null,
+
+ _ensureDirectory() {
+ if (this._ensureDirectoryPromise === null) {
+ this._ensureDirectoryPromise = IOUtils.makeDirectory(
+ PathUtils.parent(this.file),
+ {
+ ignoreExisting: true,
+ createAncestors: true,
+ }
+ );
+ }
+
+ return this._ensureDirectoryPromise;
+ },
+
+ // When the application version changes, this file is removed by
+ // RemoveComponentRegistries in nsAppRunner.cpp.
+ file: PathUtils.join(
+ Services.dirsvc.get("ProfLD", Ci.nsIFile).path,
+ "startupCache",
+ "webext.sc.lz4"
+ ),
+
+ async _saveNow() {
+ let data = new Uint8Array(lazy.aomStartup.encodeBlob(this._data));
+ await this._ensureDirectoryPromise;
+ await IOUtils.write(this.file, data, { tmpPath: `${this.file}.tmp` });
+ Services.telemetry.scalarSet(
+ "extensions.startupCache.write_byteLength",
+ data.byteLength
+ );
+ },
+
+ save() {
+ this._ensureDirectory();
+
+ if (!this._saveTask) {
+ this._saveTask = new lazy.DeferredTask(() => this._saveNow(), 5000);
+
+ IOUtils.profileBeforeChange.addBlocker(
+ "Flush WebExtension StartupCache",
+ async () => {
+ await this._saveTask.finalize();
+ this._saveTask = null;
+ }
+ );
+ }
+
+ return this._saveTask.arm();
+ },
+
+ _data: null,
+ async _readData() {
+ let result = new Map();
+ try {
+ Glean.extensions.startupCacheLoadTime.start();
+ let { buffer } = await IOUtils.read(this.file);
+
+ result = lazy.aomStartup.decodeBlob(buffer);
+ Glean.extensions.startupCacheLoadTime.stop();
+ } catch (e) {
+ Glean.extensions.startupCacheLoadTime.cancel();
+ if (!DOMException.isInstance(e) || e.name !== "NotFoundError") {
+ Cu.reportError(e);
+ }
+
+ Services.telemetry.keyedScalarAdd(
+ "extensions.startupCache.read_errors",
+ lazy.getErrorNameForTelemetry(e),
+ 1
+ );
+ }
+
+ this._data = result;
+ return result;
+ },
+
+ get dataPromise() {
+ if (!this._dataPromise) {
+ this._dataPromise = this._readData();
+ }
+ return this._dataPromise;
+ },
+
+ clearAddonData(id) {
+ return Promise.all([
+ this.general.delete(id),
+ this.locales.delete(id),
+ this.manifests.delete(id),
+ this.permissions.delete(id),
+ this.menus.delete(id),
+ ]).catch(e => {
+ // Ignore the error. It happens when we try to flush the add-on
+ // data after the AddonManager has flushed the entire startup cache.
+ });
+ },
+
+ observe(subject, topic, data) {
+ if (topic === "startupcache-invalidate") {
+ this._data = new Map();
+ this._dataPromise = Promise.resolve(this._data);
+ }
+ },
+
+ get(extension, path, createFunc) {
+ return this.general.get(
+ [extension.id, extension.version, ...path],
+ createFunc
+ );
+ },
+
+ delete(extension, path) {
+ return this.general.delete([extension.id, extension.version, ...path]);
+ },
+};
+
+Services.obs.addObserver(StartupCache, "startupcache-invalidate");
+
+class CacheStore {
+ constructor(storeName) {
+ this.storeName = storeName;
+ }
+
+ async getStore(path = null) {
+ let data = await StartupCache.dataPromise;
+
+ let store = data.get(this.storeName);
+ if (!store) {
+ store = new Map();
+ data.set(this.storeName, store);
+ }
+
+ let key = path;
+ if (Array.isArray(path)) {
+ for (let elem of path.slice(0, -1)) {
+ let next = store.get(elem);
+ if (!next) {
+ next = new Map();
+ store.set(elem, next);
+ }
+ store = next;
+ }
+ key = path[path.length - 1];
+ }
+
+ return [store, key];
+ }
+
+ async get(path, createFunc) {
+ let [store, key] = await this.getStore(path);
+
+ let result = store.get(key);
+
+ if (result === undefined) {
+ result = await createFunc(path);
+ store.set(key, result);
+ StartupCache.save();
+ }
+
+ return result;
+ }
+
+ async set(path, value) {
+ let [store, key] = await this.getStore(path);
+
+ store.set(key, value);
+ StartupCache.save();
+ }
+
+ async getAll() {
+ let [store] = await this.getStore();
+
+ return new Map(store);
+ }
+
+ async delete(path) {
+ let [store, key] = await this.getStore(path);
+
+ if (store.delete(key)) {
+ StartupCache.save();
+ }
+ }
+}
+
+for (let name of StartupCache.STORE_NAMES) {
+ StartupCache[name] = new CacheStore(name);
+}
+
+export var ExtensionParent = {
+ GlobalManager,
+ HiddenExtensionPage,
+ IconDetails,
+ ParentAPIManager,
+ StartupCache,
+ WebExtensionPolicy,
+ apiManager,
+ promiseExtensionViewLoaded,
+ watchExtensionProxyContextLoad,
+ watchExtensionWorkerContextLoaded,
+ DebugUtils,
+};
+
+// browserPaintedPromise and browserStartupPromise are promises that
+// resolve after the first browser window is painted and after browser
+// windows have been restored, respectively. Alternatively,
+// browserStartupPromise also resolves from the extensions-late-startup
+// notification sent by Firefox Reality on desktop platforms, because it
+// doesn't support SessionStore.
+// _resetStartupPromises should only be called from outside this file in tests.
+ExtensionParent._resetStartupPromises = () => {
+ ExtensionParent.browserPaintedPromise = promiseObserved(
+ "browser-delayed-startup-finished"
+ ).then(() => {});
+ ExtensionParent.browserStartupPromise = Promise.race([
+ promiseObserved("sessionstore-windows-restored"),
+ promiseObserved("extensions-late-startup"),
+ ]).then(() => {});
+};
+ExtensionParent._resetStartupPromises();
+
+XPCOMUtils.defineLazyGetter(ExtensionParent, "PlatformInfo", () => {
+ return Object.freeze({
+ os: (function () {
+ let os = AppConstants.platform;
+ if (os == "macosx") {
+ os = "mac";
+ }
+ return os;
+ })(),
+ arch: (function () {
+ let abi = Services.appinfo.XPCOMABI;
+ let [arch] = abi.split("-");
+ if (arch == "x86") {
+ arch = "x86-32";
+ } else if (arch == "x86_64") {
+ arch = "x86-64";
+ }
+ return arch;
+ })(),
+ });
+});
+
+/**
+ * Retreives the browser_style stylesheets needed for extension popups and sidebars.
+ *
+ * @returns {Array<string>} an array of stylesheets needed for the current platform.
+ */
+XPCOMUtils.defineLazyGetter(ExtensionParent, "extensionStylesheets", () => {
+ let stylesheets = ["chrome://browser/content/extension.css"];
+
+ if (AppConstants.platform === "macosx") {
+ stylesheets.push("chrome://browser/content/extension-mac.css");
+ }
+
+ return stylesheets;
+});