summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/parent
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/extensions/parent
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/extensions/parent')
-rw-r--r--toolkit/components/extensions/parent/.eslintrc.js32
-rw-r--r--toolkit/components/extensions/parent/ext-activityLog.js38
-rw-r--r--toolkit/components/extensions/parent/ext-alarms.js161
-rw-r--r--toolkit/components/extensions/parent/ext-backgroundPage.js1116
-rw-r--r--toolkit/components/extensions/parent/ext-browserSettings.js592
-rw-r--r--toolkit/components/extensions/parent/ext-browsingData.js405
-rw-r--r--toolkit/components/extensions/parent/ext-captivePortal.js158
-rw-r--r--toolkit/components/extensions/parent/ext-clipboard.js87
-rw-r--r--toolkit/components/extensions/parent/ext-contentScripts.js232
-rw-r--r--toolkit/components/extensions/parent/ext-contextualIdentities.js362
-rw-r--r--toolkit/components/extensions/parent/ext-cookies.js696
-rw-r--r--toolkit/components/extensions/parent/ext-declarativeNetRequest.js169
-rw-r--r--toolkit/components/extensions/parent/ext-dns.js87
-rw-r--r--toolkit/components/extensions/parent/ext-downloads.js1261
-rw-r--r--toolkit/components/extensions/parent/ext-extension.js25
-rw-r--r--toolkit/components/extensions/parent/ext-geckoProfiler.js191
-rw-r--r--toolkit/components/extensions/parent/ext-i18n.js46
-rw-r--r--toolkit/components/extensions/parent/ext-identity.js152
-rw-r--r--toolkit/components/extensions/parent/ext-idle.js113
-rw-r--r--toolkit/components/extensions/parent/ext-management.js354
-rw-r--r--toolkit/components/extensions/parent/ext-networkStatus.js85
-rw-r--r--toolkit/components/extensions/parent/ext-notifications.js188
-rw-r--r--toolkit/components/extensions/parent/ext-permissions.js191
-rw-r--r--toolkit/components/extensions/parent/ext-privacy.js516
-rw-r--r--toolkit/components/extensions/parent/ext-protocolHandlers.js100
-rw-r--r--toolkit/components/extensions/parent/ext-proxy.js335
-rw-r--r--toolkit/components/extensions/parent/ext-runtime.js310
-rw-r--r--toolkit/components/extensions/parent/ext-scripting.js365
-rw-r--r--toolkit/components/extensions/parent/ext-storage.js366
-rw-r--r--toolkit/components/extensions/parent/ext-tabs-base.js2377
-rw-r--r--toolkit/components/extensions/parent/ext-telemetry.js195
-rw-r--r--toolkit/components/extensions/parent/ext-theme.js529
-rw-r--r--toolkit/components/extensions/parent/ext-toolkit.js130
-rw-r--r--toolkit/components/extensions/parent/ext-userScripts.js158
-rw-r--r--toolkit/components/extensions/parent/ext-webNavigation.js276
-rw-r--r--toolkit/components/extensions/parent/ext-webRequest.js206
36 files changed, 12604 insertions, 0 deletions
diff --git a/toolkit/components/extensions/parent/.eslintrc.js b/toolkit/components/extensions/parent/.eslintrc.js
new file mode 100644
index 0000000000..2af2a2b34b
--- /dev/null
+++ b/toolkit/components/extensions/parent/.eslintrc.js
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+module.exports = {
+ globals: {
+ CONTAINER_STORE: true,
+ DEFAULT_STORE: true,
+ EventEmitter: true,
+ EventManager: true,
+ InputEventManager: true,
+ PRIVATE_STORE: true,
+ TabBase: true,
+ TabManagerBase: true,
+ TabTrackerBase: true,
+ WindowBase: true,
+ WindowManagerBase: true,
+ WindowTrackerBase: true,
+ getUserContextIdForCookieStoreId: true,
+ getContainerForCookieStoreId: true,
+ getCookieStoreIdForContainer: true,
+ getCookieStoreIdForOriginAttributes: true,
+ getCookieStoreIdForTab: true,
+ getOriginAttributesPatternForCookieStoreId: true,
+ isContainerCookieStoreId: true,
+ isDefaultCookieStoreId: true,
+ isPrivateCookieStoreId: true,
+ isValidCookieStoreId: true,
+ },
+};
diff --git a/toolkit/components/extensions/parent/ext-activityLog.js b/toolkit/components/extensions/parent/ext-activityLog.js
new file mode 100644
index 0000000000..2b0c68614e
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-activityLog.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionActivityLog: "resource://gre/modules/ExtensionActivityLog.sys.mjs",
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
+});
+
+this.activityLog = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ activityLog: {
+ onExtensionActivity: new ExtensionCommon.EventManager({
+ context,
+ name: "activityLog.onExtensionActivity",
+ register: (fire, id) => {
+ // A logger cannot log itself.
+ if (id === context.extension.id) {
+ throw new ExtensionUtils.ExtensionError(
+ "Extension cannot monitor itself."
+ );
+ }
+ function handler(details) {
+ fire.async(details);
+ }
+
+ ExtensionActivityLog.addListener(id, handler);
+ return () => {
+ ExtensionActivityLog.removeListener(id, handler);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-alarms.js b/toolkit/components/extensions/parent/ext-alarms.js
new file mode 100644
index 0000000000..1eea8397e2
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-alarms.js
@@ -0,0 +1,161 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// The ext-* files are imported into the same scopes.
+/* import-globals-from ext-toolkit.js */
+
+// Manages an alarm created by the extension (alarms API).
+class Alarm {
+ constructor(api, name, alarmInfo) {
+ this.api = api;
+ this.name = name;
+ this.when = alarmInfo.when;
+ this.delayInMinutes = alarmInfo.delayInMinutes;
+ this.periodInMinutes = alarmInfo.periodInMinutes;
+ this.canceled = false;
+
+ let delay, scheduledTime;
+ if (this.when) {
+ scheduledTime = this.when;
+ delay = this.when - Date.now();
+ } else {
+ if (!this.delayInMinutes) {
+ this.delayInMinutes = this.periodInMinutes;
+ }
+ delay = this.delayInMinutes * 60 * 1000;
+ scheduledTime = Date.now() + delay;
+ }
+
+ this.scheduledTime = scheduledTime;
+
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ delay = delay > 0 ? delay : 0;
+ timer.init(this, delay, Ci.nsITimer.TYPE_ONE_SHOT);
+ this.timer = timer;
+ }
+
+ clear() {
+ this.timer.cancel();
+ this.api.alarms.delete(this.name);
+ this.canceled = true;
+ }
+
+ observe(subject, topic, data) {
+ if (this.canceled) {
+ return;
+ }
+
+ for (let callback of this.api.callbacks) {
+ callback(this);
+ }
+
+ if (!this.periodInMinutes) {
+ this.clear();
+ return;
+ }
+
+ let delay = this.periodInMinutes * 60 * 1000;
+ this.scheduledTime = Date.now() + delay;
+ this.timer.init(this, delay, Ci.nsITimer.TYPE_ONE_SHOT);
+ }
+
+ get data() {
+ return {
+ name: this.name,
+ scheduledTime: this.scheduledTime,
+ periodInMinutes: this.periodInMinutes,
+ };
+ }
+}
+
+this.alarms = class extends ExtensionAPIPersistent {
+ constructor(extension) {
+ super(extension);
+
+ this.alarms = new Map();
+ this.callbacks = new Set();
+ }
+
+ onShutdown() {
+ for (let alarm of this.alarms.values()) {
+ alarm.clear();
+ }
+ }
+
+ PERSISTENT_EVENTS = {
+ onAlarm({ fire }) {
+ let callback = alarm => {
+ fire.sync(alarm.data);
+ };
+
+ this.callbacks.add(callback);
+
+ return {
+ unregister: () => {
+ this.callbacks.delete(callback);
+ },
+ convert(_fire, context) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ const self = this;
+
+ return {
+ alarms: {
+ create: function (name, alarmInfo) {
+ name = name || "";
+ if (self.alarms.has(name)) {
+ self.alarms.get(name).clear();
+ }
+ let alarm = new Alarm(self, name, alarmInfo);
+ self.alarms.set(alarm.name, alarm);
+ },
+
+ get: function (name) {
+ name = name || "";
+ if (self.alarms.has(name)) {
+ return Promise.resolve(self.alarms.get(name).data);
+ }
+ return Promise.resolve();
+ },
+
+ getAll: function () {
+ let result = Array.from(self.alarms.values(), alarm => alarm.data);
+ return Promise.resolve(result);
+ },
+
+ clear: function (name) {
+ name = name || "";
+ if (self.alarms.has(name)) {
+ self.alarms.get(name).clear();
+ return Promise.resolve(true);
+ }
+ return Promise.resolve(false);
+ },
+
+ clearAll: function () {
+ let cleared = false;
+ for (let alarm of self.alarms.values()) {
+ alarm.clear();
+ cleared = true;
+ }
+ return Promise.resolve(cleared);
+ },
+
+ onAlarm: new EventManager({
+ context,
+ module: "alarms",
+ event: "onAlarm",
+ extensionApi: self,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-backgroundPage.js b/toolkit/components/extensions/parent/ext-backgroundPage.js
new file mode 100644
index 0000000000..155220c67a
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-backgroundPage.js
@@ -0,0 +1,1116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var {
+ HiddenExtensionPage,
+ promiseBackgroundViewLoaded,
+ watchExtensionWorkerContextLoaded,
+} = ExtensionParent;
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(this, "serviceWorkerManager", () => {
+ return Cc["@mozilla.org/serviceworkers/manager;1"].getService(
+ Ci.nsIServiceWorkerManager
+ );
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "backgroundIdleTimeout",
+ "extensions.background.idle.timeout",
+ 30000,
+ null,
+ // Minimum 100ms, max 5min
+ delay => Math.min(Math.max(delay, 100), 5 * 60 * 1000)
+);
+
+// Pref used in tests to assert background page state set to
+// stopped on an extension process crash.
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "disableRestartPersistentAfterCrash",
+ "extensions.background.disableRestartPersistentAfterCrash",
+ false
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["DOMException"]);
+
+function notifyBackgroundScriptStatus(addonId, isRunning) {
+ // Notify devtools when the background scripts is started or stopped
+ // (used to show the current status in about:debugging).
+ const subject = { addonId, isRunning };
+ Services.obs.notifyObservers(subject, "extension:background-script-status");
+}
+
+// Same as nsITelemetry msSinceProcessStartExcludingSuspend but returns
+// undefined instead of throwing an extension.
+function msSinceProcessStartExcludingSuspend() {
+ let now;
+ try {
+ now = Services.telemetry.msSinceProcessStartExcludingSuspend();
+ } catch (err) {
+ Cu.reportError(err);
+ }
+ return now;
+}
+
+/**
+ * Background Page state transitions:
+ *
+ * ------> STOPPED <-------
+ * | | |
+ * | v |
+ * | STARTING >------|
+ * | | |
+ * | v ^
+ * |----< RUNNING ----> SUSPENDING
+ * ^ v
+ * |------------|
+ *
+ * STARTING: The background is being built.
+ * RUNNING: The background is running.
+ * SUSPENDING: The background is suspending, runtime.onSuspend will be called.
+ * STOPPED: The background is not running.
+ *
+ * For persistent backgrounds, SUSPENDING is not used.
+ *
+ * See BackgroundContextOwner for the exact relation.
+ */
+const BACKGROUND_STATE = {
+ STARTING: "starting",
+ RUNNING: "running",
+ SUSPENDING: "suspending",
+ STOPPED: "stopped",
+};
+
+// Responsible for the background_page section of the manifest.
+class BackgroundPage extends HiddenExtensionPage {
+ constructor(extension, options) {
+ super(extension, "background");
+
+ this.page = options.page || null;
+ this.isGenerated = !!options.scripts;
+
+ // Last background/event page created time (retrieved using
+ // Services.telemetry.msSinceProcessStartExcludingSuspend when the
+ // parent process proxy context has been created).
+ this.msSinceCreated = null;
+
+ if (this.page) {
+ this.url = this.extension.baseURI.resolve(this.page);
+ } else if (this.isGenerated) {
+ this.url = this.extension.baseURI.resolve(
+ "_generated_background_page.html"
+ );
+ }
+ }
+
+ async build() {
+ const { extension } = this;
+ ExtensionTelemetry.backgroundPageLoad.stopwatchStart(extension, this);
+
+ let context;
+ try {
+ await this.createBrowserElement();
+ if (!this.browser) {
+ throw new Error(
+ "Extension shut down before the background page was created"
+ );
+ }
+ extension._backgroundPageFrameLoader = this.browser.frameLoader;
+
+ extensions.emit("extension-browser-inserted", this.browser);
+
+ let contextPromise = promiseBackgroundViewLoaded(this.browser);
+ this.browser.fixupAndLoadURIString(this.url, {
+ triggeringPrincipal: extension.principal,
+ });
+
+ context = await contextPromise;
+ // NOTE: context can be null if the load failed.
+
+ this.msSinceCreated = msSinceProcessStartExcludingSuspend();
+
+ ExtensionTelemetry.backgroundPageLoad.stopwatchFinish(extension, this);
+ } catch (e) {
+ // Extension was down before the background page has loaded.
+ ExtensionTelemetry.backgroundPageLoad.stopwatchCancel(extension, this);
+ throw e;
+ }
+
+ return context;
+ }
+
+ shutdown() {
+ this.extension._backgroundPageFrameLoader = null;
+ super.shutdown();
+ }
+}
+
+// Responsible for the background.service_worker section of the manifest.
+class BackgroundWorker {
+ constructor(extension, options) {
+ this.extension = extension;
+ this.workerScript = options.service_worker;
+
+ if (!this.workerScript) {
+ throw new Error("Missing mandatory background.service_worker property");
+ }
+ }
+
+ get registrationInfo() {
+ const { principal } = this.extension;
+ return serviceWorkerManager.getRegistrationForAddonPrincipal(principal);
+ }
+
+ getWorkerInfo(descriptorId) {
+ return this.registrationInfo?.getWorkerByID(descriptorId);
+ }
+
+ validateWorkerInfoForContext(context) {
+ const { extension } = this;
+ if (!this.getWorkerInfo(context.workerDescriptorId)) {
+ throw new Error(
+ `ServiceWorkerInfo not found for ${extension.policy.debugName} contextId ${context.contextId}`
+ );
+ }
+ }
+
+ async build() {
+ const { extension } = this;
+ let context;
+ const contextPromise = new Promise(resolve => {
+ // TODO bug 1844486: resolve and/or unwatch when startup is interrupted.
+ let unwatch = watchExtensionWorkerContextLoaded(
+ { extension, viewType: "background_worker" },
+ context => {
+ unwatch();
+ this.validateWorkerInfoForContext(context);
+ resolve(context);
+ }
+ );
+ });
+
+ // TODO(Bug 17228327): follow up to spawn the active worker for a previously installed
+ // background service worker.
+ await serviceWorkerManager.registerForAddonPrincipal(
+ this.extension.principal
+ );
+
+ // TODO bug 1844486: Confirm that a shutdown() call during the above or
+ // below `await` calls can interrupt build() without leaving a stray worker
+ // registration behind.
+
+ context = await contextPromise;
+
+ await this.waitForActiveWorker();
+ return context;
+ }
+
+ shutdown(isAppShutdown) {
+ // All service worker registrations related to the extensions will be unregistered
+ // - when the extension is shutting down if the application is not also shutting down
+ // shutdown (in which case a previously registered service worker is expected to stay
+ // active across browser restarts).
+ // - when the extension has been uninstalled
+ if (!isAppShutdown) {
+ this.registrationInfo?.forceShutdown();
+ }
+ }
+
+ waitForActiveWorker() {
+ const { extension, registrationInfo } = this;
+ return new Promise((resolve, reject) => {
+ const resolveOnActive = () => {
+ if (
+ registrationInfo.activeWorker?.state ===
+ Ci.nsIServiceWorkerInfo.STATE_ACTIVATED
+ ) {
+ resolve();
+ return true;
+ }
+ return false;
+ };
+
+ const rejectOnUnregistered = () => {
+ if (registrationInfo.unregistered) {
+ reject(
+ new Error(
+ `Background service worker unregistered for "${extension.policy.debugName}"`
+ )
+ );
+ return true;
+ }
+ return false;
+ };
+
+ if (resolveOnActive() || rejectOnUnregistered()) {
+ return;
+ }
+
+ const listener = {
+ onChange() {
+ if (resolveOnActive() || rejectOnUnregistered()) {
+ registrationInfo.removeListener(listener);
+ }
+ },
+ };
+ registrationInfo.addListener(listener);
+ });
+ }
+}
+
+/**
+ * The BackgroundContextOwner is instantiated at most once per extension and
+ * tracks the state of the background context. State changes can be triggered
+ * by explicit calls to methods with the "setBgState" prefix, but also by the
+ * background context itself, e.g. via an extension process crash.
+ *
+ * This class identifies the following stages of interest:
+ *
+ * 1. Initially no active background, waiting for a signal to get started.
+ * - method: none (at constructor and after setBgStateStopped)
+ * - state: STOPPED
+ * - context: null
+ * 2. Parent-triggered background startup
+ * - method: setBgStateStarting
+ * - state: STARTING (was STOPPED)
+ * - context: null
+ * 3. Background context creation observed in parent
+ * - method: none (observed by ExtensionParent's recvCreateProxyContext)
+ * TODO: add method to observe and keep track of it sooner than stage 4.
+ * - state: STARTING
+ * - context: ProxyContextParent subclass (was null)
+ * 4. Parent-observed background startup completion
+ * - method: setBgStateRunning
+ * - state: RUNNING (was STARTING)
+ * - context: ProxyContextParent (was null)
+ * 5. Background context unloaded for any reason
+ * - method: setBgStateStopped
+ * TODO bug 1844217: This is only implemented for process crashes and
+ * intentionally triggered terminations, not navigations/reloads.
+ * When unloads happen due to navigations/reloads, context will be
+ * null but the state will still be RUNNING.
+ * - state: STOPPED (was STOPPED, STARTING, RUNNING or SUSPENDING)
+ * - context: null (was ProxyContextParent if stage 4 ran).
+ * - Continue at stage 1 if the extension has not shut down yet.
+ */
+class BackgroundContextOwner {
+ /**
+ * @property {BackgroundBuilder} backgroundBuilder
+ *
+ * The source of parent-triggered background state changes.
+ */
+ backgroundBuilder;
+
+ /**
+ * @property {Extension} [extension]
+ *
+ * The Extension associated with the background. This is always set and
+ * cleared at extension shutdown.
+ */
+ extension;
+
+ /**
+ * @property {BackgroundPage|BackgroundWorker} [bgInstance]
+ *
+ * The BackgroundClass instance responsible for creating the background
+ * context. This is set as soon as there is a desire to start a background,
+ * and cleared as soon as the background context is not wanted any more.
+ *
+ * This field is set iff extension.backgroundState is not STOPPED.
+ */
+ bgInstance = null;
+
+ /**
+ * @property {ExtensionPageContextParent|BackgroundWorkerContextParent} [context]
+ *
+ * The parent-side counterpart to a background context in a child. The value
+ * is a subclass of ProxyContextParent, which manages its own lifetime. The
+ * class is ultimately instantiated through bgInstance. It can be destroyed by
+ * bgInstance or externally (e.g. by the context itself or a process crash).
+ * The reference to the context is cleared as soon as the context is unloaded.
+ *
+ * This is currently set when the background has fully loaded. To access the
+ * background context before that, use |extension.backgroundContext|.
+ *
+ * This field is set when extension.backgroundState is RUNNING or SUSPENDING.
+ */
+ context = null;
+
+ /**
+ * @property {boolean} [canBePrimed]
+ *
+ * This property reflects whether persistent listeners can be primed. This
+ * means that `backgroundState` is `STOPPED` and the listeners haven't been
+ * primed yet. It is initially `true`, and set to `false` as soon as
+ * listeners are primed. It can become `true` again if `primeBackground` was
+ * skipped due to `shouldPrimeBackground` being `false`.
+ * NOTE: this flag is set for both event pages and persistent background pages.
+ */
+ canBePrimed = true;
+
+ /**
+ * @property {boolean} [shouldPrimeBackground]
+ *
+ * This property controls whether we should prime listeners. Under normal
+ * conditions, this should always be `true` but when too many crashes have
+ * occurred, we might have to disable process spawning, which would lead to
+ * this property being set to `false`.
+ */
+ shouldPrimeBackground = true;
+
+ get #hasEnteredShutdown() {
+ // This getter is just a small helper to make sure we always check for
+ // the extension shutdown being already initiated.
+ // Ordinarily the extension object is expected to be nullified from the
+ // onShutdown method, but extension.hasShutdown is set earlier and because
+ // the shutdown goes through some async steps there is a chance for other
+ // internals to be hit while the hasShutdown flag is set bug onShutdown
+ // not hit yet.
+ return this.extension.hasShutdown || Services.startup.shuttingDown;
+ }
+
+ /**
+ * @param {BackgroundBuilder} backgroundBuilder
+ * @param {Extension} extension
+ */
+ constructor(backgroundBuilder, extension) {
+ this.backgroundBuilder = backgroundBuilder;
+ this.extension = extension;
+ this.onExtensionProcessCrashed = this.onExtensionProcessCrashed.bind(this);
+ this.onApplicationInForeground = this.onApplicationInForeground.bind(this);
+ this.onExtensionEnableProcessSpawning =
+ this.onExtensionEnableProcessSpawning.bind(this);
+
+ extension.backgroundState = BACKGROUND_STATE.STOPPED;
+
+ extensions.on("extension-process-crash", this.onExtensionProcessCrashed);
+ extensions.on(
+ "extension-enable-process-spawning",
+ this.onExtensionEnableProcessSpawning
+ );
+ // We only defer handling extension process crashes for persistent
+ // background context.
+ if (extension.persistentBackground) {
+ extensions.on("application-foreground", this.onApplicationInForeground);
+ }
+ }
+
+ /**
+ * setBgStateStarting - right before the background context is initialized.
+ *
+ * @param {BackgroundWorker|BackgroundPage} bgInstance
+ */
+ setBgStateStarting(bgInstance) {
+ if (!this.extension) {
+ throw new Error(`Cannot start background after extension shutdown.`);
+ }
+ if (this.bgInstance) {
+ throw new Error(`Cannot start multiple background instances`);
+ }
+ this.extension.backgroundState = BACKGROUND_STATE.STARTING;
+ this.bgInstance = bgInstance;
+ // Often already false, except if we're waking due to a listener that was
+ // registered with isInStartup=true.
+ this.canBePrimed = false;
+ }
+
+ /**
+ * setBgStateRunning - when the background context has fully loaded.
+ *
+ * This method may throw if the background should no longer be active; if that
+ * is the case, the caller should make sure that the background is cleaned up
+ * by calling setBgStateStopped.
+ *
+ * @param {ExtensionPageContextParent|BackgroundWorkerContextParent} context
+ */
+ setBgStateRunning(context) {
+ if (!this.extension) {
+ // Caller should have checked this.
+ throw new Error(`Extension has shut down before startup completion.`);
+ }
+ if (this.context) {
+ // This can currently not happen - we set the context only once.
+ // TODO bug 1844217: Handle navigation (bug 1286083). For now, reject.
+ throw new Error(`Context already set before at startup completion.`);
+ }
+ if (!context) {
+ throw new Error(`Context not found at startup completion.`);
+ }
+ if (context.unloaded) {
+ throw new Error(`Context has unloaded before startup completion.`);
+ }
+ this.extension.backgroundState = BACKGROUND_STATE.RUNNING;
+ this.context = context;
+ context.callOnClose(this);
+
+ // When the background startup completes successfully, update the set of
+ // events that should be persisted.
+ EventManager.clearPrimedListeners(this.extension, true);
+
+ // This notification will be balanced in setBgStateStopped / close.
+ notifyBackgroundScriptStatus(this.extension.id, true);
+
+ this.extension.emit("background-script-started");
+ }
+
+ /**
+ * setBgStateStopped - when the background context has unloaded or should be
+ * unloaded. Regardless of the actual state at the entry of this method, upon
+ * returning the background is considered stopped.
+ *
+ * If the context was active at the time of the invocation, the actual unload
+ * of |this.context| is asynchronous as it may involve a round-trip to the
+ * child process.
+ *
+ * @param {boolean} [isAppShutdown]
+ */
+ setBgStateStopped(isAppShutdown) {
+ const backgroundState = this.extension.backgroundState;
+ if (this.context) {
+ this.context.forgetOnClose(this);
+ this.context = null;
+ // This is the counterpart to the notification in setBgStateRunning.
+ notifyBackgroundScriptStatus(this.extension.id, false);
+ }
+
+ // We only need to call clearPrimedListeners for states STOPPED and STARTING
+ // because setBgStateRunning clears all primed listeners when it switches
+ // from STARTING to RUNNING. Further, the only way to get primed listeners
+ // is by a primeListeners call, which only happens in the STOPPED state.
+ if (
+ backgroundState === BACKGROUND_STATE.STOPPED ||
+ backgroundState === BACKGROUND_STATE.STARTING
+ ) {
+ EventManager.clearPrimedListeners(this.extension, false);
+ }
+
+ // Ensure there is no backgroundTimer running
+ this.backgroundBuilder.clearIdleTimer();
+
+ const bgInstance = this.bgInstance;
+ if (bgInstance) {
+ this.bgInstance = null;
+ isAppShutdown ||= Services.startup.shuttingDown;
+ // bgInstance.shutdown() unloads the associated context, if any.
+ bgInstance.shutdown(isAppShutdown);
+ this.backgroundBuilder.onBgInstanceShutdown(bgInstance);
+ }
+
+ this.extension.backgroundState = BACKGROUND_STATE.STOPPED;
+ if (backgroundState === BACKGROUND_STATE.STARTING) {
+ this.extension.emit("background-script-aborted");
+ }
+
+ if (this.extension.hasShutdown) {
+ this.extension = null;
+ } else if (this.shouldPrimeBackground) {
+ // Prime again, so that a stopped background can always be revived when
+ // needed.
+ this.backgroundBuilder.primeBackground(false);
+ } else {
+ this.canBePrimed = true;
+ }
+ }
+
+ // Called by registration via context.callOnClose (if this.context is set).
+ close() {
+ // close() is called when:
+ // - background context unloads (without replacement context).
+ // - extension process crashes (without replacement context).
+ // - background context reloads (context likely replaced by new context).
+ // - background context navigates (context likely replaced by new context).
+ //
+ // When the background is gone without replacement, switch to STOPPED.
+ // TODO bug 1286083: Drop support for navigations.
+
+ // To fully maintain the state, we should call this.setBgStateStopped();
+ // But we cannot do that yet because that would close background pages upon
+ // reload and navigation, which would be a backwards-incompatible change.
+ // For now, we only do the bare minimum here.
+ //
+ // Note that once a navigation or reload starts, that the context is
+ // untracked. This is a pre-existing issue that we should fix later.
+ // TODO bug 1844217: Detect context replacement and update this.context.
+ if (this.context) {
+ this.context.forgetOnClose(this);
+ this.context = null;
+ // This is the counterpart to the notification in setBgStateRunning.
+ notifyBackgroundScriptStatus(this.extension.id, false);
+ }
+ }
+
+ restartPersistentBackgroundAfterCrash() {
+ const { extension } = this;
+ if (
+ this.#hasEnteredShutdown ||
+ // Ignore if the background state isn't the one expected to be set
+ // after a crash.
+ extension.backgroundState !== BACKGROUND_STATE.STOPPED ||
+ // Auto-restart persistent background scripts after crash disabled by prefs.
+ disableRestartPersistentAfterCrash
+ ) {
+ return;
+ }
+
+ // Persistent background pages are re-primed from setBgStateStopped when we
+ // are hitting a crash (if the threshold was not exceeded, otherwise they
+ // are going to be re-primed from onExtensionEnableProcessSpawning).
+ extension.emit("start-background-script");
+ }
+
+ onExtensionEnableProcessSpawning() {
+ if (this.#hasEnteredShutdown) {
+ return;
+ }
+
+ if (!this.canBePrimed) {
+ return;
+ }
+
+ // Allow priming again.
+ this.shouldPrimeBackground = true;
+ this.backgroundBuilder.primeBackground(false);
+
+ if (this.extension.persistentBackground) {
+ this.restartPersistentBackgroundAfterCrash();
+ }
+ }
+
+ onApplicationInForeground(eventName, data) {
+ if (
+ this.#hasEnteredShutdown ||
+ // Past the silent crash handling threashold.
+ data.processSpawningDisabled
+ ) {
+ return;
+ }
+
+ this.restartPersistentBackgroundAfterCrash();
+ }
+
+ onExtensionProcessCrashed(eventName, data) {
+ if (this.#hasEnteredShutdown) {
+ return;
+ }
+
+ // data.childID holds the process ID of the crashed extension process.
+ // For now, assume that there is only one, so clean up unconditionally.
+
+ this.shouldPrimeBackground = !data.processSpawningDisabled;
+
+ // We only need to clean up if a bgInstance has been created. Without it,
+ // there is only state in the parent process, not the child, and a crashed
+ // extension process doesn't affect us.
+ if (this.bgInstance) {
+ this.setBgStateStopped();
+ }
+
+ if (this.extension.persistentBackground) {
+ // Defer to when back in foreground and/or process spawning is explicitly re-enabled.
+ if (!data.appInForeground || data.processSpawningDisabled) {
+ return;
+ }
+
+ this.restartPersistentBackgroundAfterCrash();
+ }
+ }
+
+ // Called by ExtensionAPI.onShutdown (once).
+ onShutdown(isAppShutdown) {
+ // If a background context was active during extension shutdown, then
+ // close() was called before onShutdown, which clears |this.extension|.
+ // If the background has not fully started yet, then we have to clear here.
+ if (this.extension) {
+ this.setBgStateStopped(isAppShutdown);
+ }
+ extensions.off("extension-process-crash", this.onExtensionProcessCrashed);
+ extensions.off(
+ "extension-enable-process-spawning",
+ this.onExtensionEnableProcessSpawning
+ );
+ extensions.off("application-foreground", this.onApplicationInForeground);
+ }
+}
+
+/**
+ * BackgroundBuilder manages the creation and parent-triggered termination of
+ * the background context. Non-parent-triggered terminations are usually due to
+ * an external cause (e.g. crashes) and detected by BackgroundContextOwner.
+ *
+ * Because these external terminations can happen at any time, and the creation
+ * and suspension of the background context is async, the methods of this
+ * BackgroundBuilder class necessarily need to check the state of the background
+ * before proceeding with the operation (and abort + clean up as needed).
+ *
+ * The following interruptions are explicitly accounted for:
+ * - Extension shuts down.
+ * - Background unloads for any reason.
+ * - Another background instance starts in the meantime.
+ */
+class BackgroundBuilder {
+ constructor(extension) {
+ this.extension = extension;
+ this.backgroundContextOwner = new BackgroundContextOwner(this, extension);
+ }
+
+ async build() {
+ if (this.backgroundContextOwner.bgInstance) {
+ return;
+ }
+
+ let { extension } = this;
+ let { manifest } = extension;
+ extension.backgroundState = BACKGROUND_STATE.STARTING;
+
+ this.isWorker =
+ !!manifest.background.service_worker &&
+ WebExtensionPolicy.backgroundServiceWorkerEnabled;
+
+ let BackgroundClass = this.isWorker ? BackgroundWorker : BackgroundPage;
+
+ const bgInstance = new BackgroundClass(extension, manifest.background);
+ this.backgroundContextOwner.setBgStateStarting(bgInstance);
+ let context;
+ try {
+ context = await bgInstance.build();
+ } catch (e) {
+ Cu.reportError(e);
+ // If background startup gets interrupted (e.g. extension shutdown),
+ // bgInstance.shutdown() is called and backgroundContextOwner.bgInstance
+ // is cleared.
+ if (this.backgroundContextOwner.bgInstance === bgInstance) {
+ this.backgroundContextOwner.setBgStateStopped();
+ }
+ return;
+ }
+
+ if (context) {
+ // Wait until all event listeners registered by the script so far
+ // to be handled. We then set listenerPromises to null, which indicates
+ // to addListener that the background script has finished loading.
+ await Promise.all(context.listenerPromises);
+ context.listenerPromises = null;
+ }
+
+ if (this.backgroundContextOwner.bgInstance !== bgInstance) {
+ // Background closed/restarted in the meantime.
+ return;
+ }
+
+ try {
+ this.backgroundContextOwner.setBgStateRunning(context);
+ } catch (e) {
+ Cu.reportError(e);
+ this.backgroundContextOwner.setBgStateStopped();
+ }
+ }
+
+ observe(subject, topic, data) {
+ if (topic == "timer-callback") {
+ let { extension } = this;
+ this.clearIdleTimer();
+ extension?.terminateBackground();
+ }
+ }
+
+ clearIdleTimer() {
+ this.backgroundTimer?.cancel();
+ this.backgroundTimer = null;
+ }
+
+ resetIdleTimer() {
+ this.clearIdleTimer();
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(this, backgroundIdleTimeout, Ci.nsITimer.TYPE_ONE_SHOT);
+ this.backgroundTimer = timer;
+ }
+
+ primeBackground(isInStartup = true) {
+ let { extension } = this;
+
+ if (this.backgroundContextOwner.bgInstance) {
+ // This should never happen. The need to prime listeners is mutually
+ // exclusive with the existence of a background instance.
+ throw new Error(`bgInstance exists before priming ${extension.id}`);
+ }
+
+ // Used by runtime messaging to wait for background page listeners.
+ let bgStartupPromise = new Promise(resolve => {
+ let done = () => {
+ extension.off("background-script-started", done);
+ extension.off("background-script-aborted", done);
+ extension.off("shutdown", done);
+ resolve();
+ };
+ extension.on("background-script-started", done);
+ extension.on("background-script-aborted", done);
+ extension.on("shutdown", done);
+ });
+
+ extension.promiseBackgroundStarted = () => {
+ return bgStartupPromise;
+ };
+
+ extension.wakeupBackground = () => {
+ if (extension.hasShutdown) {
+ return Promise.reject(
+ new Error(
+ "wakeupBackground called while the extension was already shutting down"
+ )
+ );
+ }
+ extension.emit("background-script-event");
+ // `extension.wakeupBackground` is set back to the original arrow function
+ // when the background page is terminated and `primeBackground` is called again.
+ extension.wakeupBackground = () => bgStartupPromise;
+ return bgStartupPromise;
+ };
+
+ let resetBackgroundIdle = (eventName, resetIdleDetails) => {
+ this.clearIdleTimer();
+ if (!this.extension || extension.persistentBackground) {
+ // Extension was already shut down or is persistent and
+ // does not idle timout.
+ return;
+ }
+ // TODO remove at an appropriate point in the future prior
+ // to general availability. There may be some racy conditions
+ // with idle timeout between an event starting and the event firing
+ // but we still want testing with an idle timeout.
+ if (
+ !Services.prefs.getBoolPref("extensions.background.idle.enabled", true)
+ ) {
+ return;
+ }
+
+ if (
+ extension.backgroundState == BACKGROUND_STATE.SUSPENDING &&
+ // After we begin suspending the background, parent API calls from
+ // runtime.onSuspend listeners shouldn't cancel the suspension.
+ resetIdleDetails?.reason !== "parentApiCall"
+ ) {
+ extension.backgroundState = BACKGROUND_STATE.RUNNING;
+ // call runtime.onSuspendCanceled
+ extension.emit("background-script-suspend-canceled");
+ }
+
+ this.resetIdleTimer();
+
+ if (
+ eventName === "background-script-reset-idle" &&
+ // TODO(Bug 1790087): record similar telemetry for background service worker.
+ !this.isWorker
+ ) {
+ // Record the reason for resetting the event page idle timeout
+ // in a idle result histogram, with the category set based
+ // on the reason for resetting (defaults to 'reset_other'
+ // if resetIdleDetails.reason is missing or not mapped into the
+ // telemetry histogram categories).
+ //
+ // Keep this in sync with the categories listed in Histograms.json
+ // for "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT".
+ let category = "reset_other";
+ switch (resetIdleDetails?.reason) {
+ case "event":
+ category = "reset_event";
+ return; // not break; because too frequent, see bug 1868960.
+ case "hasActiveNativeAppPorts":
+ category = "reset_nativeapp";
+ break;
+ case "hasActiveStreamFilter":
+ category = "reset_streamfilter";
+ break;
+ case "pendingListeners":
+ category = "reset_listeners";
+ break;
+ case "parentApiCall":
+ category = "reset_parentapicall";
+ return; // not break; because too frequent, see bug 1868960.
+ }
+
+ ExtensionTelemetry.eventPageIdleResult.histogramAdd({
+ extension,
+ category,
+ });
+ }
+ };
+
+ // Listen for events from the EventManager
+ extension.on("background-script-reset-idle", resetBackgroundIdle);
+ // After the background is started, initiate the first timer
+ extension.once("background-script-started", resetBackgroundIdle);
+
+ // TODO bug 1844488: terminateBackground should account for externally
+ // triggered background restarts. It does currently performs various
+ // backgroundState checks, but it is possible for the background to have
+ // been crashes or restarted in the meantime.
+ extension.terminateBackground = async ({
+ ignoreDevToolsAttached = false,
+ disableResetIdleForTest = false, // Disable all reset idle checks for testing purpose.
+ } = {}) => {
+ await bgStartupPromise;
+ if (!this.extension || this.extension.hasShutdown) {
+ // Extension was already shut down.
+ return;
+ }
+ if (extension.backgroundState != BACKGROUND_STATE.RUNNING) {
+ return;
+ }
+
+ if (
+ !ignoreDevToolsAttached &&
+ ExtensionParent.DebugUtils.hasDevToolsAttached(extension.id)
+ ) {
+ extension.emit("background-script-suspend-ignored");
+ return;
+ }
+
+ // Similar to what happens in recent Chrome version for MV3 extensions, extensions non-persistent
+ // background scripts with a nativeMessaging port still open or a sendNativeMessage request still
+ // pending an answer are exempt from being terminated when the idle timeout expires.
+ // The motivation, as for the similar change that Chrome applies to MV3 extensions, is that using
+ // the native messaging API have already an higher barrier due to having to specify a native messaging
+ // host app in their manifest and the user also have to install the native app separately as a native
+ // application).
+ if (
+ !disableResetIdleForTest &&
+ extension.backgroundContext?.hasActiveNativeAppPorts
+ ) {
+ extension.emit("background-script-reset-idle", {
+ reason: "hasActiveNativeAppPorts",
+ });
+ return;
+ }
+
+ if (
+ !disableResetIdleForTest &&
+ extension.backgroundContext?.pendingRunListenerPromisesCount
+ ) {
+ extension.emit("background-script-reset-idle", {
+ reason: "pendingListeners",
+ pendingListeners:
+ extension.backgroundContext.pendingRunListenerPromisesCount,
+ });
+ // Clear the pending promises being tracked when we have reset the idle
+ // once because some where still pending, so that the pending listeners
+ // calls can reset the idle timer only once.
+ extension.backgroundContext.clearPendingRunListenerPromises();
+ return;
+ }
+
+ const childId = extension.backgroundContext?.childId;
+ if (
+ childId !== undefined &&
+ extension.hasPermission("webRequestBlocking") &&
+ (extension.manifestVersion <= 3 ||
+ extension.hasPermission("webRequestFilterResponse"))
+ ) {
+ // Ask to the background page context in the child process to check if there are
+ // StreamFilter instances active (e.g. ones with status "transferringdata" or "suspended",
+ // see StreamFilterStatus enum defined in StreamFilter.webidl).
+ // TODO(Bug 1748533): consider additional changes to prevent a StreamFilter that never gets to an
+ // inactive state from preventing an even page from being ever suspended.
+ const hasActiveStreamFilter =
+ await ExtensionParent.ParentAPIManager.queryStreamFilterSuspendCancel(
+ extension.backgroundContext.childId
+ ).catch(err => {
+ // an AbortError raised from the JSWindowActor is expected if the background page was already been
+ // terminated in the meantime, and so we only log the errors that don't match these particular conditions.
+ if (
+ extension.backgroundState == BACKGROUND_STATE.STOPPED &&
+ DOMException.isInstance(err) &&
+ err.name === "AbortError"
+ ) {
+ return false;
+ }
+ Cu.reportError(err);
+ return false;
+ });
+ if (!disableResetIdleForTest && hasActiveStreamFilter) {
+ extension.emit("background-script-reset-idle", {
+ reason: "hasActiveStreamFilter",
+ });
+ return;
+ }
+
+ // Return earlier if extension have started or completed its shutdown in the meantime.
+ if (
+ extension.backgroundState !== BACKGROUND_STATE.RUNNING ||
+ extension.hasShutdown
+ ) {
+ return;
+ }
+ }
+
+ extension.backgroundState = BACKGROUND_STATE.SUSPENDING;
+ this.clearIdleTimer();
+ // call runtime.onSuspend
+ await extension.emit("background-script-suspend");
+ // If in the meantime another event fired, state will be RUNNING,
+ // and if it was shutdown it will be STOPPED.
+ if (extension.backgroundState != BACKGROUND_STATE.SUSPENDING) {
+ return;
+ }
+ extension.off("background-script-reset-idle", resetBackgroundIdle);
+
+ // TODO(Bug 1790087): record similar telemetry for background service worker.
+ if (!this.isWorker) {
+ ExtensionTelemetry.eventPageIdleResult.histogramAdd({
+ extension,
+ category: "suspend",
+ });
+ }
+
+ this.backgroundContextOwner.setBgStateStopped(false);
+ };
+
+ EventManager.primeListeners(extension, isInStartup);
+ // Avoid setting the flag to false when called during extension startup.
+ if (!isInStartup) {
+ this.backgroundContextOwner.canBePrimed = false;
+ }
+
+ // TODO: start-background-script and background-script-event should be
+ // unregistered when build() starts or when the extension shuts down.
+ extension.once("start-background-script", async () => {
+ if (!this.extension || this.extension.hasShutdown) {
+ // Extension was shut down. Don't build the background page.
+ // Primed listeners have been cleared in onShutdown.
+ return;
+ }
+ await this.build();
+ });
+
+ // There are two ways to start the background page:
+ // 1. If a primed event fires, then start the background page as
+ // soon as we have painted a browser window.
+ // 2. After all windows have been restored on startup (see onManifestEntry).
+ extension.once("background-script-event", async () => {
+ await ExtensionParent.browserPaintedPromise;
+ extension.emit("start-background-script");
+ });
+ }
+
+ onBgInstanceShutdown(bgInstance) {
+ const { msSinceCreated } = bgInstance;
+ const { extension } = this;
+
+ // Emit an event for tests.
+ extension.emit("shutdown-background-script");
+
+ if (msSinceCreated) {
+ const now = msSinceProcessStartExcludingSuspend();
+ if (
+ now &&
+ // TODO(Bug 1790087): record similar telemetry for background service worker.
+ !(this.isWorker || extension.persistentBackground)
+ ) {
+ ExtensionTelemetry.eventPageRunningTime.histogramAdd({
+ extension,
+ value: now - msSinceCreated,
+ });
+ }
+ }
+ }
+}
+
+this.backgroundPage = class extends ExtensionAPI {
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+
+ // When in PPB background pages all run in a private context. This check
+ // simply avoids an extraneous error in the console since the BaseContext
+ // will throw.
+ if (
+ PrivateBrowsingUtils.permanentPrivateBrowsing &&
+ !extension.privateBrowsingAllowed
+ ) {
+ return;
+ }
+
+ this.backgroundBuilder = new BackgroundBuilder(extension);
+
+ // runtime.onStartup event support. We listen for the first
+ // background startup then emit a first-run event.
+ extension.once("background-script-started", () => {
+ extension.emit("background-first-run");
+ });
+
+ this.backgroundBuilder.primeBackground();
+
+ // Persistent backgrounds are started immediately except during APP_STARTUP.
+ // Non-persistent backgrounds must be started immediately for new install or enable
+ // to initialize the addon and create the persisted listeners.
+ // updateReason is set when an extension is updated during APP_STARTUP.
+ if (
+ extension.testNoDelayedStartup ||
+ extension.startupReason !== "APP_STARTUP" ||
+ extension.updateReason
+ ) {
+ // TODO bug 1543354: Avoid AsyncShutdown timeouts by removing the await
+ // here, at least for non-test situations.
+ await this.backgroundBuilder.build();
+
+ // The task in ExtensionParent.browserPaintedPromise below would be fully
+ // skipped because of the above build() that sets bgInstance. Return early
+ // so that it is obvious that the logic is skipped.
+ return;
+ }
+
+ ExtensionParent.browserStartupPromise.then(() => {
+ // Return early if the background has started in the meantime. This can
+ // happen if a primed listener (isInStartup) has been triggered.
+ if (
+ !this.backgroundBuilder ||
+ this.backgroundBuilder.backgroundContextOwner.bgInstance ||
+ !this.backgroundBuilder.backgroundContextOwner.canBePrimed
+ ) {
+ return;
+ }
+
+ // We either start the background page immediately, or fully prime for
+ // real.
+ this.backgroundBuilder.backgroundContextOwner.canBePrimed = false;
+
+ // If there are no listeners for the extension that were persisted, we need to
+ // start the event page so they can be registered.
+ if (
+ extension.persistentBackground ||
+ !extension.persistentListeners?.size ||
+ // If runtime.onStartup has a listener and this is app_startup,
+ // start the extension so it will fire the event.
+ (extension.startupReason == "APP_STARTUP" &&
+ extension.persistentListeners?.get("runtime").has("onStartup"))
+ ) {
+ extension.emit("start-background-script");
+ } else {
+ // During startup we only prime startup blocking listeners. At
+ // this stage we need to prime all listeners for event pages.
+ EventManager.clearPrimedListeners(extension, false);
+ // Allow re-priming by deleting existing listeners.
+ extension.persistentListeners = null;
+ EventManager.primeListeners(extension, false);
+ }
+ });
+ }
+
+ onShutdown(isAppShutdown) {
+ if (this.backgroundBuilder) {
+ this.backgroundBuilder.backgroundContextOwner.onShutdown(isAppShutdown);
+ this.backgroundBuilder = null;
+ }
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-browserSettings.js b/toolkit/components/extensions/parent/ext-browserSettings.js
new file mode 100644
index 0000000000..7b292f76b8
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-browserSettings.js
@@ -0,0 +1,592 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs",
+});
+
+var { ExtensionPreferencesManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs"
+);
+
+var { ExtensionError } = ExtensionUtils;
+var { getSettingsAPI, getPrimedSettingsListener } = ExtensionPreferencesManager;
+
+const HOMEPAGE_URL_PREF = "browser.startup.homepage";
+
+const PERM_DENY_ACTION = Services.perms.DENY_ACTION;
+
+// Add settings objects for supported APIs to the preferences manager.
+ExtensionPreferencesManager.addSetting("allowPopupsForUserEvents", {
+ permission: "browserSettings",
+ prefNames: ["dom.popup_allowed_events"],
+
+ setCallback(value) {
+ let returnObj = {};
+ // If the value is true, then reset the pref, otherwise set it to "".
+ returnObj[this.prefNames[0]] = value ? undefined : "";
+ return returnObj;
+ },
+
+ getCallback() {
+ return Services.prefs.getCharPref("dom.popup_allowed_events") != "";
+ },
+});
+
+ExtensionPreferencesManager.addSetting("cacheEnabled", {
+ permission: "browserSettings",
+ prefNames: ["browser.cache.disk.enable", "browser.cache.memory.enable"],
+
+ setCallback(value) {
+ let returnObj = {};
+ for (let pref of this.prefNames) {
+ returnObj[pref] = value;
+ }
+ return returnObj;
+ },
+
+ getCallback() {
+ return (
+ Services.prefs.getBoolPref("browser.cache.disk.enable") &&
+ Services.prefs.getBoolPref("browser.cache.memory.enable")
+ );
+ },
+});
+
+ExtensionPreferencesManager.addSetting("closeTabsByDoubleClick", {
+ permission: "browserSettings",
+ prefNames: ["browser.tabs.closeTabByDblclick"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return Services.prefs.getBoolPref("browser.tabs.closeTabByDblclick");
+ },
+
+ validate() {
+ if (AppConstants.platform == "android") {
+ throw new ExtensionError(
+ `android is not a supported platform for the closeTabsByDoubleClick setting.`
+ );
+ }
+ },
+});
+
+ExtensionPreferencesManager.addSetting("colorManagement.mode", {
+ permission: "browserSettings",
+ prefNames: ["gfx.color_management.mode"],
+
+ setCallback(value) {
+ switch (value) {
+ case "off":
+ return { [this.prefNames[0]]: 0 };
+ case "full":
+ return { [this.prefNames[0]]: 1 };
+ case "tagged_only":
+ return { [this.prefNames[0]]: 2 };
+ }
+ },
+
+ getCallback() {
+ switch (Services.prefs.getIntPref("gfx.color_management.mode")) {
+ case 0:
+ return "off";
+ case 1:
+ return "full";
+ case 2:
+ return "tagged_only";
+ }
+ },
+});
+
+ExtensionPreferencesManager.addSetting("colorManagement.useNativeSRGB", {
+ permission: "browserSettings",
+ prefNames: ["gfx.color_management.native_srgb"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return Services.prefs.getBoolPref("gfx.color_management.native_srgb");
+ },
+});
+
+ExtensionPreferencesManager.addSetting(
+ "colorManagement.useWebRenderCompositor",
+ {
+ permission: "browserSettings",
+ prefNames: ["gfx.webrender.compositor"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return Services.prefs.getBoolPref("gfx.webrender.compositor");
+ },
+ }
+);
+
+ExtensionPreferencesManager.addSetting("contextMenuShowEvent", {
+ permission: "browserSettings",
+ prefNames: ["ui.context_menus.after_mouseup"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value === "mouseup" };
+ },
+
+ getCallback() {
+ if (AppConstants.platform === "win") {
+ return "mouseup";
+ }
+ let prefValue = Services.prefs.getBoolPref(
+ "ui.context_menus.after_mouseup",
+ null
+ );
+ return prefValue ? "mouseup" : "mousedown";
+ },
+});
+
+ExtensionPreferencesManager.addSetting("imageAnimationBehavior", {
+ permission: "browserSettings",
+ prefNames: ["image.animation_mode"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return Services.prefs.getCharPref("image.animation_mode");
+ },
+});
+
+ExtensionPreferencesManager.addSetting("newTabPosition", {
+ permission: "browserSettings",
+ prefNames: [
+ "browser.tabs.insertRelatedAfterCurrent",
+ "browser.tabs.insertAfterCurrent",
+ ],
+
+ setCallback(value) {
+ return {
+ "browser.tabs.insertAfterCurrent": value === "afterCurrent",
+ "browser.tabs.insertRelatedAfterCurrent": value === "relatedAfterCurrent",
+ };
+ },
+
+ getCallback() {
+ if (Services.prefs.getBoolPref("browser.tabs.insertAfterCurrent")) {
+ return "afterCurrent";
+ }
+ if (Services.prefs.getBoolPref("browser.tabs.insertRelatedAfterCurrent")) {
+ return "relatedAfterCurrent";
+ }
+ return "atEnd";
+ },
+});
+
+ExtensionPreferencesManager.addSetting("openBookmarksInNewTabs", {
+ permission: "browserSettings",
+ prefNames: ["browser.tabs.loadBookmarksInTabs"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return Services.prefs.getBoolPref("browser.tabs.loadBookmarksInTabs");
+ },
+});
+
+ExtensionPreferencesManager.addSetting("openSearchResultsInNewTabs", {
+ permission: "browserSettings",
+ prefNames: ["browser.search.openintab"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return Services.prefs.getBoolPref("browser.search.openintab");
+ },
+});
+
+ExtensionPreferencesManager.addSetting("openUrlbarResultsInNewTabs", {
+ permission: "browserSettings",
+ prefNames: ["browser.urlbar.openintab"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return Services.prefs.getBoolPref("browser.urlbar.openintab");
+ },
+});
+
+ExtensionPreferencesManager.addSetting("webNotificationsDisabled", {
+ permission: "browserSettings",
+ prefNames: ["permissions.default.desktop-notification"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value ? PERM_DENY_ACTION : undefined };
+ },
+
+ getCallback() {
+ let prefValue = Services.prefs.getIntPref(
+ "permissions.default.desktop-notification",
+ null
+ );
+ return prefValue === PERM_DENY_ACTION;
+ },
+});
+
+ExtensionPreferencesManager.addSetting("overrideDocumentColors", {
+ permission: "browserSettings",
+ prefNames: ["browser.display.document_color_use"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ let prefValue = Services.prefs.getIntPref(
+ "browser.display.document_color_use"
+ );
+ if (prefValue === 1) {
+ return "never";
+ } else if (prefValue === 2) {
+ return "always";
+ }
+ return "high-contrast-only";
+ },
+});
+
+ExtensionPreferencesManager.addSetting("overrideContentColorScheme", {
+ permission: "browserSettings",
+ prefNames: ["layout.css.prefers-color-scheme.content-override"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ let prefValue = Services.prefs.getIntPref(
+ "layout.css.prefers-color-scheme.content-override"
+ );
+ switch (prefValue) {
+ case 0:
+ return "dark";
+ case 1:
+ return "light";
+ default:
+ return "auto";
+ }
+ },
+});
+
+ExtensionPreferencesManager.addSetting("useDocumentFonts", {
+ permission: "browserSettings",
+ prefNames: ["browser.display.use_document_fonts"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return (
+ Services.prefs.getIntPref("browser.display.use_document_fonts") !== 0
+ );
+ },
+});
+
+ExtensionPreferencesManager.addSetting("zoomFullPage", {
+ permission: "browserSettings",
+ prefNames: ["browser.zoom.full"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return Services.prefs.getBoolPref("browser.zoom.full");
+ },
+});
+
+ExtensionPreferencesManager.addSetting("zoomSiteSpecific", {
+ permission: "browserSettings",
+ prefNames: ["browser.zoom.siteSpecific"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return Services.prefs.getBoolPref("browser.zoom.siteSpecific");
+ },
+});
+
+this.browserSettings = class extends ExtensionAPI {
+ homePageOverrideListener(fire) {
+ let listener = () => {
+ fire.async({
+ levelOfControl: "not_controllable",
+ value: Services.prefs.getStringPref(HOMEPAGE_URL_PREF),
+ });
+ };
+ Services.prefs.addObserver(HOMEPAGE_URL_PREF, listener);
+ return {
+ unregister: () => {
+ Services.prefs.removeObserver(HOMEPAGE_URL_PREF, listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ }
+
+ newTabOverrideListener(fire) {
+ let listener = () => {
+ fire.async({
+ levelOfControl: "not_controllable",
+ value: AboutNewTab.newTabURL,
+ });
+ };
+ Services.obs.addObserver(listener, "newtab-url-changed");
+ return {
+ unregister: () => {
+ Services.obs.removeObserver(listener, "newtab-url-changed");
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ }
+
+ primeListener(event, fire) {
+ let { extension } = this;
+ if (event == "homepageOverride") {
+ return this.homePageOverrideListener(fire);
+ }
+ if (event == "newTabPageOverride") {
+ return this.newTabOverrideListener(fire);
+ }
+ let listener = getPrimedSettingsListener({
+ extension,
+ name: event,
+ });
+ return listener(fire);
+ }
+
+ getAPI(context) {
+ let self = this;
+ let { extension } = context;
+
+ function makeSettingsAPI(name) {
+ return getSettingsAPI({
+ context,
+ module: "browserSettings",
+ name,
+ });
+ }
+
+ return {
+ browserSettings: {
+ allowPopupsForUserEvents: makeSettingsAPI("allowPopupsForUserEvents"),
+ cacheEnabled: makeSettingsAPI("cacheEnabled"),
+ closeTabsByDoubleClick: makeSettingsAPI("closeTabsByDoubleClick"),
+ contextMenuShowEvent: Object.assign(
+ makeSettingsAPI("contextMenuShowEvent"),
+ {
+ set: details => {
+ if (!["mouseup", "mousedown"].includes(details.value)) {
+ throw new ExtensionError(
+ `${details.value} is not a valid value for contextMenuShowEvent.`
+ );
+ }
+ if (
+ AppConstants.platform === "android" ||
+ (AppConstants.platform === "win" &&
+ details.value === "mousedown")
+ ) {
+ return false;
+ }
+ return ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "contextMenuShowEvent",
+ details.value
+ );
+ },
+ }
+ ),
+ ftpProtocolEnabled: getSettingsAPI({
+ context,
+ name: "ftpProtocolEnabled",
+ readOnly: true,
+ callback() {
+ return false;
+ },
+ }),
+ homepageOverride: getSettingsAPI({
+ context,
+ // Name differs here to preserve this setting properly
+ name: "homepage_override",
+ callback() {
+ return Services.prefs.getStringPref(HOMEPAGE_URL_PREF);
+ },
+ readOnly: true,
+ onChange: new ExtensionCommon.EventManager({
+ context,
+ module: "browserSettings",
+ event: "homepageOverride",
+ name: "homepageOverride.onChange",
+ register: fire => {
+ return self.homePageOverrideListener(fire).unregister;
+ },
+ }).api(),
+ }),
+ imageAnimationBehavior: makeSettingsAPI("imageAnimationBehavior"),
+ newTabPosition: makeSettingsAPI("newTabPosition"),
+ newTabPageOverride: getSettingsAPI({
+ context,
+ // Name differs here to preserve this setting properly
+ name: "newTabURL",
+ callback() {
+ return AboutNewTab.newTabURL;
+ },
+ storeType: "url_overrides",
+ readOnly: true,
+ onChange: new ExtensionCommon.EventManager({
+ context,
+ module: "browserSettings",
+ event: "newTabPageOverride",
+ name: "newTabPageOverride.onChange",
+ register: fire => {
+ return self.newTabOverrideListener(fire).unregister;
+ },
+ }).api(),
+ }),
+ openBookmarksInNewTabs: makeSettingsAPI("openBookmarksInNewTabs"),
+ openSearchResultsInNewTabs: makeSettingsAPI(
+ "openSearchResultsInNewTabs"
+ ),
+ openUrlbarResultsInNewTabs: makeSettingsAPI(
+ "openUrlbarResultsInNewTabs"
+ ),
+ webNotificationsDisabled: makeSettingsAPI("webNotificationsDisabled"),
+ overrideDocumentColors: Object.assign(
+ makeSettingsAPI("overrideDocumentColors"),
+ {
+ set: details => {
+ if (
+ !["never", "always", "high-contrast-only"].includes(
+ details.value
+ )
+ ) {
+ throw new ExtensionError(
+ `${details.value} is not a valid value for overrideDocumentColors.`
+ );
+ }
+ let prefValue = 0; // initialize to 0 - auto/high-contrast-only
+ if (details.value === "never") {
+ prefValue = 1;
+ } else if (details.value === "always") {
+ prefValue = 2;
+ }
+ return ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "overrideDocumentColors",
+ prefValue
+ );
+ },
+ }
+ ),
+ overrideContentColorScheme: Object.assign(
+ makeSettingsAPI("overrideContentColorScheme"),
+ {
+ set: details => {
+ let value = details.value;
+ if (value == "system" || value == "browser") {
+ // Map previous values that used to be different but were
+ // unified under the "auto" setting. In practice this should
+ // almost always behave like the extension author expects.
+ extension.logger.warn(
+ `The "${value}" value for overrideContentColorScheme has been deprecated. Use "auto" instead`
+ );
+ value = "auto";
+ }
+ let prefValue = ["dark", "light", "auto"].indexOf(value);
+ if (prefValue === -1) {
+ throw new ExtensionError(
+ `${value} is not a valid value for overrideContentColorScheme.`
+ );
+ }
+ return ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "overrideContentColorScheme",
+ prefValue
+ );
+ },
+ }
+ ),
+ useDocumentFonts: Object.assign(makeSettingsAPI("useDocumentFonts"), {
+ set: details => {
+ if (typeof details.value !== "boolean") {
+ throw new ExtensionError(
+ `${details.value} is not a valid value for useDocumentFonts.`
+ );
+ }
+ return ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "useDocumentFonts",
+ Number(details.value)
+ );
+ },
+ }),
+ zoomFullPage: Object.assign(makeSettingsAPI("zoomFullPage"), {
+ set: details => {
+ if (typeof details.value !== "boolean") {
+ throw new ExtensionError(
+ `${details.value} is not a valid value for zoomFullPage.`
+ );
+ }
+ return ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "zoomFullPage",
+ details.value
+ );
+ },
+ }),
+ zoomSiteSpecific: Object.assign(makeSettingsAPI("zoomSiteSpecific"), {
+ set: details => {
+ if (typeof details.value !== "boolean") {
+ throw new ExtensionError(
+ `${details.value} is not a valid value for zoomSiteSpecific.`
+ );
+ }
+ return ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "zoomSiteSpecific",
+ details.value
+ );
+ },
+ }),
+ colorManagement: {
+ mode: makeSettingsAPI("colorManagement.mode"),
+ useNativeSRGB: makeSettingsAPI("colorManagement.useNativeSRGB"),
+ useWebRenderCompositor: makeSettingsAPI(
+ "colorManagement.useWebRenderCompositor"
+ ),
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-browsingData.js b/toolkit/components/extensions/parent/ext-browsingData.js
new file mode 100644
index 0000000000..d06f7a3a1b
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-browsingData.js
@@ -0,0 +1,405 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ // This helper contains the platform-specific bits of browsingData.
+ BrowsingDataDelegate: "resource:///modules/ExtensionBrowsingData.sys.mjs",
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+ ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+/**
+ * A number of iterations after which to yield time back
+ * to the system.
+ */
+const YIELD_PERIOD = 10;
+
+/**
+ * Convert a Date object to a PRTime (microseconds).
+ *
+ * @param {Date} date
+ * the Date object to convert.
+ * @returns {integer} microseconds from the epoch.
+ */
+const toPRTime = date => {
+ if (typeof date != "number" && date.constructor.name != "Date") {
+ throw new Error("Invalid value passed to toPRTime");
+ }
+ return date * 1000;
+};
+
+const makeRange = options => {
+ return options.since == null
+ ? null
+ : [toPRTime(options.since), toPRTime(Date.now())];
+};
+global.makeRange = makeRange;
+
+// General implementation for clearing data using Services.clearData.
+// Currently Sanitizer.items uses this under the hood.
+async function clearData(options, flags) {
+ if (options.hostnames) {
+ await Promise.all(
+ options.hostnames.map(
+ host =>
+ new Promise(resolve => {
+ // Set aIsUserRequest to true. This means when the ClearDataService
+ // "Cleaner" implementation doesn't support clearing by host
+ // it will delete all data instead.
+ // This is appropriate for cases like |cache|, which doesn't
+ // support clearing by a time range.
+ // In future when we use this for other data types, we have to
+ // evaluate if that behavior is still acceptable.
+ Services.clearData.deleteDataFromHost(host, true, flags, resolve);
+ })
+ )
+ );
+ return;
+ }
+
+ if (options.since) {
+ const range = makeRange(options);
+ await new Promise(resolve => {
+ Services.clearData.deleteDataInTimeRange(...range, true, flags, resolve);
+ });
+ return;
+ }
+
+ // Don't return the promise here and above to prevent leaking the resolved
+ // value.
+ await new Promise(resolve => Services.clearData.deleteData(flags, resolve));
+}
+
+const clearCache = options => {
+ return clearData(options, Ci.nsIClearDataService.CLEAR_ALL_CACHES);
+};
+
+const clearCookies = async function (options) {
+ let cookieMgr = Services.cookies;
+ // This code has been borrowed from Sanitizer.jsm.
+ let yieldCounter = 0;
+
+ if (options.since || options.hostnames || options.cookieStoreId) {
+ // Iterate through the cookies and delete any created after our cutoff.
+ let cookies = cookieMgr.cookies;
+ if (
+ !options.cookieStoreId ||
+ isPrivateCookieStoreId(options.cookieStoreId)
+ ) {
+ // By default nsICookieManager.cookies doesn't contain private cookies.
+ const privateCookies = cookieMgr.getCookiesWithOriginAttributes(
+ JSON.stringify({
+ privateBrowsingId: 1,
+ })
+ );
+ cookies = cookies.concat(privateCookies);
+ }
+ for (const cookie of cookies) {
+ if (
+ (!options.since || cookie.creationTime >= toPRTime(options.since)) &&
+ (!options.hostnames ||
+ options.hostnames.includes(cookie.host.replace(/^\./, ""))) &&
+ (!options.cookieStoreId ||
+ getCookieStoreIdForOriginAttributes(cookie.originAttributes) ===
+ options.cookieStoreId)
+ ) {
+ // This cookie was created after our cutoff, clear it.
+ cookieMgr.remove(
+ cookie.host,
+ cookie.name,
+ cookie.path,
+ cookie.originAttributes
+ );
+
+ if (++yieldCounter % YIELD_PERIOD == 0) {
+ await new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long.
+ }
+ }
+ }
+ } else {
+ // Remove everything.
+ cookieMgr.removeAll();
+ }
+};
+
+// Ideally we could reuse the logic in Sanitizer.jsm or nsIClearDataService,
+// but this API exposes an ability to wipe data at a much finger granularity
+// than those APIs. (See also Bug 1531276)
+async function clearQuotaManager(options, dataType) {
+ // Can not clear localStorage/indexedDB in private browsing mode,
+ // just ignore.
+ if (options.cookieStoreId == PRIVATE_STORE) {
+ return;
+ }
+
+ let promises = [];
+ await new Promise((resolve, reject) => {
+ Services.qms.getUsage(request => {
+ if (request.resultCode != Cr.NS_OK) {
+ reject({ message: `Clear ${dataType} failed` });
+ return;
+ }
+
+ for (let item of request.result) {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ item.origin
+ );
+
+ // Consistently to removeIndexedDB and the API documentation for
+ // removeLocalStorage, we should only clear the data stored by
+ // regular websites, on the contrary we shouldn't clear data stored
+ // by browser components (like about:newtab) or other extensions.
+ if (!["http", "https", "file"].includes(principal.scheme)) {
+ continue;
+ }
+
+ let host = principal.hostPort;
+ if (
+ (!options.hostnames || options.hostnames.includes(host)) &&
+ (!options.cookieStoreId ||
+ getCookieStoreIdForOriginAttributes(principal.originAttributes) ===
+ options.cookieStoreId)
+ ) {
+ promises.push(
+ new Promise((resolve, reject) => {
+ let clearRequest;
+ if (dataType === "indexedDB") {
+ clearRequest = Services.qms.clearStoragesForPrincipal(
+ principal,
+ null,
+ "idb"
+ );
+ } else {
+ clearRequest = Services.qms.clearStoragesForPrincipal(
+ principal,
+ "default",
+ "ls"
+ );
+ }
+
+ clearRequest.callback = () => {
+ if (clearRequest.resultCode == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject({ message: `Clear ${dataType} failed` });
+ }
+ };
+ })
+ );
+ }
+ }
+
+ resolve();
+ });
+ });
+
+ return Promise.all(promises);
+}
+
+const clearIndexedDB = async function (options) {
+ return clearQuotaManager(options, "indexedDB");
+};
+
+const clearLocalStorage = async function (options) {
+ if (options.since) {
+ return Promise.reject({
+ message: "Firefox does not support clearing localStorage with 'since'.",
+ });
+ }
+
+ // The legacy LocalStorage implementation that will eventually be removed
+ // depends on this observer notification. Some other subsystems like
+ // Reporting headers depend on this too.
+ // When NextGenLocalStorage is enabled these notifications are ignored.
+ if (options.hostnames) {
+ for (let hostname of options.hostnames) {
+ Services.obs.notifyObservers(
+ null,
+ "extension:purge-localStorage",
+ hostname
+ );
+ }
+ } else {
+ Services.obs.notifyObservers(null, "extension:purge-localStorage");
+ }
+
+ if (Services.domStorageManager.nextGenLocalStorageEnabled) {
+ return clearQuotaManager(options, "localStorage");
+ }
+};
+
+const clearPasswords = async function (options) {
+ let yieldCounter = 0;
+
+ // Iterate through the logins and delete any updated after our cutoff.
+ for (let login of await LoginHelper.getAllUserFacingLogins()) {
+ login.QueryInterface(Ci.nsILoginMetaInfo);
+ if (!options.since || login.timePasswordChanged >= options.since) {
+ Services.logins.removeLogin(login);
+ if (++yieldCounter % YIELD_PERIOD == 0) {
+ await new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long.
+ }
+ }
+ }
+};
+
+const clearServiceWorkers = options => {
+ if (!options.hostnames) {
+ return ServiceWorkerCleanUp.removeAll();
+ }
+
+ return Promise.all(
+ options.hostnames.map(host => {
+ return ServiceWorkerCleanUp.removeFromHost(host);
+ })
+ );
+};
+
+class BrowsingDataImpl {
+ constructor(extension) {
+ this.extension = extension;
+ // Some APIs cannot implement in a platform-independent way and they are
+ // delegated to a platform-specific delegate.
+ this.platformDelegate = new BrowsingDataDelegate(extension);
+ }
+
+ handleRemoval(dataType, options) {
+ // First, let's see if the platform implements this
+ let result = this.platformDelegate.handleRemoval(dataType, options);
+ if (result !== undefined) {
+ return result;
+ }
+
+ // ... if not, run the default behavior.
+ switch (dataType) {
+ case "cache":
+ return clearCache(options);
+ case "cookies":
+ return clearCookies(options);
+ case "indexedDB":
+ return clearIndexedDB(options);
+ case "localStorage":
+ return clearLocalStorage(options);
+ case "passwords":
+ return clearPasswords(options);
+ case "pluginData":
+ this.extension?.logger.warn(
+ "pluginData has been deprecated (along with Flash plugin support)"
+ );
+ return Promise.resolve();
+ case "serviceWorkers":
+ return clearServiceWorkers(options);
+ default:
+ return undefined;
+ }
+ }
+
+ doRemoval(options, dataToRemove) {
+ if (
+ options.originTypes &&
+ (options.originTypes.protectedWeb || options.originTypes.extension)
+ ) {
+ return Promise.reject({
+ message:
+ "Firefox does not support protectedWeb or extension as originTypes.",
+ });
+ }
+
+ if (options.cookieStoreId) {
+ const SUPPORTED_TYPES = ["cookies", "indexedDB"];
+ if (Services.domStorageManager.nextGenLocalStorageEnabled) {
+ // Only the next-gen storage supports removal by cookieStoreId.
+ SUPPORTED_TYPES.push("localStorage");
+ }
+
+ for (let dataType in dataToRemove) {
+ if (dataToRemove[dataType] && !SUPPORTED_TYPES.includes(dataType)) {
+ return Promise.reject({
+ message: `Firefox does not support clearing ${dataType} with 'cookieStoreId'.`,
+ });
+ }
+ }
+
+ if (
+ !isPrivateCookieStoreId(options.cookieStoreId) &&
+ !isDefaultCookieStoreId(options.cookieStoreId) &&
+ !getContainerForCookieStoreId(options.cookieStoreId)
+ ) {
+ return Promise.reject({
+ message: `Invalid cookieStoreId: ${options.cookieStoreId}`,
+ });
+ }
+ }
+
+ let removalPromises = [];
+ let invalidDataTypes = [];
+ for (let dataType in dataToRemove) {
+ if (dataToRemove[dataType]) {
+ let result = this.handleRemoval(dataType, options);
+ if (result === undefined) {
+ invalidDataTypes.push(dataType);
+ } else {
+ removalPromises.push(result);
+ }
+ }
+ }
+ if (invalidDataTypes.length) {
+ this.extension.logger.warn(
+ `Firefox does not support dataTypes: ${invalidDataTypes.toString()}.`
+ );
+ }
+ return Promise.all(removalPromises);
+ }
+
+ settings() {
+ return this.platformDelegate.settings();
+ }
+}
+
+this.browsingData = class extends ExtensionAPI {
+ getAPI(context) {
+ const impl = new BrowsingDataImpl(context.extension);
+ return {
+ browsingData: {
+ settings() {
+ return impl.settings();
+ },
+ remove(options, dataToRemove) {
+ return impl.doRemoval(options, dataToRemove);
+ },
+ removeCache(options) {
+ return impl.doRemoval(options, { cache: true });
+ },
+ removeCookies(options) {
+ return impl.doRemoval(options, { cookies: true });
+ },
+ removeDownloads(options) {
+ return impl.doRemoval(options, { downloads: true });
+ },
+ removeFormData(options) {
+ return impl.doRemoval(options, { formData: true });
+ },
+ removeHistory(options) {
+ return impl.doRemoval(options, { history: true });
+ },
+ removeIndexedDB(options) {
+ return impl.doRemoval(options, { indexedDB: true });
+ },
+ removeLocalStorage(options) {
+ return impl.doRemoval(options, { localStorage: true });
+ },
+ removePasswords(options) {
+ return impl.doRemoval(options, { passwords: true });
+ },
+ removePluginData(options) {
+ return impl.doRemoval(options, { pluginData: true });
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-captivePortal.js b/toolkit/components/extensions/parent/ext-captivePortal.js
new file mode 100644
index 0000000000..547abaa594
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-captivePortal.js
@@ -0,0 +1,158 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gCPS",
+ "@mozilla.org/network/captive-portal-service;1",
+ "nsICaptivePortalService"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gCaptivePortalEnabled",
+ "network.captive-portal-service.enabled",
+ false
+);
+
+var { ExtensionPreferencesManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs"
+);
+
+var { getSettingsAPI } = ExtensionPreferencesManager;
+
+const CAPTIVE_URL_PREF = "captivedetect.canonicalURL";
+
+var { ExtensionError } = ExtensionUtils;
+
+this.captivePortal = class extends ExtensionAPIPersistent {
+ checkCaptivePortalEnabled() {
+ if (!gCaptivePortalEnabled) {
+ throw new ExtensionError("Captive Portal detection is not enabled");
+ }
+ }
+
+ nameForCPSState(state) {
+ switch (state) {
+ case gCPS.UNKNOWN:
+ return "unknown";
+ case gCPS.NOT_CAPTIVE:
+ return "not_captive";
+ case gCPS.UNLOCKED_PORTAL:
+ return "unlocked_portal";
+ case gCPS.LOCKED_PORTAL:
+ return "locked_portal";
+ default:
+ return "unknown";
+ }
+ }
+
+ PERSISTENT_EVENTS = {
+ onStateChanged({ fire }) {
+ this.checkCaptivePortalEnabled();
+
+ let observer = (subject, topic) => {
+ fire.async({ state: this.nameForCPSState(gCPS.state) });
+ };
+
+ Services.obs.addObserver(
+ observer,
+ "ipc:network:captive-portal-set-state"
+ );
+ return {
+ unregister: () => {
+ Services.obs.removeObserver(
+ observer,
+ "ipc:network:captive-portal-set-state"
+ );
+ },
+ convert(_fire, context) {
+ fire = _fire;
+ },
+ };
+ },
+ onConnectivityAvailable({ fire }) {
+ this.checkCaptivePortalEnabled();
+
+ let observer = (subject, topic, data) => {
+ fire.async({ status: data });
+ };
+
+ Services.obs.addObserver(observer, "network:captive-portal-connectivity");
+ return {
+ unregister: () => {
+ Services.obs.removeObserver(
+ observer,
+ "network:captive-portal-connectivity"
+ );
+ },
+ convert(_fire, context) {
+ fire = _fire;
+ },
+ };
+ },
+ "captiveURL.onChange": ({ fire }) => {
+ let listener = (text, id) => {
+ fire.async({
+ levelOfControl: "not_controllable",
+ value: Services.prefs.getStringPref(CAPTIVE_URL_PREF),
+ });
+ };
+ Services.prefs.addObserver(CAPTIVE_URL_PREF, listener);
+ return {
+ unregister: () => {
+ Services.prefs.removeObserver(CAPTIVE_URL_PREF, listener);
+ },
+ convert(_fire, context) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let self = this;
+ return {
+ captivePortal: {
+ getState() {
+ self.checkCaptivePortalEnabled();
+ return self.nameForCPSState(gCPS.state);
+ },
+ getLastChecked() {
+ self.checkCaptivePortalEnabled();
+ return gCPS.lastChecked;
+ },
+ onStateChanged: new EventManager({
+ context,
+ module: "captivePortal",
+ event: "onStateChanged",
+ extensionApi: self,
+ }).api(),
+ onConnectivityAvailable: new EventManager({
+ context,
+ module: "captivePortal",
+ event: "onConnectivityAvailable",
+ extensionApi: self,
+ }).api(),
+ canonicalURL: getSettingsAPI({
+ context,
+ name: "captiveURL",
+ callback() {
+ return Services.prefs.getStringPref(CAPTIVE_URL_PREF);
+ },
+ readOnly: true,
+ onChange: new ExtensionCommon.EventManager({
+ context,
+ module: "captivePortal",
+ event: "captiveURL.onChange",
+ extensionApi: self,
+ }).api(),
+ }),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-clipboard.js b/toolkit/components/extensions/parent/ext-clipboard.js
new file mode 100644
index 0000000000..9916b14be7
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-clipboard.js
@@ -0,0 +1,87 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "imgTools",
+ "@mozilla.org/image/tools;1",
+ "imgITools"
+);
+
+const Transferable = Components.Constructor(
+ "@mozilla.org/widget/transferable;1",
+ "nsITransferable"
+);
+
+this.clipboard = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ clipboard: {
+ async setImageData(imageData, imageType) {
+ if (AppConstants.platform == "android") {
+ return Promise.reject({
+ message:
+ "Writing images to the clipboard is not supported on Android",
+ });
+ }
+ let img;
+ try {
+ img = imgTools.decodeImageFromArrayBuffer(
+ imageData,
+ `image/${imageType}`
+ );
+ } catch (e) {
+ return Promise.reject({
+ message: `Data is not a valid ${imageType} image`,
+ });
+ }
+
+ // Other applications can only access the copied image once the data
+ // is exported via the platform-specific clipboard APIs:
+ // nsClipboard::SelectionGetEvent (widget/gtk/nsClipboard.cpp)
+ // nsClipboard::PasteDictFromTransferable (widget/cocoa/nsClipboard.mm)
+ // nsDataObj::GetDib (widget/windows/nsDataObj.cpp)
+ //
+ // The common protocol for exporting a nsITransferable as an image is:
+ // - Use nsITransferable::GetTransferData to fetch the stored data.
+ // - QI imgIContainer on the pointer.
+ // - Convert the image to the native clipboard format.
+ //
+ // Below we create a nsITransferable in the above format.
+ let transferable = new Transferable();
+ transferable.init(null);
+ const kNativeImageMime = "application/x-moz-nativeimage";
+ transferable.addDataFlavor(kNativeImageMime);
+
+ // Internal consumers expect the image data to be stored as a
+ // nsIInputStream. On Linux and Windows, pasted data is directly
+ // retrieved from the system's native clipboard, and made available
+ // as a nsIInputStream.
+ //
+ // On macOS, nsClipboard::GetNativeClipboardData (nsClipboard.mm) uses
+ // a cached copy of nsITransferable if available, e.g. when the copy
+ // was initiated by the same browser instance. To make sure that a
+ // nsIInputStream is returned instead of the cached imgIContainer,
+ // the image is exported as as `kNativeImageMime`. Data associated
+ // with this type is converted to a platform-specific image format
+ // when written to the clipboard. The type is not used when images
+ // are read from the clipboard (on all platforms, not just macOS).
+ // This forces nsClipboard::GetNativeClipboardData to fall back to
+ // the native clipboard, and return the image as a nsITransferable.
+ transferable.setTransferData(kNativeImageMime, img);
+
+ Services.clipboard.setData(
+ transferable,
+ null,
+ Services.clipboard.kGlobalClipboard
+ );
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-contentScripts.js b/toolkit/components/extensions/parent/ext-contentScripts.js
new file mode 100644
index 0000000000..068b2c7403
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-contentScripts.js
@@ -0,0 +1,232 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+
+var { ExtensionError, getUniqueId } = ExtensionUtils;
+
+function getOriginAttributesPatternForCookieStoreId(cookieStoreId) {
+ if (isDefaultCookieStoreId(cookieStoreId)) {
+ return {
+ userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID,
+ privateBrowsingId:
+ Ci.nsIScriptSecurityManager.DEFAULT_PRIVATE_BROWSING_ID,
+ };
+ }
+ if (isPrivateCookieStoreId(cookieStoreId)) {
+ return {
+ userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID,
+ privateBrowsingId: 1,
+ };
+ }
+ if (isContainerCookieStoreId(cookieStoreId)) {
+ let userContextId = getContainerForCookieStoreId(cookieStoreId);
+ if (userContextId !== null) {
+ return { userContextId };
+ }
+ }
+
+ throw new ExtensionError("Invalid cookieStoreId");
+}
+
+/**
+ * Represents (in the main browser process) a content script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ProxyContextParent} context
+ * The parent proxy context related to the extension context which
+ * has registered the content script.
+ * @param {RegisteredContentScriptOptions} details
+ * The options object related to the registered content script
+ * (which has the properties described in the content_scripts.json
+ * JSON API schema file).
+ */
+class ContentScriptParent {
+ constructor({ context, details }) {
+ this.context = context;
+ this.scriptId = getUniqueId();
+ this.blobURLs = new Set();
+
+ this.options = this._convertOptions(details);
+
+ context.callOnClose(this);
+ }
+
+ close() {
+ this.destroy();
+ }
+
+ destroy() {
+ if (this.destroyed) {
+ throw new Error("Unable to destroy ContentScriptParent twice");
+ }
+
+ this.destroyed = true;
+
+ this.context.forgetOnClose(this);
+
+ for (const blobURL of this.blobURLs) {
+ this.context.cloneScope.URL.revokeObjectURL(blobURL);
+ }
+
+ this.blobURLs.clear();
+
+ this.context = null;
+ this.options = null;
+ }
+
+ _convertOptions(details) {
+ const { context } = this;
+
+ const options = {
+ matches: details.matches,
+ excludeMatches: details.excludeMatches,
+ includeGlobs: details.includeGlobs,
+ excludeGlobs: details.excludeGlobs,
+ allFrames: details.allFrames,
+ matchAboutBlank: details.matchAboutBlank,
+ runAt: details.runAt || "document_idle",
+ jsPaths: [],
+ cssPaths: [],
+ originAttributesPatterns: null,
+ };
+
+ if (details.cookieStoreId != null) {
+ const cookieStoreIds = Array.isArray(details.cookieStoreId)
+ ? details.cookieStoreId
+ : [details.cookieStoreId];
+ options.originAttributesPatterns = cookieStoreIds.map(cookieStoreId =>
+ getOriginAttributesPatternForCookieStoreId(cookieStoreId)
+ );
+ }
+
+ const convertCodeToURL = (data, mime) => {
+ const blob = new context.cloneScope.Blob(data, { type: mime });
+ const blobURL = context.cloneScope.URL.createObjectURL(blob);
+
+ this.blobURLs.add(blobURL);
+
+ return blobURL;
+ };
+
+ if (details.js && details.js.length) {
+ options.jsPaths = details.js.map(data => {
+ if (data.file) {
+ return data.file;
+ }
+
+ return convertCodeToURL([data.code], "text/javascript");
+ });
+ }
+
+ if (details.css && details.css.length) {
+ options.cssPaths = details.css.map(data => {
+ if (data.file) {
+ return data.file;
+ }
+
+ return convertCodeToURL([data.code], "text/css");
+ });
+ }
+
+ return options;
+ }
+
+ serialize() {
+ return this.options;
+ }
+}
+
+this.contentScripts = class extends ExtensionAPI {
+ getAPI(context) {
+ const { extension } = context;
+
+ // Map of the content script registered from the extension context.
+ //
+ // Map<scriptId -> ContentScriptParent>
+ const parentScriptsMap = new Map();
+
+ // Unregister all the scriptId related to a context when it is closed.
+ context.callOnClose({
+ close() {
+ if (parentScriptsMap.size === 0) {
+ return;
+ }
+
+ const scriptIds = Array.from(parentScriptsMap.keys());
+
+ for (let scriptId of scriptIds) {
+ extension.registeredContentScripts.delete(scriptId);
+ }
+ extension.updateContentScripts();
+
+ extension.broadcast("Extension:UnregisterContentScripts", {
+ id: extension.id,
+ scriptIds,
+ });
+ },
+ });
+
+ return {
+ contentScripts: {
+ async register(details) {
+ for (let origin of details.matches) {
+ if (!extension.allowedOrigins.subsumes(new MatchPattern(origin))) {
+ throw new ExtensionError(
+ `Permission denied to register a content script for ${origin}`
+ );
+ }
+ }
+
+ const contentScript = new ContentScriptParent({ context, details });
+ const { scriptId } = contentScript;
+
+ parentScriptsMap.set(scriptId, contentScript);
+
+ const scriptOptions = contentScript.serialize();
+
+ extension.registeredContentScripts.set(scriptId, scriptOptions);
+ extension.updateContentScripts();
+
+ await extension.broadcast("Extension:RegisterContentScripts", {
+ id: extension.id,
+ scripts: [{ scriptId, options: scriptOptions }],
+ });
+
+ return scriptId;
+ },
+
+ // This method is not available to the extension code, the extension code
+ // doesn't have access to the internally used scriptId, on the contrary
+ // the extension code will call script.unregister on the script API object
+ // that is resolved from the register API method returned promise.
+ async unregister(scriptId) {
+ const contentScript = parentScriptsMap.get(scriptId);
+ if (!contentScript) {
+ Cu.reportError(new Error(`No such content script ID: ${scriptId}`));
+
+ return;
+ }
+
+ parentScriptsMap.delete(scriptId);
+ extension.registeredContentScripts.delete(scriptId);
+ extension.updateContentScripts();
+
+ contentScript.destroy();
+
+ await extension.broadcast("Extension:UnregisterContentScripts", {
+ id: extension.id,
+ scriptIds: [scriptId],
+ });
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-contextualIdentities.js b/toolkit/components/extensions/parent/ext-contextualIdentities.js
new file mode 100644
index 0000000000..c7f28d5e90
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-contextualIdentities.js
@@ -0,0 +1,362 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+});
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "containersEnabled",
+ "privacy.userContext.enabled"
+);
+
+var { ExtensionPreferencesManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+const CONTAINER_PREF_INSTALL_DEFAULTS = {
+ "privacy.userContext.extension": undefined,
+};
+
+const CONTAINERS_ENABLED_SETTING_NAME = "privacy.containers";
+
+const CONTAINER_COLORS = new Map([
+ ["blue", "#37adff"],
+ ["turquoise", "#00c79a"],
+ ["green", "#51cd00"],
+ ["yellow", "#ffcb00"],
+ ["orange", "#ff9f00"],
+ ["red", "#ff613d"],
+ ["pink", "#ff4bda"],
+ ["purple", "#af51f5"],
+ ["toolbar", "#7c7c7d"],
+]);
+
+const CONTAINER_ICONS = new Set([
+ "briefcase",
+ "cart",
+ "circle",
+ "dollar",
+ "fence",
+ "fingerprint",
+ "gift",
+ "vacation",
+ "food",
+ "fruit",
+ "pet",
+ "tree",
+ "chill",
+]);
+
+function getContainerIcon(iconName) {
+ if (!CONTAINER_ICONS.has(iconName)) {
+ throw new ExtensionError(`Invalid icon ${iconName} for container`);
+ }
+ return `resource://usercontext-content/${iconName}.svg`;
+}
+
+function getContainerColor(colorName) {
+ if (!CONTAINER_COLORS.has(colorName)) {
+ throw new ExtensionError(`Invalid color name ${colorName} for container`);
+ }
+ return CONTAINER_COLORS.get(colorName);
+}
+
+const convertIdentity = identity => {
+ let result = {
+ name: ContextualIdentityService.getUserContextLabel(identity.userContextId),
+ icon: identity.icon,
+ iconUrl: getContainerIcon(identity.icon),
+ color: identity.color,
+ colorCode: getContainerColor(identity.color),
+ cookieStoreId: getCookieStoreIdForContainer(identity.userContextId),
+ };
+
+ return result;
+};
+
+const checkAPIEnabled = () => {
+ if (!containersEnabled) {
+ throw new ExtensionError("Contextual identities are currently disabled");
+ }
+};
+
+const convertIdentityFromObserver = wrappedIdentity => {
+ let identity = wrappedIdentity.wrappedJSObject;
+ let iconUrl, colorCode;
+ try {
+ iconUrl = getContainerIcon(identity.icon);
+ colorCode = getContainerColor(identity.color);
+ } catch (e) {
+ return null;
+ }
+
+ let result = {
+ name: identity.name,
+ icon: identity.icon,
+ iconUrl,
+ color: identity.color,
+ colorCode,
+ cookieStoreId: getCookieStoreIdForContainer(identity.userContextId),
+ };
+
+ return result;
+};
+
+ExtensionPreferencesManager.addSetting(CONTAINERS_ENABLED_SETTING_NAME, {
+ prefNames: Object.keys(CONTAINER_PREF_INSTALL_DEFAULTS),
+
+ setCallback(value) {
+ if (value !== true) {
+ return {
+ ...CONTAINER_PREF_INSTALL_DEFAULTS,
+ "privacy.userContext.extension": value,
+ };
+ }
+ return {};
+ },
+});
+
+this.contextualIdentities = class extends ExtensionAPIPersistent {
+ eventRegistrar(eventName) {
+ return ({ fire }) => {
+ let observer = (subject, topic) => {
+ let convertedIdentity = convertIdentityFromObserver(subject);
+ if (convertedIdentity) {
+ fire.async({ contextualIdentity: convertedIdentity });
+ }
+ };
+
+ Services.obs.addObserver(observer, eventName);
+ return {
+ unregister() {
+ Services.obs.removeObserver(observer, eventName);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ onCreated: this.eventRegistrar("contextual-identity-created"),
+ onUpdated: this.eventRegistrar("contextual-identity-updated"),
+ onRemoved: this.eventRegistrar("contextual-identity-deleted"),
+ };
+
+ onStartup() {
+ let { extension } = this;
+
+ if (extension.hasPermission("contextualIdentities")) {
+ // Turn on contextual identities, and never turn it off. We handle
+ // this here to ensure prefs are set when an addon is enabled.
+ Services.prefs.setBoolPref("privacy.userContext.enabled", true);
+ Services.prefs.setBoolPref("privacy.userContext.ui.enabled", true);
+
+ ExtensionPreferencesManager.setSetting(
+ extension.id,
+ CONTAINERS_ENABLED_SETTING_NAME,
+ extension.id
+ );
+ }
+ }
+
+ getAPI(context) {
+ let self = {
+ contextualIdentities: {
+ async get(cookieStoreId) {
+ checkAPIEnabled();
+ let containerId = getContainerForCookieStoreId(cookieStoreId);
+ if (!containerId) {
+ throw new ExtensionError(
+ `Invalid contextual identity: ${cookieStoreId}`
+ );
+ }
+
+ let identity =
+ ContextualIdentityService.getPublicIdentityFromId(containerId);
+ return convertIdentity(identity);
+ },
+
+ async query(details) {
+ checkAPIEnabled();
+ let identities = [];
+ ContextualIdentityService.getPublicIdentities().forEach(identity => {
+ if (
+ details.name &&
+ ContextualIdentityService.getUserContextLabel(
+ identity.userContextId
+ ) != details.name
+ ) {
+ return;
+ }
+
+ identities.push(convertIdentity(identity));
+ });
+
+ return identities;
+ },
+
+ async create(details) {
+ // Lets prevent making containers that are not valid
+ getContainerIcon(details.icon);
+ getContainerColor(details.color);
+
+ let identity = ContextualIdentityService.create(
+ details.name,
+ details.icon,
+ details.color
+ );
+ return convertIdentity(identity);
+ },
+
+ async update(cookieStoreId, details) {
+ checkAPIEnabled();
+ let containerId = getContainerForCookieStoreId(cookieStoreId);
+ if (!containerId) {
+ throw new ExtensionError(
+ `Invalid contextual identity: ${cookieStoreId}`
+ );
+ }
+
+ let identity =
+ ContextualIdentityService.getPublicIdentityFromId(containerId);
+ if (!identity) {
+ throw new ExtensionError(
+ `Invalid contextual identity: ${cookieStoreId}`
+ );
+ }
+
+ if (details.name !== null) {
+ identity.name = details.name;
+ }
+
+ if (details.color !== null) {
+ getContainerColor(details.color);
+ identity.color = details.color;
+ }
+
+ if (details.icon !== null) {
+ getContainerIcon(details.icon);
+ identity.icon = details.icon;
+ }
+
+ if (
+ !ContextualIdentityService.update(
+ identity.userContextId,
+ identity.name,
+ identity.icon,
+ identity.color
+ )
+ ) {
+ throw new ExtensionError(
+ `Contextual identity failed to update: ${cookieStoreId}`
+ );
+ }
+
+ return convertIdentity(identity);
+ },
+
+ async move(cookieStoreIds, position) {
+ checkAPIEnabled();
+ if (!Array.isArray(cookieStoreIds)) {
+ cookieStoreIds = [cookieStoreIds];
+ }
+
+ if (!cookieStoreIds.length) {
+ return;
+ }
+
+ const totalIds =
+ ContextualIdentityService.getPublicIdentities().length;
+ if (position < -1 || position > totalIds - cookieStoreIds.length) {
+ throw new ExtensionError(`Moving to invalid position ${position}`);
+ }
+
+ let userContextIds = [];
+ cookieStoreIds.forEach((cookieStoreId, index) => {
+ if (cookieStoreIds.indexOf(cookieStoreId) !== index) {
+ throw new ExtensionError(
+ `Duplicate contextual identity: ${cookieStoreId}`
+ );
+ }
+
+ let containerId = getContainerForCookieStoreId(cookieStoreId);
+ if (!containerId) {
+ throw new ExtensionError(
+ `Invalid contextual identity: ${cookieStoreId}`
+ );
+ }
+
+ userContextIds.push(containerId);
+ });
+
+ if (!ContextualIdentityService.move(userContextIds, position)) {
+ throw new ExtensionError(
+ `Contextual identities failed to move: ${cookieStoreIds}`
+ );
+ }
+ },
+
+ async remove(cookieStoreId) {
+ checkAPIEnabled();
+ let containerId = getContainerForCookieStoreId(cookieStoreId);
+ if (!containerId) {
+ throw new ExtensionError(
+ `Invalid contextual identity: ${cookieStoreId}`
+ );
+ }
+
+ let identity =
+ ContextualIdentityService.getPublicIdentityFromId(containerId);
+ if (!identity) {
+ throw new ExtensionError(
+ `Invalid contextual identity: ${cookieStoreId}`
+ );
+ }
+
+ // We have to create the identity object before removing it.
+ let convertedIdentity = convertIdentity(identity);
+
+ if (!ContextualIdentityService.remove(identity.userContextId)) {
+ throw new ExtensionError(
+ `Contextual identity failed to remove: ${cookieStoreId}`
+ );
+ }
+
+ return convertedIdentity;
+ },
+
+ onCreated: new EventManager({
+ context,
+ module: "contextualIdentities",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+
+ onUpdated: new EventManager({
+ context,
+ module: "contextualIdentities",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ module: "contextualIdentities",
+ event: "onRemoved",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+
+ return self;
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-cookies.js b/toolkit/components/extensions/parent/ext-cookies.js
new file mode 100644
index 0000000000..9308a56cfd
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-cookies.js
@@ -0,0 +1,696 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals DEFAULT_STORE, PRIVATE_STORE */
+
+var { ExtensionError } = ExtensionUtils;
+
+const SAME_SITE_STATUSES = [
+ "no_restriction", // Index 0 = Ci.nsICookie.SAMESITE_NONE
+ "lax", // Index 1 = Ci.nsICookie.SAMESITE_LAX
+ "strict", // Index 2 = Ci.nsICookie.SAMESITE_STRICT
+];
+
+const isIPv4 = host => {
+ let match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(host);
+
+ if (match) {
+ return match[1] < 256 && match[2] < 256 && match[3] < 256 && match[4] < 256;
+ }
+ return false;
+};
+const isIPv6 = host => host.includes(":");
+const addBracketIfIPv6 = host =>
+ isIPv6(host) && !host.startsWith("[") ? `[${host}]` : host;
+const dropBracketIfIPv6 = host =>
+ isIPv6(host) && host.startsWith("[") && host.endsWith("]")
+ ? host.slice(1, -1)
+ : host;
+
+// Converts the partitionKey format of the extension API (i.e. PartitionKey) to
+// a valid format for the "partitionKey" member of OriginAttributes.
+function fromExtPartitionKey(extPartitionKey) {
+ if (!extPartitionKey) {
+ // Unpartitioned by default.
+ return "";
+ }
+ const { topLevelSite } = extPartitionKey;
+ // TODO: Expand API to force the generation of a partitionKey that differs
+ // from the default that's specified by privacy.dynamic_firstparty.use_site.
+ if (topLevelSite) {
+ // If topLevelSite is set and a non-empty string (a site in a URL format).
+ try {
+ return ChromeUtils.getPartitionKeyFromURL(topLevelSite);
+ } catch (e) {
+ throw new ExtensionError("Invalid value for 'partitionKey' attribute");
+ }
+ }
+ // Unpartitioned.
+ return "";
+}
+// Converts an internal partitionKey (format used by OriginAttributes) to the
+// string value as exposed through the extension API.
+function toExtPartitionKey(partitionKey) {
+ if (!partitionKey) {
+ // Canonical representation of an empty partitionKey is null.
+ // In theory {topLevelSite: ""} also works, but alas.
+ return null;
+ }
+ // Parse partitionKey in order to generate the desired return type (URL).
+ // OriginAttributes::ParsePartitionKey cannot be used because it assumes that
+ // the input matches the format of the privacy.dynamic_firstparty.use_site
+ // pref, which is not necessarily the case for cookies before the pref flip.
+ if (!partitionKey.startsWith("(")) {
+ // A partitionKey generated with privacy.dynamic_firstparty.use_site=false.
+ return { topLevelSite: `https://${partitionKey}` };
+ }
+ // partitionKey starts with "(" and ends with ")".
+ let [scheme, domain, port] = partitionKey.slice(1, -1).split(",");
+ let topLevelSite = `${scheme}://${domain}`;
+ if (port) {
+ topLevelSite += `:${port}`;
+ }
+ return { topLevelSite };
+}
+
+const convertCookie = ({ cookie, isPrivate }) => {
+ let result = {
+ name: cookie.name,
+ value: cookie.value,
+ domain: addBracketIfIPv6(cookie.host),
+ hostOnly: !cookie.isDomain,
+ path: cookie.path,
+ secure: cookie.isSecure,
+ httpOnly: cookie.isHttpOnly,
+ sameSite: SAME_SITE_STATUSES[cookie.sameSite],
+ session: cookie.isSession,
+ firstPartyDomain: cookie.originAttributes.firstPartyDomain || "",
+ partitionKey: toExtPartitionKey(cookie.originAttributes.partitionKey),
+ };
+
+ if (!cookie.isSession) {
+ result.expirationDate = cookie.expiry;
+ }
+
+ if (cookie.originAttributes.userContextId) {
+ result.storeId = getCookieStoreIdForContainer(
+ cookie.originAttributes.userContextId
+ );
+ } else if (cookie.originAttributes.privateBrowsingId || isPrivate) {
+ result.storeId = PRIVATE_STORE;
+ } else {
+ result.storeId = DEFAULT_STORE;
+ }
+
+ return result;
+};
+
+const isSubdomain = (otherDomain, baseDomain) => {
+ return otherDomain == baseDomain || otherDomain.endsWith("." + baseDomain);
+};
+
+// Checks that the given extension has permission to set the given cookie for
+// the given URI.
+const checkSetCookiePermissions = (extension, uri, cookie) => {
+ // Permission checks:
+ //
+ // - If the extension does not have permissions for the specified
+ // URL, it cannot set cookies for it.
+ //
+ // - If the specified URL could not set the given cookie, neither can
+ // the extension.
+ //
+ // Ideally, we would just have the cookie service make the latter
+ // determination, but that turns out to be quite complicated. At the
+ // moment, it requires constructing a cookie string and creating a
+ // dummy channel, both of which can be problematic. It also triggers
+ // a whole set of additional permission and preference checks, which
+ // may or may not be desirable.
+ //
+ // So instead, we do a similar set of checks here. Exactly what
+ // cookies a given URL should be able to set is not well-documented,
+ // and is not standardized in any standard that anyone actually
+ // follows. So instead, we follow the rules used by the cookie
+ // service.
+ //
+ // See source/netwerk/cookie/CookieService.cpp, in particular
+ // CheckDomain() and SetCookieInternal().
+
+ if (uri.scheme != "http" && uri.scheme != "https") {
+ return false;
+ }
+
+ if (!extension.allowedOrigins.matches(uri)) {
+ return false;
+ }
+
+ if (!cookie.host) {
+ // If no explicit host is specified, this becomes a host-only cookie.
+ cookie.host = uri.host;
+ return true;
+ }
+
+ // A leading "." is not expected, but is tolerated if it's not the only
+ // character in the host. If there is one, start by stripping it off. We'll
+ // add a new one on success.
+ if (cookie.host.length > 1) {
+ cookie.host = cookie.host.replace(/^\./, "");
+ }
+ cookie.host = cookie.host.toLowerCase();
+ cookie.host = dropBracketIfIPv6(cookie.host);
+
+ if (cookie.host != uri.host) {
+ // Not an exact match, so check for a valid subdomain.
+ let baseDomain;
+ try {
+ baseDomain = Services.eTLD.getBaseDomain(uri);
+ } catch (e) {
+ if (
+ e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS ||
+ e.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
+ ) {
+ // The cookie service uses these to determine whether the domain
+ // requires an exact match. We already know we don't have an exact
+ // match, so return false. In all other cases, re-raise the error.
+ return false;
+ }
+ throw e;
+ }
+
+ // The cookie domain must be a subdomain of the base domain. This prevents
+ // us from setting cookies for domains like ".co.uk".
+ // The domain of the requesting URL must likewise be a subdomain of the
+ // cookie domain. This prevents us from setting cookies for entirely
+ // unrelated domains.
+ if (
+ !isSubdomain(cookie.host, baseDomain) ||
+ !isSubdomain(uri.host, cookie.host)
+ ) {
+ return false;
+ }
+
+ // RFC2109 suggests that we may only add cookies for sub-domains 1-level
+ // below us, but enforcing that would break the web, so we don't.
+ }
+
+ // If the host is an IP address, avoid adding a leading ".".
+ // An IP address is not a domain name, and only supports host-only cookies.
+ if (isIPv6(cookie.host) || isIPv4(cookie.host)) {
+ return true;
+ }
+
+ // An explicit domain was passed, so add a leading "." to make this a
+ // domain cookie.
+ cookie.host = "." + cookie.host;
+
+ // We don't do any significant checking of path permissions. RFC2109
+ // suggests we only allow sites to add cookies for sub-paths, similar to
+ // same origin policy enforcement, but no-one implements this.
+
+ return true;
+};
+
+/**
+ * Converts the details received from the cookies API to the OriginAttributes
+ * format, using default values when needed (firstPartyDomain/partitionKey).
+ *
+ * If allowPattern is true, an OriginAttributesPattern may be returned instead.
+ *
+ * @param {object} details
+ * The details received from the extension.
+ * @param {BaseContext} context
+ * @param {boolean} allowPattern
+ * Whether to potentially return an OriginAttributesPattern instead of
+ * OriginAttributes. The get/set/remove cookie methods operate on exact
+ * OriginAttributes, the getAll method allows a partial pattern and may
+ * potentially match cookies with distinct origin attributes.
+ * @returns {object} An object with the following properties:
+ * - originAttributes {OriginAttributes|OriginAttributesPattern}
+ * - isPattern {boolean} Whether originAttributes is a pattern.
+ * - isPrivate {boolean} Whether the cookie belongs to private browsing mode.
+ * - storeId {string} The storeId of the cookie.
+ */
+const oaFromDetails = (details, context, allowPattern) => {
+ // Default values, may be filled in based on details.
+ let originAttributes = {
+ userContextId: 0,
+ privateBrowsingId: 0,
+ // The following two keys may be deleted if allowPattern=true
+ firstPartyDomain: details.firstPartyDomain ?? "",
+ partitionKey: fromExtPartitionKey(details.partitionKey),
+ };
+
+ let isPrivate = context.incognito;
+ let storeId = isPrivate ? PRIVATE_STORE : DEFAULT_STORE;
+ if (details.storeId) {
+ storeId = details.storeId;
+ if (isDefaultCookieStoreId(storeId)) {
+ isPrivate = false;
+ } else if (isPrivateCookieStoreId(storeId)) {
+ isPrivate = true;
+ } else {
+ isPrivate = false;
+ let userContextId = getContainerForCookieStoreId(storeId);
+ if (!userContextId) {
+ throw new ExtensionError(`Invalid cookie store id: "${storeId}"`);
+ }
+ originAttributes.userContextId = userContextId;
+ }
+ }
+
+ if (isPrivate) {
+ originAttributes.privateBrowsingId = 1;
+ if (!context.privateBrowsingAllowed) {
+ throw new ExtensionError(
+ "Extension disallowed access to the private cookies storeId."
+ );
+ }
+ }
+
+ // If any of the originAttributes's keys are deleted, this becomes true.
+ let isPattern = false;
+ if (allowPattern) {
+ // firstPartyDomain is unset / void / string.
+ // If unset, then we default to non-FPI cookies (or if FPI is enabled,
+ // an error is thrown by validateFirstPartyDomain). We are able to detect
+ // whether the property is set due to "omit-key-if-missing" in cookies.json.
+ // If set to a string, we keep the filter.
+ // If set to void (undefined / null), we drop the FPI filter:
+ if ("firstPartyDomain" in details && details.firstPartyDomain == null) {
+ delete originAttributes.firstPartyDomain;
+ isPattern = true;
+ }
+
+ // partitionKey is an object or null.
+ // null implies the default (unpartitioned cookies).
+ // An object is a filter for partitionKey; currently we require topLevelSite
+ // to be set to determine the exact partitionKey. Without it, we drop the
+ // dFPI filter:
+ if (details.partitionKey && details.partitionKey.topLevelSite == null) {
+ delete originAttributes.partitionKey;
+ isPattern = true;
+ }
+ }
+ return { originAttributes, isPattern, isPrivate, storeId };
+};
+
+/**
+ * Query the cookie store for matching cookies.
+ *
+ * @param {object} detailsIn
+ * @param {Array} props Properties the extension is interested in matching against.
+ * The firstPartyDomain / partitionKey / storeId
+ * props are always accounted for.
+ * @param {BaseContext} context The context making the query.
+ * @param {boolean} allowPattern Whether to allow the query to match distinct
+ * origin attributes instead of falling back to
+ * default values. See the oaFromDetails method.
+ */
+const query = function* (detailsIn, props, context, allowPattern) {
+ let details = {};
+ props.forEach(property => {
+ if (detailsIn[property] !== null) {
+ details[property] = detailsIn[property];
+ }
+ });
+
+ let parsedOA;
+ try {
+ parsedOA = oaFromDetails(detailsIn, context, allowPattern);
+ } catch (e) {
+ if (e.message.startsWith("Invalid cookie store id")) {
+ // For backwards-compatibility with previous versions of Firefox, fail
+ // silently (by not returning any results) instead of throwing an error.
+ return;
+ }
+ throw e;
+ }
+ let { originAttributes, isPattern, isPrivate, storeId } = parsedOA;
+
+ if ("domain" in details) {
+ details.domain = details.domain.toLowerCase().replace(/^\./, "");
+ details.domain = dropBracketIfIPv6(details.domain);
+ }
+
+ // We can use getCookiesFromHost for faster searching.
+ let cookies;
+ let host;
+ let url;
+ if ("url" in details) {
+ try {
+ url = new URL(details.url);
+ host = dropBracketIfIPv6(url.hostname);
+ } catch (ex) {
+ // This often happens for about: URLs
+ return;
+ }
+ } else if ("domain" in details) {
+ host = details.domain;
+ }
+
+ if (host && !isPattern) {
+ // getCookiesFromHost is more efficient than getCookiesWithOriginAttributes
+ // if the host and all origin attributes are known.
+ cookies = Services.cookies.getCookiesFromHost(host, originAttributes);
+ } else {
+ cookies = Services.cookies.getCookiesWithOriginAttributes(
+ JSON.stringify(originAttributes),
+ host
+ );
+ }
+
+ // Based on CookieService::GetCookieStringFromHttp
+ function matches(cookie) {
+ function domainMatches(host) {
+ return (
+ cookie.rawHost == host ||
+ (cookie.isDomain && host.endsWith(cookie.host))
+ );
+ }
+
+ function pathMatches(path) {
+ let cookiePath = cookie.path.replace(/\/$/, "");
+
+ if (!path.startsWith(cookiePath)) {
+ return false;
+ }
+
+ // path == cookiePath, but without the redundant string compare.
+ if (path.length == cookiePath.length) {
+ return true;
+ }
+
+ // URL path is a substring of the cookie path, so it matches if, and
+ // only if, the next character is a path delimiter.
+ return path[cookiePath.length] === "/";
+ }
+
+ // "Restricts the retrieved cookies to those that would match the given URL."
+ if (url) {
+ if (!domainMatches(host)) {
+ return false;
+ }
+
+ if (cookie.isSecure && url.protocol != "https:") {
+ return false;
+ }
+
+ if (!pathMatches(url.pathname)) {
+ return false;
+ }
+ }
+
+ if ("name" in details && details.name != cookie.name) {
+ return false;
+ }
+
+ // "Restricts the retrieved cookies to those whose domains match or are subdomains of this one."
+ if ("domain" in details && !isSubdomain(cookie.rawHost, details.domain)) {
+ return false;
+ }
+
+ // "Restricts the retrieved cookies to those whose path exactly matches this string.""
+ if ("path" in details && details.path != cookie.path) {
+ return false;
+ }
+
+ if ("secure" in details && details.secure != cookie.isSecure) {
+ return false;
+ }
+
+ if ("session" in details && details.session != cookie.isSession) {
+ return false;
+ }
+
+ // Check that the extension has permissions for this host.
+ if (!context.extension.allowedOrigins.matchesCookie(cookie)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ for (const cookie of cookies) {
+ if (matches(cookie)) {
+ yield { cookie, isPrivate, storeId };
+ }
+ }
+};
+
+const validateFirstPartyDomain = details => {
+ if (details.firstPartyDomain != null) {
+ return;
+ }
+ if (Services.prefs.getBoolPref("privacy.firstparty.isolate")) {
+ throw new ExtensionError(
+ "First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set."
+ );
+ }
+};
+
+this.cookies = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ onChanged({ fire }) {
+ let observer = (subject, topic) => {
+ let notify = (removed, cookie, cause) => {
+ cookie.QueryInterface(Ci.nsICookie);
+
+ if (this.extension.allowedOrigins.matchesCookie(cookie)) {
+ fire.async({
+ removed,
+ cookie: convertCookie({
+ cookie,
+ isPrivate: topic == "private-cookie-changed",
+ }),
+ cause,
+ });
+ }
+ };
+
+ let notification = subject.QueryInterface(Ci.nsICookieNotification);
+ let { cookie } = notification;
+
+ let {
+ COOKIE_DELETED,
+ COOKIE_ADDED,
+ COOKIE_CHANGED,
+ COOKIES_BATCH_DELETED,
+ } = Ci.nsICookieNotification;
+
+ // We do our best effort here to map the incompatible states.
+ switch (notification.action) {
+ case COOKIE_DELETED:
+ notify(true, cookie, "explicit");
+ break;
+ case COOKIE_ADDED:
+ notify(false, cookie, "explicit");
+ break;
+ case COOKIE_CHANGED:
+ notify(true, cookie, "overwrite");
+ notify(false, cookie, "explicit");
+ break;
+ case COOKIES_BATCH_DELETED:
+ let cookieArray = notification.batchDeletedCookies.QueryInterface(
+ Ci.nsIArray
+ );
+ for (let i = 0; i < cookieArray.length; i++) {
+ let cookie = cookieArray.queryElementAt(i, Ci.nsICookie);
+ if (!cookie.isSession && cookie.expiry * 1000 <= Date.now()) {
+ notify(true, cookie, "expired");
+ } else {
+ notify(true, cookie, "evicted");
+ }
+ }
+ break;
+ }
+ };
+
+ const { privateBrowsingAllowed } = this.extension;
+ Services.obs.addObserver(observer, "cookie-changed");
+ if (privateBrowsingAllowed) {
+ Services.obs.addObserver(observer, "private-cookie-changed");
+ }
+ return {
+ unregister() {
+ Services.obs.removeObserver(observer, "cookie-changed");
+ if (privateBrowsingAllowed) {
+ Services.obs.removeObserver(observer, "private-cookie-changed");
+ }
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+ getAPI(context) {
+ let { extension } = context;
+ let self = {
+ cookies: {
+ get: function (details) {
+ validateFirstPartyDomain(details);
+
+ // TODO bug 1818968: We don't sort by length of path and creation time.
+ let allowed = ["url", "name"];
+ for (let cookie of query(details, allowed, context)) {
+ return Promise.resolve(convertCookie(cookie));
+ }
+
+ // Found no match.
+ return Promise.resolve(null);
+ },
+
+ getAll: function (details) {
+ if (!("firstPartyDomain" in details)) {
+ // Check and throw an error if firstPartyDomain is required.
+ validateFirstPartyDomain(details);
+ }
+
+ let allowed = ["url", "name", "domain", "path", "secure", "session"];
+ let result = Array.from(
+ query(details, allowed, context, /* allowPattern = */ true),
+ convertCookie
+ );
+
+ return Promise.resolve(result);
+ },
+
+ set: function (details) {
+ validateFirstPartyDomain(details);
+ if (details.firstPartyDomain && details.partitionKey) {
+ // FPI and dFPI are mutually exclusive, so it does not make sense
+ // to accept non-empty (i.e. non-default) values for both.
+ throw new ExtensionError(
+ "Partitioned cookies cannot have a 'firstPartyDomain' attribute."
+ );
+ }
+
+ let uri = Services.io.newURI(details.url);
+
+ let path;
+ if (details.path !== null) {
+ path = details.path;
+ } else {
+ // This interface essentially emulates the behavior of the
+ // Set-Cookie header. In the case of an omitted path, the cookie
+ // service uses the directory path of the requesting URL, ignoring
+ // any filename or query parameters.
+ path = uri.QueryInterface(Ci.nsIURL).directory;
+ }
+
+ let name = details.name !== null ? details.name : "";
+ let value = details.value !== null ? details.value : "";
+ let secure = details.secure !== null ? details.secure : false;
+ let httpOnly = details.httpOnly !== null ? details.httpOnly : false;
+ let isSession = details.expirationDate === null;
+ let expiry = isSession
+ ? Number.MAX_SAFE_INTEGER
+ : details.expirationDate;
+
+ let { originAttributes } = oaFromDetails(details, context);
+
+ let cookieAttrs = {
+ host: details.domain,
+ path: path,
+ isSecure: secure,
+ };
+ if (!checkSetCookiePermissions(extension, uri, cookieAttrs)) {
+ return Promise.reject({
+ message: `Permission denied to set cookie ${JSON.stringify(
+ details
+ )}`,
+ });
+ }
+
+ let sameSite = SAME_SITE_STATUSES.indexOf(details.sameSite);
+
+ let schemeType = Ci.nsICookie.SCHEME_UNSET;
+ if (uri.scheme === "https") {
+ schemeType = Ci.nsICookie.SCHEME_HTTPS;
+ } else if (uri.scheme === "http") {
+ schemeType = Ci.nsICookie.SCHEME_HTTP;
+ } else if (uri.scheme === "file") {
+ schemeType = Ci.nsICookie.SCHEME_FILE;
+ }
+
+ // The permission check may have modified the domain, so use
+ // the new value instead.
+ Services.cookies.add(
+ cookieAttrs.host,
+ path,
+ name,
+ value,
+ secure,
+ httpOnly,
+ isSession,
+ expiry,
+ originAttributes,
+ sameSite,
+ schemeType
+ );
+
+ return self.cookies.get(details);
+ },
+
+ remove: function (details) {
+ validateFirstPartyDomain(details);
+
+ let allowed = ["url", "name"];
+ for (let { cookie, storeId } of query(details, allowed, context)) {
+ Services.cookies.remove(
+ cookie.host,
+ cookie.name,
+ cookie.path,
+ cookie.originAttributes
+ );
+
+ // TODO Bug 1387957: could there be multiple per subdomain?
+ return Promise.resolve({
+ url: details.url,
+ name: details.name,
+ storeId,
+ firstPartyDomain: cookie.originAttributes.firstPartyDomain,
+ partitionKey: toExtPartitionKey(
+ cookie.originAttributes.partitionKey
+ ),
+ });
+ }
+
+ return Promise.resolve(null);
+ },
+
+ getAllCookieStores: function () {
+ let data = {};
+ for (let tab of extension.tabManager.query()) {
+ if (!(tab.cookieStoreId in data)) {
+ data[tab.cookieStoreId] = [];
+ }
+ data[tab.cookieStoreId].push(tab.id);
+ }
+
+ let result = [];
+ for (let key in data) {
+ result.push({
+ id: key,
+ tabIds: data[key],
+ incognito: key == PRIVATE_STORE,
+ });
+ }
+ return Promise.resolve(result);
+ },
+
+ onChanged: new EventManager({
+ context,
+ module: "cookies",
+ event: "onChanged",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+
+ return self;
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-declarativeNetRequest.js b/toolkit/components/extensions/parent/ext-declarativeNetRequest.js
new file mode 100644
index 0000000000..766a43d98a
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-declarativeNetRequest.js
@@ -0,0 +1,169 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs",
+});
+
+var { ExtensionError } = ExtensionUtils;
+
+const PREF_DNR_FEEDBACK = "extensions.dnr.feedback";
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "dnrFeedbackEnabled",
+ PREF_DNR_FEEDBACK,
+ false
+);
+
+function ensureDNRFeedbackEnabled(apiName) {
+ if (!dnrFeedbackEnabled) {
+ throw new ExtensionError(
+ `${apiName} is only available when the "${PREF_DNR_FEEDBACK}" preference is set to true.`
+ );
+ }
+}
+
+this.declarativeNetRequest = class extends ExtensionAPI {
+ onManifestEntry(entryName) {
+ if (entryName === "declarative_net_request") {
+ ExtensionDNR.validateManifestEntry(this.extension);
+ }
+ }
+
+ onShutdown() {
+ ExtensionDNR.clearRuleManager(this.extension);
+ }
+
+ getAPI(context) {
+ const { extension } = this;
+
+ return {
+ declarativeNetRequest: {
+ updateDynamicRules({ removeRuleIds, addRules }) {
+ return ExtensionDNR.updateDynamicRules(extension, {
+ removeRuleIds,
+ addRules,
+ });
+ },
+
+ updateSessionRules({ removeRuleIds, addRules }) {
+ const ruleManager = ExtensionDNR.getRuleManager(extension);
+ let ruleValidator = new ExtensionDNR.RuleValidator(
+ ruleManager.getSessionRules(),
+ { isSessionRuleset: true }
+ );
+ if (removeRuleIds) {
+ ruleValidator.removeRuleIds(removeRuleIds);
+ }
+ if (addRules) {
+ ruleValidator.addRules(addRules);
+ }
+ let failures = ruleValidator.getFailures();
+ if (failures.length) {
+ throw new ExtensionError(failures[0].message);
+ }
+ let validatedRules = ruleValidator.getValidatedRules();
+ let ruleQuotaCounter = new ExtensionDNR.RuleQuotaCounter();
+ ruleQuotaCounter.tryAddRules("_session", validatedRules);
+ ruleManager.setSessionRules(validatedRules);
+ },
+
+ async getEnabledRulesets() {
+ await ExtensionDNR.ensureInitialized(extension);
+ const ruleManager = ExtensionDNR.getRuleManager(extension);
+ return ruleManager.enabledStaticRulesetIds;
+ },
+
+ async getAvailableStaticRuleCount() {
+ await ExtensionDNR.ensureInitialized(extension);
+ const ruleManager = ExtensionDNR.getRuleManager(extension);
+ return ruleManager.availableStaticRuleCount;
+ },
+
+ updateEnabledRulesets({ disableRulesetIds, enableRulesetIds }) {
+ return ExtensionDNR.updateEnabledStaticRulesets(extension, {
+ disableRulesetIds,
+ enableRulesetIds,
+ });
+ },
+
+ async getDynamicRules() {
+ await ExtensionDNR.ensureInitialized(extension);
+ return ExtensionDNR.getRuleManager(extension).getDynamicRules();
+ },
+
+ getSessionRules() {
+ // ruleManager.getSessionRules() returns an array of Rule instances.
+ // When these are structurally cloned (to send them to the child),
+ // the enumerable public fields of the class instances are copied to
+ // plain objects, as desired.
+ return ExtensionDNR.getRuleManager(extension).getSessionRules();
+ },
+
+ isRegexSupported(regexOptions) {
+ const {
+ regex: regexFilter,
+ isCaseSensitive: isUrlFilterCaseSensitive,
+ // requireCapturing: is ignored, as it does not affect validation.
+ } = regexOptions;
+
+ let ruleValidator = new ExtensionDNR.RuleValidator([]);
+ ruleValidator.addRules([
+ {
+ id: 1,
+ condition: { regexFilter, isUrlFilterCaseSensitive },
+ action: { type: "allow" },
+ },
+ ]);
+ let failures = ruleValidator.getFailures();
+ if (failures.length) {
+ // While the UnsupportedRegexReason enum has more entries than just
+ // "syntaxError" (e.g. also "memoryLimitExceeded"), our validation
+ // is currently very permissive, and therefore the only
+ // distinguishable error is "syntaxError".
+ return { isSupported: false, reason: "syntaxError" };
+ }
+ return { isSupported: true };
+ },
+
+ async testMatchOutcome(request, options) {
+ ensureDNRFeedbackEnabled("declarativeNetRequest.testMatchOutcome");
+ let { url, initiator, ...req } = request;
+ req.requestURI = Services.io.newURI(url);
+ if (initiator) {
+ req.initiatorURI = Services.io.newURI(initiator);
+ if (req.initiatorURI.schemeIs("data")) {
+ // data:-URIs are always opaque, i.e. a null principal. We should
+ // therefore ignore them here.
+ // ExtensionDNR's NetworkIntegration.startDNREvaluation does not
+ // encounter data:-URIs because opaque principals are mapped to a
+ // null initiatorURI. For consistency, we do the same here.
+ req.initiatorURI = null;
+ }
+ }
+ const matchedRules = ExtensionDNR.getMatchedRulesForRequest(
+ req,
+ options?.includeOtherExtensions ? null : extension
+ ).map(matchedRule => {
+ // Converts an internal MatchedRule instance to an object described
+ // by the "MatchedRule" type in declarative_net_request.json.
+ const result = {
+ ruleId: matchedRule.rule.id,
+ rulesetId: matchedRule.ruleset.id,
+ };
+ if (matchedRule.ruleManager.extension !== extension) {
+ result.extensionId = matchedRule.ruleManager.extension.id;
+ }
+ return result;
+ });
+ return { matchedRules };
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-dns.js b/toolkit/components/extensions/parent/ext-dns.js
new file mode 100644
index 0000000000..f32243c032
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-dns.js
@@ -0,0 +1,87 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const dnssFlags = {
+ allow_name_collisions: Ci.nsIDNSService.RESOLVE_ALLOW_NAME_COLLISION,
+ bypass_cache: Ci.nsIDNSService.RESOLVE_BYPASS_CACHE,
+ canonical_name: Ci.nsIDNSService.RESOLVE_CANONICAL_NAME,
+ disable_ipv4: Ci.nsIDNSService.RESOLVE_DISABLE_IPV4,
+ disable_ipv6: Ci.nsIDNSService.RESOLVE_DISABLE_IPV6,
+ disable_trr: Ci.nsIDNSService.RESOLVE_DISABLE_TRR,
+ offline: Ci.nsIDNSService.RESOLVE_OFFLINE,
+ priority_low: Ci.nsIDNSService.RESOLVE_PRIORITY_LOW,
+ priority_medium: Ci.nsIDNSService.RESOLVE_PRIORITY_MEDIUM,
+ speculate: Ci.nsIDNSService.RESOLVE_SPECULATE,
+};
+
+function getErrorString(nsresult) {
+ let e = new Components.Exception("", nsresult);
+ return e.name;
+}
+
+this.dns = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ dns: {
+ resolve: function (hostname, flags) {
+ let dnsFlags = flags.reduce(
+ (mask, flag) => mask | dnssFlags[flag],
+ 0
+ );
+
+ return new Promise((resolve, reject) => {
+ let request;
+ let response = {
+ addresses: [],
+ };
+ let listener = {
+ onLookupComplete: function (inRequest, inRecord, inStatus) {
+ if (inRequest === request) {
+ if (!Components.isSuccessCode(inStatus)) {
+ return reject({ message: getErrorString(inStatus) });
+ }
+ inRecord.QueryInterface(Ci.nsIDNSAddrRecord);
+ if (dnsFlags & Ci.nsIDNSService.RESOLVE_CANONICAL_NAME) {
+ try {
+ response.canonicalName = inRecord.canonicalName;
+ } catch (e) {
+ // no canonicalName
+ }
+ }
+ response.isTRR = inRecord.IsTRR();
+ while (inRecord.hasMore()) {
+ let addr = inRecord.getNextAddrAsString();
+ // Sometimes there are duplicate records with the same ip.
+ if (!response.addresses.includes(addr)) {
+ response.addresses.push(addr);
+ }
+ }
+ return resolve(response);
+ }
+ },
+ };
+ try {
+ request = Services.dns.asyncResolve(
+ hostname,
+ Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
+ dnsFlags,
+ null, // AdditionalInfo
+ listener,
+ null,
+ {} /* defaultOriginAttributes */
+ );
+ } catch (e) {
+ // handle exceptions such as offline mode.
+ return reject({ message: e.name });
+ }
+ });
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-downloads.js b/toolkit/components/extensions/parent/ext-downloads.js
new file mode 100644
index 0000000000..9cd96e0d65
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-downloads.js
@@ -0,0 +1,1261 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadLastDir: "resource://gre/modules/DownloadLastDir.sys.mjs",
+ DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+var { EventEmitter, ignoreEvent } = ExtensionCommon;
+var { ExtensionError } = ExtensionUtils;
+
+const DOWNLOAD_ITEM_FIELDS = [
+ "id",
+ "url",
+ "referrer",
+ "filename",
+ "incognito",
+ "cookieStoreId",
+ "danger",
+ "mime",
+ "startTime",
+ "endTime",
+ "estimatedEndTime",
+ "state",
+ "paused",
+ "canResume",
+ "error",
+ "bytesReceived",
+ "totalBytes",
+ "fileSize",
+ "exists",
+ "byExtensionId",
+ "byExtensionName",
+];
+
+const DOWNLOAD_DATE_FIELDS = ["startTime", "endTime", "estimatedEndTime"];
+
+// Fields that we generate onChanged events for.
+const DOWNLOAD_ITEM_CHANGE_FIELDS = [
+ "endTime",
+ "state",
+ "paused",
+ "canResume",
+ "error",
+ "exists",
+];
+
+// From https://fetch.spec.whatwg.org/#forbidden-header-name
+// Since bug 1367626 we allow extensions to set REFERER.
+const FORBIDDEN_HEADERS = [
+ "ACCEPT-CHARSET",
+ "ACCEPT-ENCODING",
+ "ACCESS-CONTROL-REQUEST-HEADERS",
+ "ACCESS-CONTROL-REQUEST-METHOD",
+ "CONNECTION",
+ "CONTENT-LENGTH",
+ "COOKIE",
+ "COOKIE2",
+ "DATE",
+ "DNT",
+ "EXPECT",
+ "HOST",
+ "KEEP-ALIVE",
+ "ORIGIN",
+ "TE",
+ "TRAILER",
+ "TRANSFER-ENCODING",
+ "UPGRADE",
+ "VIA",
+];
+
+const FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i;
+
+const PROMPTLESS_DOWNLOAD_PREF = "browser.download.useDownloadDir";
+
+// Lists of file extensions for each file picker filter taken from filepicker.properties
+const FILTER_HTML_EXTENSIONS = ["html", "htm", "shtml", "xhtml"];
+
+const FILTER_TEXT_EXTENSIONS = ["txt", "text"];
+
+const FILTER_IMAGES_EXTENSIONS = [
+ "jpe",
+ "jpg",
+ "jpeg",
+ "gif",
+ "png",
+ "bmp",
+ "ico",
+ "svg",
+ "svgz",
+ "tif",
+ "tiff",
+ "ai",
+ "drw",
+ "pct",
+ "psp",
+ "xcf",
+ "psd",
+ "raw",
+ "webp",
+ "heic",
+];
+
+const FILTER_XML_EXTENSIONS = ["xml"];
+
+const FILTER_AUDIO_EXTENSIONS = [
+ "aac",
+ "aif",
+ "flac",
+ "iff",
+ "m4a",
+ "m4b",
+ "mid",
+ "midi",
+ "mp3",
+ "mpa",
+ "mpc",
+ "oga",
+ "ogg",
+ "ra",
+ "ram",
+ "snd",
+ "wav",
+ "wma",
+];
+
+const FILTER_VIDEO_EXTENSIONS = [
+ "avi",
+ "divx",
+ "flv",
+ "m4v",
+ "mkv",
+ "mov",
+ "mp4",
+ "mpeg",
+ "mpg",
+ "ogm",
+ "ogv",
+ "ogx",
+ "rm",
+ "rmvb",
+ "smil",
+ "webm",
+ "wmv",
+ "xvid",
+];
+
+class DownloadItem {
+ constructor(id, download, extension) {
+ this.id = id;
+ this.download = download;
+ this.extension = extension;
+ this.prechange = {};
+ this._error = null;
+ }
+
+ get url() {
+ return this.download.source.url;
+ }
+
+ get referrer() {
+ const uri = this.download.source.referrerInfo?.originalReferrer;
+
+ return uri?.spec;
+ }
+
+ get filename() {
+ return this.download.target.path;
+ }
+
+ get incognito() {
+ return this.download.source.isPrivate;
+ }
+
+ get cookieStoreId() {
+ if (this.download.source.isPrivate) {
+ return PRIVATE_STORE;
+ }
+ if (this.download.source.userContextId) {
+ return getCookieStoreIdForContainer(this.download.source.userContextId);
+ }
+ return DEFAULT_STORE;
+ }
+
+ get danger() {
+ // TODO
+ return "safe";
+ }
+
+ get mime() {
+ return this.download.contentType;
+ }
+
+ get startTime() {
+ return this.download.startTime;
+ }
+
+ get endTime() {
+ // TODO bug 1256269: implement endTime.
+ return null;
+ }
+
+ get estimatedEndTime() {
+ // Based on the code in summarizeDownloads() in DownloadsCommon.sys.mjs
+ if (this.download.hasProgress && this.download.speed > 0) {
+ let sizeLeft = this.download.totalBytes - this.download.currentBytes;
+ let timeLeftInSeconds = sizeLeft / this.download.speed;
+ return new Date(Date.now() + timeLeftInSeconds * 1000);
+ }
+ }
+
+ get state() {
+ if (this.download.succeeded) {
+ return "complete";
+ }
+ if (this.download.canceled || this.error) {
+ return "interrupted";
+ }
+ return "in_progress";
+ }
+
+ get paused() {
+ return (
+ this.download.canceled &&
+ this.download.hasPartialData &&
+ !this.download.error
+ );
+ }
+
+ get canResume() {
+ return (
+ (this.download.stopped || this.download.canceled) &&
+ this.download.hasPartialData &&
+ !this.download.error
+ );
+ }
+
+ get error() {
+ if (this._error) {
+ return this._error;
+ }
+ if (
+ !this.download.startTime ||
+ !this.download.stopped ||
+ this.download.succeeded
+ ) {
+ return null;
+ }
+
+ // TODO store this instead of calculating it
+ if (this.download.error) {
+ if (this.download.error.becauseSourceFailed) {
+ return "NETWORK_FAILED"; // TODO
+ }
+ if (this.download.error.becauseTargetFailed) {
+ return "FILE_FAILED"; // TODO
+ }
+ return "CRASH";
+ }
+ return "USER_CANCELED";
+ }
+
+ set error(value) {
+ this._error = value && value.toString();
+ }
+
+ get bytesReceived() {
+ return this.download.currentBytes;
+ }
+
+ get totalBytes() {
+ return this.download.hasProgress ? this.download.totalBytes : -1;
+ }
+
+ get fileSize() {
+ // todo: this is supposed to be post-compression
+ return this.download.succeeded ? this.download.target.size : -1;
+ }
+
+ get exists() {
+ return this.download.target.exists;
+ }
+
+ get byExtensionId() {
+ return this.extension?.id;
+ }
+
+ get byExtensionName() {
+ return this.extension?.name;
+ }
+
+ /**
+ * Create a cloneable version of this object by pulling all the
+ * fields into simple properties (instead of getters).
+ *
+ * @returns {object} A DownloadItem with flat properties,
+ * suitable for cloning.
+ */
+ serialize() {
+ let obj = {};
+ for (let field of DOWNLOAD_ITEM_FIELDS) {
+ obj[field] = this[field];
+ }
+ for (let field of DOWNLOAD_DATE_FIELDS) {
+ if (obj[field]) {
+ obj[field] = obj[field].toISOString();
+ }
+ }
+ return obj;
+ }
+
+ // When a change event fires, handlers can look at how an individual
+ // field changed by comparing item.fieldname with item.prechange.fieldname.
+ // After all handlers have been invoked, this gets called to store the
+ // current values of all fields ahead of the next event.
+ _storePrechange() {
+ for (let field of DOWNLOAD_ITEM_CHANGE_FIELDS) {
+ this.prechange[field] = this[field];
+ }
+ }
+}
+
+// DownloadMap maps back and forth between the numeric identifiers used in
+// the downloads WebExtension API and a Download object from the Downloads sys.mjs.
+// TODO Bug 1247794: make id and extension info persistent
+const DownloadMap = new (class extends EventEmitter {
+ constructor() {
+ super();
+
+ this.currentId = 0;
+ this.loadPromise = null;
+
+ // Maps numeric id -> DownloadItem
+ this.byId = new Map();
+
+ // Maps Download object -> DownloadItem
+ this.byDownload = new WeakMap();
+ }
+
+ lazyInit() {
+ if (!this.loadPromise) {
+ this.loadPromise = (async () => {
+ const list = await Downloads.getList(Downloads.ALL);
+
+ await list.addView({
+ onDownloadAdded: download => {
+ const item = this.newFromDownload(download, null);
+ this.emit("create", item);
+ item._storePrechange();
+ },
+ onDownloadRemoved: download => {
+ const item = this.byDownload.get(download);
+ if (item) {
+ this.emit("erase", item);
+ this.byDownload.delete(download);
+ this.byId.delete(item.id);
+ }
+ },
+ onDownloadChanged: download => {
+ const item = this.byDownload.get(download);
+ if (item) {
+ this.emit("change", item);
+ item._storePrechange();
+ } else {
+ Cu.reportError(
+ "Got onDownloadChanged for unknown download object"
+ );
+ }
+ },
+ });
+
+ const downloads = await list.getAll();
+
+ for (let download of downloads) {
+ this.newFromDownload(download, null);
+ }
+
+ return list;
+ })();
+ }
+
+ return this.loadPromise;
+ }
+
+ getDownloadList() {
+ return this.lazyInit();
+ }
+
+ async getAll() {
+ await this.lazyInit();
+ return this.byId.values();
+ }
+
+ fromId(id, privateAllowed = true) {
+ const download = this.byId.get(id);
+ if (!download || (!privateAllowed && download.incognito)) {
+ throw new ExtensionError(`Invalid download id ${id}`);
+ }
+ return download;
+ }
+
+ newFromDownload(download, extension) {
+ if (this.byDownload.has(download)) {
+ return this.byDownload.get(download);
+ }
+
+ const id = ++this.currentId;
+ let item = new DownloadItem(id, download, extension);
+ this.byId.set(id, item);
+ this.byDownload.set(download, item);
+ return item;
+ }
+
+ async erase(item) {
+ // TODO Bug 1255507: for now we only work with downloads in the DownloadList
+ // from getAll()
+ const list = await this.getDownloadList();
+ list.remove(item.download);
+ }
+})();
+
+// Create a callable function that filters a DownloadItem based on a
+// query object of the type passed to search() or erase().
+const downloadQuery = query => {
+ let queryTerms = [];
+ let queryNegativeTerms = [];
+ if (query.query != null) {
+ for (let term of query.query) {
+ if (term[0] == "-") {
+ queryNegativeTerms.push(term.slice(1).toLowerCase());
+ } else {
+ queryTerms.push(term.toLowerCase());
+ }
+ }
+ }
+
+ function normalizeDownloadTime(arg, before) {
+ if (arg == null) {
+ return before ? Number.MAX_VALUE : 0;
+ }
+ return ExtensionCommon.normalizeTime(arg).getTime();
+ }
+
+ const startedBefore = normalizeDownloadTime(query.startedBefore, true);
+ const startedAfter = normalizeDownloadTime(query.startedAfter, false);
+
+ // TODO bug 1727510: Implement endedBefore/endedAfter
+ // const endedBefore = normalizeDownloadTime(query.endedBefore, true);
+ // const endedAfter = normalizeDownloadTime(query.endedAfter, false);
+
+ const totalBytesGreater = query.totalBytesGreater ?? -1;
+ const totalBytesLess = query.totalBytesLess ?? Number.MAX_VALUE;
+
+ // Handle options for which we can have a regular expression and/or
+ // an explicit value to match.
+ function makeMatch(regex, value, field) {
+ if (value == null && regex == null) {
+ return input => true;
+ }
+
+ let re;
+ try {
+ re = new RegExp(regex || "", "i");
+ } catch (err) {
+ throw new ExtensionError(`Invalid ${field}Regex: ${err.message}`);
+ }
+ if (value == null) {
+ return input => re.test(input);
+ }
+
+ value = value.toLowerCase();
+ if (re.test(value)) {
+ return input => value == input;
+ }
+ return input => false;
+ }
+
+ const matchFilename = makeMatch(
+ query.filenameRegex,
+ query.filename,
+ "filename"
+ );
+ const matchUrl = makeMatch(query.urlRegex, query.url, "url");
+
+ return function (item) {
+ const url = item.url.toLowerCase();
+ const filename = item.filename.toLowerCase();
+
+ if (
+ !queryTerms.every(term => url.includes(term) || filename.includes(term))
+ ) {
+ return false;
+ }
+
+ if (
+ queryNegativeTerms.some(
+ term => url.includes(term) || filename.includes(term)
+ )
+ ) {
+ return false;
+ }
+
+ if (!matchFilename(filename) || !matchUrl(url)) {
+ return false;
+ }
+
+ if (!item.startTime) {
+ if (query.startedBefore != null || query.startedAfter != null) {
+ return false;
+ }
+ } else if (
+ item.startTime > startedBefore ||
+ item.startTime < startedAfter
+ ) {
+ return false;
+ }
+
+ // todo endedBefore, endedAfter
+
+ if (item.totalBytes == -1) {
+ if (query.totalBytesGreater !== null || query.totalBytesLess !== null) {
+ return false;
+ }
+ } else if (
+ item.totalBytes <= totalBytesGreater ||
+ item.totalBytes >= totalBytesLess
+ ) {
+ return false;
+ }
+
+ // todo: include danger
+ const SIMPLE_ITEMS = [
+ "id",
+ "mime",
+ "startTime",
+ "endTime",
+ "state",
+ "paused",
+ "error",
+ "incognito",
+ "cookieStoreId",
+ "bytesReceived",
+ "totalBytes",
+ "fileSize",
+ "exists",
+ ];
+ for (let field of SIMPLE_ITEMS) {
+ if (query[field] != null && item[field] != query[field]) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+};
+
+const queryHelper = async query => {
+ let matchFn = downloadQuery(query);
+ let compareFn;
+
+ if (query.orderBy) {
+ const fields = query.orderBy.map(field =>
+ field[0] == "-"
+ ? { reverse: true, name: field.slice(1) }
+ : { reverse: false, name: field }
+ );
+
+ for (let field of fields) {
+ if (!DOWNLOAD_ITEM_FIELDS.includes(field.name)) {
+ throw new ExtensionError(`Invalid orderBy field ${field.name}`);
+ }
+ }
+
+ compareFn = (dl1, dl2) => {
+ for (let field of fields) {
+ const val1 = dl1[field.name];
+ const val2 = dl2[field.name];
+
+ if (val1 < val2) {
+ return field.reverse ? 1 : -1;
+ } else if (val1 > val2) {
+ return field.reverse ? -1 : 1;
+ }
+ }
+ return 0;
+ };
+ }
+
+ let downloads = await DownloadMap.getAll();
+
+ if (compareFn) {
+ downloads = Array.from(downloads);
+ downloads.sort(compareFn);
+ }
+
+ let results = [];
+ for (let download of downloads) {
+ if (query.limit && results.length >= query.limit) {
+ break;
+ }
+ if (matchFn(download)) {
+ results.push(download);
+ }
+ }
+ return results;
+};
+
+this.downloads = class extends ExtensionAPIPersistent {
+ downloadEventRegistrar(event, listener) {
+ let { extension } = this;
+ return ({ fire }) => {
+ const handler = (what, item) => {
+ if (extension.privateBrowsingAllowed || !item.incognito) {
+ listener(fire, what, item);
+ }
+ };
+ let registerPromise = DownloadMap.getDownloadList().then(() => {
+ DownloadMap.on(event, handler);
+ });
+ return {
+ unregister() {
+ registerPromise.then(() => {
+ DownloadMap.off(event, handler);
+ });
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ onChanged: this.downloadEventRegistrar("change", (fire, what, item) => {
+ let changes = {};
+ const noundef = val => (val === undefined ? null : val);
+ DOWNLOAD_ITEM_CHANGE_FIELDS.forEach(fld => {
+ if (item[fld] != item.prechange[fld]) {
+ changes[fld] = {
+ previous: noundef(item.prechange[fld]),
+ current: noundef(item[fld]),
+ };
+ }
+ });
+ if (Object.keys(changes).length) {
+ changes.id = item.id;
+ fire.async(changes);
+ }
+ }),
+
+ onCreated: this.downloadEventRegistrar("create", (fire, what, item) => {
+ fire.async(item.serialize());
+ }),
+
+ onErased: this.downloadEventRegistrar("erase", (fire, what, item) => {
+ fire.async(item.id);
+ }),
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ return {
+ downloads: {
+ async download(options) {
+ const isHandlingUserInput =
+ context.callContextData?.isHandlingUserInput;
+ let { filename } = options;
+ if (filename && AppConstants.platform === "win") {
+ // cross platform javascript code uses "/"
+ filename = filename.replace(/\//g, "\\");
+ }
+
+ if (filename != null) {
+ if (!filename.length) {
+ throw new ExtensionError("filename must not be empty");
+ }
+
+ if (PathUtils.isAbsolute(filename)) {
+ throw new ExtensionError("filename must not be an absolute path");
+ }
+
+ const pathComponents = PathUtils.splitRelative(filename, {
+ allowEmpty: true,
+ allowCurrentDir: true,
+ allowParentDir: true,
+ });
+
+ if (pathComponents.some(component => component == "..")) {
+ throw new ExtensionError(
+ "filename must not contain back-references (..)"
+ );
+ }
+
+ if (
+ pathComponents.some(component => {
+ let sanitized = DownloadPaths.sanitize(component, {
+ compressWhitespaces: false,
+ });
+ return component != sanitized;
+ })
+ ) {
+ throw new ExtensionError(
+ "filename must not contain illegal characters"
+ );
+ }
+ }
+
+ if (options.incognito && !context.privateBrowsingAllowed) {
+ throw new ExtensionError("private browsing access not allowed");
+ }
+
+ if (options.conflictAction == "prompt") {
+ // TODO
+ throw new ExtensionError(
+ "conflictAction prompt not yet implemented"
+ );
+ }
+
+ if (options.headers) {
+ for (let { name } of options.headers) {
+ if (
+ FORBIDDEN_HEADERS.includes(name.toUpperCase()) ||
+ name.match(FORBIDDEN_PREFIXES)
+ ) {
+ throw new ExtensionError("Forbidden request header name");
+ }
+ }
+ }
+
+ let userContextId = null;
+ if (options.cookieStoreId != null) {
+ userContextId = getUserContextIdForCookieStoreId(
+ extension,
+ options.cookieStoreId,
+ options.incognito
+ );
+ }
+
+ // Handle method, headers and body options.
+ function adjustChannel(channel) {
+ if (channel instanceof Ci.nsIHttpChannel) {
+ const method = options.method || "GET";
+ channel.requestMethod = method;
+
+ if (options.headers) {
+ for (let { name, value } of options.headers) {
+ if (name.toLowerCase() == "referer") {
+ // The referer header and referrerInfo object should always
+ // match. So if we want to set the header from privileged
+ // context, we should set referrerInfo. The referrer header
+ // will get set internally.
+ channel.setNewReferrerInfo(
+ value,
+ Ci.nsIReferrerInfo.UNSAFE_URL,
+ true
+ );
+ } else {
+ channel.setRequestHeader(name, value, false);
+ }
+ }
+ }
+
+ if (options.body != null) {
+ const stream = Cc[
+ "@mozilla.org/io/string-input-stream;1"
+ ].createInstance(Ci.nsIStringInputStream);
+ stream.setData(options.body, options.body.length);
+
+ channel.QueryInterface(Ci.nsIUploadChannel2);
+ channel.explicitSetUploadStream(
+ stream,
+ null,
+ -1,
+ method,
+ false
+ );
+ }
+ }
+ return Promise.resolve();
+ }
+
+ function allowHttpStatus(download, status) {
+ const item = DownloadMap.byDownload.get(download);
+ if (item === null) {
+ return true;
+ }
+
+ let error = null;
+ switch (status) {
+ case 204: // No Content
+ case 205: // Reset Content
+ case 404: // Not Found
+ error = "SERVER_BAD_CONTENT";
+ break;
+
+ case 403: // Forbidden
+ error = "SERVER_FORBIDDEN";
+ break;
+
+ case 402: // Unauthorized
+ case 407: // Proxy authentication required
+ error = "SERVER_UNAUTHORIZED";
+ break;
+
+ default:
+ if (status >= 400) {
+ error = "SERVER_FAILED";
+ }
+ break;
+ }
+
+ if (error) {
+ item.error = error;
+ return false;
+ }
+
+ // No error, ergo allow the request.
+ return true;
+ }
+
+ async function createTarget(downloadsDir) {
+ if (!filename) {
+ let uri = Services.io.newURI(options.url);
+ if (uri instanceof Ci.nsIURL) {
+ filename = DownloadPaths.sanitize(
+ Services.textToSubURI.unEscapeURIForUI(
+ uri.fileName,
+ /* dontEscape = */ true
+ )
+ );
+ }
+ }
+
+ let target = PathUtils.joinRelative(
+ downloadsDir,
+ filename || "download"
+ );
+
+ let saveAs;
+ if (options.saveAs !== null) {
+ saveAs = options.saveAs;
+ } else {
+ // If options.saveAs was not specified, only show the file chooser
+ // if |browser.download.useDownloadDir == false|. That is to say,
+ // only show the file chooser if Firefox normally shows it when
+ // a file is downloaded.
+ saveAs = !Services.prefs.getBoolPref(
+ PROMPTLESS_DOWNLOAD_PREF,
+ true
+ );
+ }
+
+ // Create any needed subdirectories if required by filename.
+ const dir = PathUtils.parent(target);
+ await IOUtils.makeDirectory(dir);
+
+ if (await IOUtils.exists(target)) {
+ // This has a race, something else could come along and create
+ // the file between this test and them time the download code
+ // creates the target file. But we can't easily fix it without
+ // modifying DownloadCore so we live with it for now.
+ switch (options.conflictAction) {
+ case "uniquify":
+ default:
+ target = DownloadPaths.createNiceUniqueFile(
+ new FileUtils.File(target)
+ ).path;
+ if (saveAs) {
+ // createNiceUniqueFile actually creates the file, which
+ // is premature if we need to show a SaveAs dialog.
+ await IOUtils.remove(target);
+ }
+ break;
+
+ case "overwrite":
+ break;
+ }
+ }
+
+ if (!saveAs || AppConstants.platform === "android") {
+ return target;
+ }
+
+ if (!("windowTracker" in global)) {
+ return target;
+ }
+
+ // At this point we are committed to displaying the file picker.
+ const downloadLastDir = new DownloadLastDir(
+ null,
+ options.incognito
+ );
+
+ async function getLastDirectory() {
+ return downloadLastDir.getFileAsync(extension.baseURI);
+ }
+
+ function appendFilterForFileExtension(picker, ext) {
+ if (FILTER_HTML_EXTENSIONS.includes(ext)) {
+ picker.appendFilters(Ci.nsIFilePicker.filterHTML);
+ } else if (FILTER_TEXT_EXTENSIONS.includes(ext)) {
+ picker.appendFilters(Ci.nsIFilePicker.filterText);
+ } else if (FILTER_IMAGES_EXTENSIONS.includes(ext)) {
+ picker.appendFilters(Ci.nsIFilePicker.filterImages);
+ } else if (FILTER_XML_EXTENSIONS.includes(ext)) {
+ picker.appendFilters(Ci.nsIFilePicker.filterXML);
+ } else if (FILTER_AUDIO_EXTENSIONS.includes(ext)) {
+ picker.appendFilters(Ci.nsIFilePicker.filterAudio);
+ } else if (FILTER_VIDEO_EXTENSIONS.includes(ext)) {
+ picker.appendFilters(Ci.nsIFilePicker.filterVideo);
+ }
+ }
+
+ function saveLastDirectory(lastDir) {
+ downloadLastDir.setFile(extension.baseURI, lastDir);
+ }
+
+ // Use windowTracker to find a window, rather than Services.wm,
+ // so that this doesn't break where navigator:browser isn't the
+ // main window (e.g. Thunderbird).
+ const window = global.windowTracker.getTopWindow().window;
+ const basename = PathUtils.filename(target);
+ const ext = basename.match(/\.([^.]+)$/)?.[1];
+
+ // If the filename passed in by the extension is a simple name
+ // and not a path, we open the file picker so it displays the
+ // last directory that was chosen by the user.
+ const pathSep = AppConstants.platform === "win" ? "\\" : "/";
+ const lastFilePickerDirectory =
+ !filename || !filename.includes(pathSep)
+ ? await getLastDirectory()
+ : undefined;
+
+ // Setup the file picker Save As dialog.
+ const picker = Cc["@mozilla.org/filepicker;1"].createInstance(
+ Ci.nsIFilePicker
+ );
+ picker.init(window, null, Ci.nsIFilePicker.modeSave);
+ if (lastFilePickerDirectory) {
+ picker.displayDirectory = lastFilePickerDirectory;
+ } else {
+ picker.displayDirectory = new FileUtils.File(dir);
+ }
+ picker.defaultString = basename;
+ if (ext) {
+ // Configure a default file extension, used as fallback on Windows.
+ picker.defaultExtension = ext;
+ appendFilterForFileExtension(picker, ext);
+ }
+ picker.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ // Open the dialog and resolve/reject with the result.
+ return new Promise((resolve, reject) => {
+ picker.open(result => {
+ if (result === Ci.nsIFilePicker.returnCancel) {
+ reject({ message: "Download canceled by the user" });
+ } else {
+ saveLastDirectory(picker.file.parent);
+ resolve(picker.file.path);
+ }
+ });
+ });
+ }
+
+ const downloadsDir = await Downloads.getPreferredDownloadsDirectory();
+ const target = await createTarget(downloadsDir);
+ const uri = Services.io.newURI(options.url);
+ const cookieJarSettings = Cc[
+ "@mozilla.org/cookieJarSettings;1"
+ ].createInstance(Ci.nsICookieJarSettings);
+ cookieJarSettings.initWithURI(uri, options.incognito);
+
+ const source = {
+ url: options.url,
+ isPrivate: options.incognito,
+ // Use the extension's principal to allow extensions to observe
+ // their own downloads via the webRequest API.
+ loadingPrincipal: context.principal,
+ cookieJarSettings,
+ };
+
+ if (userContextId) {
+ source.userContextId = userContextId;
+ }
+
+ // blob:-URLs can only be loaded by the principal with which they
+ // are associated. This principal may have origin attributes.
+ // `context.principal` does sometimes not have these attributes
+ // due to bug 1653681. If `context.principal` were to be passed,
+ // the download request would be rejected because of mismatching
+ // principals (origin attributes).
+ // TODO bug 1653681: fix context.principal and remove this.
+ if (options.url.startsWith("blob:")) {
+ // To make sure that the blob:-URL can be loaded, fall back to
+ // the default (system) principal instead.
+ delete source.loadingPrincipal;
+ }
+
+ // Unless the API user explicitly wants errors ignored,
+ // set the allowHttpStatus callback, which will instruct
+ // DownloadCore to cancel downloads on HTTP errors.
+ if (!options.allowHttpErrors) {
+ source.allowHttpStatus = allowHttpStatus;
+ }
+
+ if (options.method || options.headers || options.body) {
+ source.adjustChannel = adjustChannel;
+ }
+
+ const download = await Downloads.createDownload({
+ // Only open the download panel if the method has been called
+ // while handling user input (See Bug 1759231).
+ openDownloadsListOnStart: isHandlingUserInput,
+ source,
+ target: {
+ path: target,
+ partFilePath: `${target}.part`,
+ },
+ });
+
+ const list = await DownloadMap.getDownloadList();
+ const item = DownloadMap.newFromDownload(download, extension);
+ list.add(download);
+
+ // This is necessary to make pause/resume work.
+ download.tryToKeepPartialData = true;
+
+ // Do not handle errors.
+ // Extensions will use listeners to be informed about errors.
+ // Just ignore any errors from |start()| to avoid spamming the
+ // error console.
+ download.start().catch(err => {
+ if (err.name !== "DownloadError") {
+ Cu.reportError(err);
+ }
+ });
+
+ return item.id;
+ },
+
+ async removeFile(id) {
+ await DownloadMap.lazyInit();
+
+ let item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
+
+ if (item.state !== "complete") {
+ throw new ExtensionError(
+ `Cannot remove incomplete download id ${id}`
+ );
+ }
+
+ try {
+ await IOUtils.remove(item.filename, { ignoreAbsent: false });
+ } catch (err) {
+ if (DOMException.isInstance(err) && err.name === "NotFoundError") {
+ throw new ExtensionError(
+ `Could not remove download id ${item.id} because the file doesn't exist`
+ );
+ }
+
+ // Unexpected other error. Throw the original error, so that it
+ // can bubble up to the global browser console, but keep it
+ // sanitized (i.e. not wrapped in ExtensionError) to avoid
+ // inadvertent disclosure of potentially sensitive information.
+ throw err;
+ }
+ },
+
+ async search(query) {
+ if (!context.privateBrowsingAllowed) {
+ query.incognito = false;
+ }
+
+ const items = await queryHelper(query);
+ return items.map(item => item.serialize());
+ },
+
+ async pause(id) {
+ await DownloadMap.lazyInit();
+
+ let item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
+
+ if (item.state !== "in_progress") {
+ throw new ExtensionError(
+ `Download ${id} cannot be paused since it is in state ${item.state}`
+ );
+ }
+
+ return item.download.cancel();
+ },
+
+ async resume(id) {
+ await DownloadMap.lazyInit();
+
+ let item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
+
+ if (!item.canResume) {
+ throw new ExtensionError(`Download ${id} cannot be resumed`);
+ }
+
+ item.error = null;
+ return item.download.start();
+ },
+
+ async cancel(id) {
+ await DownloadMap.lazyInit();
+
+ let item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
+
+ if (item.download.succeeded) {
+ throw new ExtensionError(`Download ${id} is already complete`);
+ }
+
+ return item.download.finalize(true);
+ },
+
+ showDefaultFolder() {
+ Downloads.getPreferredDownloadsDirectory()
+ .then(dir => {
+ let dirobj = new FileUtils.File(dir);
+ if (dirobj.isDirectory()) {
+ dirobj.launch();
+ } else {
+ throw new Error(
+ `Download directory ${dirobj.path} is not actually a directory`
+ );
+ }
+ })
+ .catch(Cu.reportError);
+ },
+
+ async erase(query) {
+ if (!context.privateBrowsingAllowed) {
+ query.incognito = false;
+ }
+
+ const items = await queryHelper(query);
+ let results = [];
+ let promises = [];
+
+ for (let item of items) {
+ promises.push(DownloadMap.erase(item));
+ results.push(item.id);
+ }
+
+ await Promise.all(promises);
+ return results;
+ },
+
+ async open(downloadId) {
+ await DownloadMap.lazyInit();
+
+ let { download } = DownloadMap.fromId(
+ downloadId,
+ context.privateBrowsingAllowed
+ );
+
+ if (!download.succeeded) {
+ throw new ExtensionError("Download has not completed.");
+ }
+
+ return download.launch();
+ },
+
+ async show(downloadId) {
+ await DownloadMap.lazyInit();
+
+ const { download } = DownloadMap.fromId(
+ downloadId,
+ context.privateBrowsingAllowed
+ );
+
+ await download.showContainingDirectory();
+
+ return true;
+ },
+
+ async getFileIcon(downloadId, options) {
+ await DownloadMap.lazyInit();
+
+ const size = options?.size || 32;
+ const { download } = DownloadMap.fromId(
+ downloadId,
+ context.privateBrowsingAllowed
+ );
+
+ let pathPrefix = "";
+ let path;
+
+ if (download.succeeded) {
+ let file = FileUtils.File(download.target.path);
+ path = Services.io.newFileURI(file).spec;
+ } else {
+ path = PathUtils.filename(download.target.path);
+ pathPrefix = "//";
+ }
+
+ let windowlessBrowser =
+ Services.appShell.createWindowlessBrowser(true);
+ let systemPrincipal =
+ Services.scriptSecurityManager.getSystemPrincipal();
+ windowlessBrowser.docShell.createAboutBlankDocumentViewer(
+ systemPrincipal,
+ systemPrincipal
+ );
+
+ let canvas = windowlessBrowser.document.createElement("canvas");
+ let img = new windowlessBrowser.docShell.domWindow.Image(size, size);
+
+ canvas.width = size;
+ canvas.height = size;
+
+ img.src = `moz-icon:${pathPrefix}${path}?size=${size}`;
+
+ try {
+ await img.decode();
+
+ canvas.getContext("2d").drawImage(img, 0, 0, size, size);
+
+ let dataURL = canvas.toDataURL("image/png");
+
+ return dataURL;
+ } finally {
+ windowlessBrowser.close();
+ }
+ },
+
+ onChanged: new EventManager({
+ context,
+ module: "downloads",
+ event: "onChanged",
+ extensionApi: this,
+ }).api(),
+
+ onCreated: new EventManager({
+ context,
+ module: "downloads",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+
+ onErased: new EventManager({
+ context,
+ module: "downloads",
+ event: "onErased",
+ extensionApi: this,
+ }).api(),
+
+ onDeterminingFilename: ignoreEvent(
+ context,
+ "downloads.onDeterminingFilename"
+ ),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-extension.js b/toolkit/components/extensions/parent/ext-extension.js
new file mode 100644
index 0000000000..2f0a168dd4
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-extension.js
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.extension = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ extension: {
+ get lastError() {
+ return context.lastError;
+ },
+
+ isAllowedIncognitoAccess() {
+ return context.privateBrowsingAllowed;
+ },
+
+ isAllowedFileSchemeAccess() {
+ return false;
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-geckoProfiler.js b/toolkit/components/extensions/parent/ext-geckoProfiler.js
new file mode 100644
index 0000000000..91f2e6e594
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-geckoProfiler.js
@@ -0,0 +1,191 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const PREF_ASYNC_STACK = "javascript.options.asyncstack";
+
+const ASYNC_STACKS_ENABLED = Services.prefs.getBoolPref(
+ PREF_ASYNC_STACK,
+ false
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+ChromeUtils.defineLazyGetter(this, "symbolicationService", () => {
+ let { createLocalSymbolicationService } = ChromeUtils.importESModule(
+ "resource://devtools/client/performance-new/shared/symbolication.sys.mjs"
+ );
+ return createLocalSymbolicationService(Services.profiler.sharedLibraries, []);
+});
+
+const isRunningObserver = {
+ _observers: new Set(),
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "profiler-started":
+ case "profiler-stopped":
+ // Call observer(false) or observer(true), but do it through a promise
+ // so that it's asynchronous.
+ // We don't want it to be synchronous because of the observer call in
+ // addObserver, which is asynchronous, and we want to get the ordering
+ // right.
+ const isRunningPromise = Promise.resolve(topic === "profiler-started");
+ for (let observer of this._observers) {
+ isRunningPromise.then(observer);
+ }
+ break;
+ }
+ },
+
+ _startListening() {
+ Services.obs.addObserver(this, "profiler-started");
+ Services.obs.addObserver(this, "profiler-stopped");
+ },
+
+ _stopListening() {
+ Services.obs.removeObserver(this, "profiler-started");
+ Services.obs.removeObserver(this, "profiler-stopped");
+ },
+
+ addObserver(observer) {
+ if (this._observers.size === 0) {
+ this._startListening();
+ }
+
+ this._observers.add(observer);
+ observer(Services.profiler.IsActive());
+ },
+
+ removeObserver(observer) {
+ if (this._observers.delete(observer) && this._observers.size === 0) {
+ this._stopListening();
+ }
+ },
+};
+
+this.geckoProfiler = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ geckoProfiler: {
+ async start(options) {
+ const { bufferSize, windowLength, interval, features, threads } =
+ options;
+
+ Services.prefs.setBoolPref(PREF_ASYNC_STACK, false);
+ if (threads) {
+ Services.profiler.StartProfiler(
+ bufferSize,
+ interval,
+ features,
+ threads,
+ 0,
+ windowLength
+ );
+ } else {
+ Services.profiler.StartProfiler(
+ bufferSize,
+ interval,
+ features,
+ [],
+ 0,
+ windowLength
+ );
+ }
+ },
+
+ async stop() {
+ if (ASYNC_STACKS_ENABLED !== null) {
+ Services.prefs.setBoolPref(PREF_ASYNC_STACK, ASYNC_STACKS_ENABLED);
+ }
+
+ Services.profiler.StopProfiler();
+ },
+
+ async pause() {
+ Services.profiler.Pause();
+ },
+
+ async resume() {
+ Services.profiler.Resume();
+ },
+
+ async dumpProfileToFile(fileName) {
+ if (!Services.profiler.IsActive()) {
+ throw new ExtensionError(
+ "The profiler is stopped. " +
+ "You need to start the profiler before you can capture a profile."
+ );
+ }
+
+ if (fileName.includes("\\") || fileName.includes("/")) {
+ throw new ExtensionError("Path cannot contain a subdirectory.");
+ }
+
+ let dirPath = PathUtils.join(PathUtils.profileDir, "profiler");
+ let filePath = PathUtils.join(dirPath, fileName);
+
+ try {
+ await IOUtils.makeDirectory(dirPath);
+ await Services.profiler.dumpProfileToFileAsync(filePath);
+ } catch (e) {
+ Cu.reportError(e);
+ throw new ExtensionError(`Dumping profile to ${filePath} failed.`);
+ }
+ },
+
+ async getProfile() {
+ if (!Services.profiler.IsActive()) {
+ throw new ExtensionError(
+ "The profiler is stopped. " +
+ "You need to start the profiler before you can capture a profile."
+ );
+ }
+
+ return Services.profiler.getProfileDataAsync();
+ },
+
+ async getProfileAsArrayBuffer() {
+ if (!Services.profiler.IsActive()) {
+ throw new ExtensionError(
+ "The profiler is stopped. " +
+ "You need to start the profiler before you can capture a profile."
+ );
+ }
+
+ return Services.profiler.getProfileDataAsArrayBuffer();
+ },
+
+ async getProfileAsGzippedArrayBuffer() {
+ if (!Services.profiler.IsActive()) {
+ throw new ExtensionError(
+ "The profiler is stopped. " +
+ "You need to start the profiler before you can capture a profile."
+ );
+ }
+
+ return Services.profiler.getProfileDataAsGzippedArrayBuffer();
+ },
+
+ async getSymbols(debugName, breakpadId) {
+ return symbolicationService.getSymbolTable(debugName, breakpadId);
+ },
+
+ onRunning: new EventManager({
+ context,
+ name: "geckoProfiler.onRunning",
+ register: fire => {
+ isRunningObserver.addObserver(fire.async);
+ return () => {
+ isRunningObserver.removeObserver(fire.async);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-i18n.js b/toolkit/components/extensions/parent/ext-i18n.js
new file mode 100644
index 0000000000..167a1d16c2
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-i18n.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ LanguageDetector:
+ "resource://gre/modules/translation/LanguageDetector.sys.mjs",
+});
+
+this.i18n = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+ return {
+ i18n: {
+ getMessage: function (messageName, substitutions) {
+ return extension.localizeMessage(messageName, substitutions, {
+ cloneScope: context.cloneScope,
+ });
+ },
+
+ getAcceptLanguages: function () {
+ let result = extension.localeData.acceptLanguages;
+ return Promise.resolve(result);
+ },
+
+ getUILanguage: function () {
+ return extension.localeData.uiLocale;
+ },
+
+ detectLanguage: function (text) {
+ return LanguageDetector.detectLanguage(text).then(result => ({
+ isReliable: result.confident,
+ languages: result.languages.map(lang => {
+ return {
+ language: lang.languageCode,
+ percentage: lang.percent,
+ };
+ }),
+ }));
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-identity.js b/toolkit/components/extensions/parent/ext-identity.js
new file mode 100644
index 0000000000..5bc643811a
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-identity.js
@@ -0,0 +1,152 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["XMLHttpRequest", "ChannelWrapper"]);
+
+var { promiseDocumentLoaded } = ExtensionUtils;
+
+const checkRedirected = (url, redirectURI) => {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", url);
+ // We expect this if the user has not authenticated.
+ xhr.onload = () => {
+ reject(0);
+ };
+ // An unexpected error happened, log for extension authors.
+ xhr.onerror = () => {
+ reject(xhr.status);
+ };
+ // Catch redirect to our redirect_uri before a new request is made.
+ xhr.channel.notificationCallbacks = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIInterfaceRequestor",
+ "nsIChannelEventSync",
+ ]),
+
+ getInterface: ChromeUtils.generateQI(["nsIChannelEventSink"]),
+
+ asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
+ let responseURL = newChannel.URI.spec;
+ if (responseURL.startsWith(redirectURI)) {
+ resolve(responseURL);
+ // Cancel the redirect.
+ callback.onRedirectVerifyCallback(Cr.NS_BINDING_ABORTED);
+ return;
+ }
+ callback.onRedirectVerifyCallback(Cr.NS_OK);
+ },
+ };
+ xhr.send();
+ });
+};
+
+const openOAuthWindow = (details, redirectURI) => {
+ let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ let supportsStringPrefURL = Cc[
+ "@mozilla.org/supports-string;1"
+ ].createInstance(Ci.nsISupportsString);
+ supportsStringPrefURL.data = details.url;
+ args.appendElement(supportsStringPrefURL);
+
+ let window = Services.ww.openWindow(
+ null,
+ AppConstants.BROWSER_CHROME_URL,
+ "launchWebAuthFlow_dialog",
+ "chrome,location=yes,centerscreen,dialog=no,resizable=yes,scrollbars=yes",
+ args
+ );
+
+ return new Promise((resolve, reject) => {
+ let httpActivityDistributor = Cc[
+ "@mozilla.org/network/http-activity-distributor;1"
+ ].getService(Ci.nsIHttpActivityDistributor);
+
+ let unloadListener;
+ let httpObserver;
+
+ const resolveIfRedirectURI = channel => {
+ const url = channel.URI && channel.URI.spec;
+ if (!url || !url.startsWith(redirectURI)) {
+ return;
+ }
+
+ // Early exit if channel isn't related to the oauth dialog.
+ let wrapper = ChannelWrapper.get(channel);
+ if (
+ !wrapper.browserElement &&
+ wrapper.browserElement !== window.gBrowser.selectedBrowser
+ ) {
+ return;
+ }
+
+ wrapper.cancel(Cr.NS_ERROR_ABORT, Ci.nsILoadInfo.BLOCKING_REASON_NONE);
+ window.gBrowser.webNavigation.stop(Ci.nsIWebNavigation.STOP_ALL);
+ window.removeEventListener("unload", unloadListener);
+ httpActivityDistributor.removeObserver(httpObserver);
+ window.close();
+ resolve(url);
+ };
+
+ httpObserver = {
+ observeActivity(channel, type, subtype, timestamp, sizeData, stringData) {
+ try {
+ channel.QueryInterface(Ci.nsIChannel);
+ } catch {
+ // Ignore activities for channels that doesn't implement nsIChannel
+ // (e.g. a NullHttpChannel).
+ return;
+ }
+
+ resolveIfRedirectURI(channel);
+ },
+ };
+
+ httpActivityDistributor.addObserver(httpObserver);
+
+ // If the user just closes the window we need to reject
+ unloadListener = () => {
+ window.removeEventListener("unload", unloadListener);
+ httpActivityDistributor.removeObserver(httpObserver);
+ reject({ message: "User cancelled or denied access." });
+ };
+
+ promiseDocumentLoaded(window.document).then(() => {
+ window.addEventListener("unload", unloadListener);
+ });
+ });
+};
+
+this.identity = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ identity: {
+ launchWebAuthFlowInParent: function (details, redirectURI) {
+ // If the request is automatically redirected the user has already
+ // authorized and we do not want to show the window.
+ return checkRedirected(details.url, redirectURI).catch(
+ requestError => {
+ // requestError is zero or xhr.status
+ if (requestError !== 0) {
+ Cu.reportError(
+ `browser.identity auth check failed with ${requestError}`
+ );
+ return Promise.reject({ message: "Invalid request" });
+ }
+ if (!details.interactive) {
+ return Promise.reject({ message: `Requires user interaction` });
+ }
+
+ return openOAuthWindow(details, redirectURI);
+ }
+ );
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-idle.js b/toolkit/components/extensions/parent/ext-idle.js
new file mode 100644
index 0000000000..f68ea293d7
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-idle.js
@@ -0,0 +1,113 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "idleService",
+ "@mozilla.org/widget/useridleservice;1",
+ "nsIUserIdleService"
+);
+
+var { DefaultWeakMap } = ExtensionUtils;
+
+// WeakMap[Extension -> Object]
+const idleObserversMap = new DefaultWeakMap(() => {
+ return {
+ observer: null,
+ detectionInterval: 60,
+ };
+});
+
+const getIdleObserver = extension => {
+ let observerInfo = idleObserversMap.get(extension);
+ let { observer, detectionInterval } = observerInfo;
+ let interval =
+ extension.startupData?.idleDetectionInterval || detectionInterval;
+
+ if (!observer) {
+ observer = new (class extends ExtensionCommon.EventEmitter {
+ observe(subject, topic, data) {
+ if (topic == "idle" || topic == "active") {
+ this.emit("stateChanged", topic);
+ }
+ }
+ })();
+ idleService.addIdleObserver(observer, interval);
+ observerInfo.observer = observer;
+ observerInfo.detectionInterval = interval;
+ }
+ return observer;
+};
+
+this.idle = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ onStateChanged({ fire }) {
+ let { extension } = this;
+ let listener = (event, data) => {
+ fire.sync(data);
+ };
+
+ getIdleObserver(extension).on("stateChanged", listener);
+ return {
+ async unregister() {
+ let observerInfo = idleObserversMap.get(extension);
+ let { observer, detectionInterval } = observerInfo;
+ if (observer) {
+ observer.off("stateChanged", listener);
+ if (!observer.has("stateChanged")) {
+ idleService.removeIdleObserver(observer, detectionInterval);
+ observerInfo.observer = null;
+ }
+ }
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ let self = this;
+
+ return {
+ idle: {
+ queryState(detectionIntervalInSeconds) {
+ if (idleService.idleTime < detectionIntervalInSeconds * 1000) {
+ return "active";
+ }
+ return "idle";
+ },
+ setDetectionInterval(detectionIntervalInSeconds) {
+ let observerInfo = idleObserversMap.get(extension);
+ let { observer, detectionInterval } = observerInfo;
+ if (detectionInterval == detectionIntervalInSeconds) {
+ return;
+ }
+ if (observer) {
+ idleService.removeIdleObserver(observer, detectionInterval);
+ idleService.addIdleObserver(observer, detectionIntervalInSeconds);
+ }
+ observerInfo.detectionInterval = detectionIntervalInSeconds;
+ // There is no great way to modify a persistent listener param, but we
+ // need to keep this for the startup listener.
+ if (!extension.persistentBackground) {
+ extension.startupData.idleDetectionInterval =
+ detectionIntervalInSeconds;
+ extension.saveStartupData();
+ }
+ },
+ onStateChanged: new EventManager({
+ context,
+ module: "idle",
+ event: "onStateChanged",
+ extensionApi: self,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-management.js b/toolkit/components/extensions/parent/ext-management.js
new file mode 100644
index 0000000000..e0834d378f
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-management.js
@@ -0,0 +1,354 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineLazyGetter(this, "strBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://global/locale/extensions.properties"
+ );
+});
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+// We can't use Services.prompt here at the moment, as tests need to mock
+// the prompt service. We could use sinon, but that didn't seem to work
+// with Android builds.
+// eslint-disable-next-line mozilla/use-services
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "promptService",
+ "@mozilla.org/prompter;1",
+ "nsIPromptService"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+const _ = (key, ...args) => {
+ if (args.length) {
+ return strBundle.formatStringFromName(key, args);
+ }
+ return strBundle.GetStringFromName(key);
+};
+
+const installType = addon => {
+ if (addon.temporarilyInstalled) {
+ return "development";
+ } else if (addon.foreignInstall) {
+ return "sideload";
+ } else if (addon.isSystem) {
+ return "other";
+ }
+ return "normal";
+};
+
+const getExtensionInfoForAddon = (extension, addon) => {
+ let extInfo = {
+ id: addon.id,
+ name: addon.name,
+ description: addon.description || "",
+ version: addon.version,
+ mayDisable: !!(addon.permissions & AddonManager.PERM_CAN_DISABLE),
+ enabled: addon.isActive,
+ optionsUrl: addon.optionsURL || "",
+ installType: installType(addon),
+ type: addon.type,
+ };
+
+ if (extension) {
+ let m = extension.manifest;
+
+ let hostPerms = extension.allowedOrigins.patterns.map(
+ matcher => matcher.pattern
+ );
+
+ extInfo.permissions = Array.from(extension.permissions).filter(perm => {
+ return !hostPerms.includes(perm);
+ });
+ extInfo.hostPermissions = hostPerms;
+
+ extInfo.shortName = m.short_name || "";
+ if (m.icons) {
+ extInfo.icons = Object.keys(m.icons).map(key => {
+ return { size: Number(key), url: m.icons[key] };
+ });
+ }
+ }
+
+ if (!addon.isActive) {
+ extInfo.disabledReason = "unknown";
+ }
+ if (addon.homepageURL) {
+ extInfo.homepageUrl = addon.homepageURL;
+ }
+ if (addon.updateURL) {
+ extInfo.updateUrl = addon.updateURL;
+ }
+ return extInfo;
+};
+
+// Some management APIs are intentionally limited.
+const allowedTypes = ["theme", "extension"];
+
+function checkAllowedAddon(addon) {
+ if (addon.isSystem || addon.isAPIExtension) {
+ return false;
+ }
+ if (addon.type == "extension" && !addon.isWebExtension) {
+ return false;
+ }
+ return allowedTypes.includes(addon.type);
+}
+
+class ManagementAddonListener extends ExtensionCommon.EventEmitter {
+ eventNames = ["onEnabled", "onDisabled", "onInstalled", "onUninstalled"];
+
+ hasAnyListeners() {
+ for (let event of this.eventNames) {
+ if (this.has(event)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ on(event, listener) {
+ if (!this.eventNames.includes(event)) {
+ throw new Error("unsupported event");
+ }
+ if (!this.hasAnyListeners()) {
+ AddonManager.addAddonListener(this);
+ }
+ super.on(event, listener);
+ }
+
+ off(event, listener) {
+ if (!this.eventNames.includes(event)) {
+ throw new Error("unsupported event");
+ }
+ super.off(event, listener);
+ if (!this.hasAnyListeners()) {
+ AddonManager.removeAddonListener(this);
+ }
+ }
+
+ getExtensionInfo(addon) {
+ let ext = WebExtensionPolicy.getByID(addon.id)?.extension;
+ return getExtensionInfoForAddon(ext, addon);
+ }
+
+ onEnabled(addon) {
+ if (!checkAllowedAddon(addon)) {
+ return;
+ }
+ this.emit("onEnabled", this.getExtensionInfo(addon));
+ }
+
+ onDisabled(addon) {
+ if (!checkAllowedAddon(addon)) {
+ return;
+ }
+ this.emit("onDisabled", this.getExtensionInfo(addon));
+ }
+
+ onInstalled(addon) {
+ if (!checkAllowedAddon(addon)) {
+ return;
+ }
+ this.emit("onInstalled", this.getExtensionInfo(addon));
+ }
+
+ onUninstalled(addon) {
+ if (!checkAllowedAddon(addon)) {
+ return;
+ }
+ this.emit("onUninstalled", this.getExtensionInfo(addon));
+ }
+}
+
+this.management = class extends ExtensionAPIPersistent {
+ addonListener = new ManagementAddonListener();
+
+ onShutdown() {
+ AddonManager.removeAddonListener(this.addonListener);
+ }
+
+ eventRegistrar(eventName) {
+ return ({ fire }) => {
+ let listener = (event, data) => {
+ fire.async(data);
+ };
+
+ this.addonListener.on(eventName, listener);
+ return {
+ unregister: () => {
+ this.addonListener.off(eventName, listener);
+ },
+ convert(_fire, context) {
+ fire = _fire;
+ },
+ };
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ onDisabled: this.eventRegistrar("onDisabled"),
+ onEnabled: this.eventRegistrar("onEnabled"),
+ onInstalled: this.eventRegistrar("onInstalled"),
+ onUninstalled: this.eventRegistrar("onUninstalled"),
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ management: {
+ async get(id) {
+ let addon = await AddonManager.getAddonByID(id);
+ if (!addon) {
+ throw new ExtensionError(`No such addon ${id}`);
+ }
+ if (!checkAllowedAddon(addon)) {
+ throw new ExtensionError("get not allowed for this addon");
+ }
+ // If the extension is enabled get it and use it for more data.
+ let ext = WebExtensionPolicy.getByID(addon.id)?.extension;
+ return getExtensionInfoForAddon(ext, addon);
+ },
+
+ async getAll() {
+ let addons = await AddonManager.getAddonsByTypes(allowedTypes);
+ return addons.filter(checkAllowedAddon).map(addon => {
+ // If the extension is enabled get it and use it for more data.
+ let ext = WebExtensionPolicy.getByID(addon.id)?.extension;
+ return getExtensionInfoForAddon(ext, addon);
+ });
+ },
+
+ async install({ url, hash }) {
+ let listener = {
+ onDownloadEnded(install) {
+ if (install.addon.appDisabled || install.addon.type !== "theme") {
+ install.cancel();
+ return false;
+ }
+ },
+ };
+
+ let telemetryInfo = {
+ source: "extension",
+ method: "management-webext-api",
+ };
+ let install = await AddonManager.getInstallForURL(url, {
+ hash,
+ telemetryInfo,
+ triggeringPrincipal: extension.principal,
+ });
+ install.addListener(listener);
+ try {
+ await install.install();
+ } catch (e) {
+ Cu.reportError(e);
+ throw new ExtensionError("Incompatible addon");
+ }
+ await install.addon.enable();
+ return { id: install.addon.id };
+ },
+
+ async getSelf() {
+ let addon = await AddonManager.getAddonByID(extension.id);
+ return getExtensionInfoForAddon(extension, addon);
+ },
+
+ async uninstallSelf(options) {
+ if (options && options.showConfirmDialog) {
+ let message = _("uninstall.confirmation.message", extension.name);
+ if (options.dialogMessage) {
+ message = `${options.dialogMessage}\n${message}`;
+ }
+ let title = _("uninstall.confirmation.title", extension.name);
+ let buttonFlags =
+ Ci.nsIPrompt.BUTTON_POS_0 * Ci.nsIPrompt.BUTTON_TITLE_IS_STRING +
+ Ci.nsIPrompt.BUTTON_POS_1 * Ci.nsIPrompt.BUTTON_TITLE_IS_STRING;
+ let button0Title = _("uninstall.confirmation.button-0.label");
+ let button1Title = _("uninstall.confirmation.button-1.label");
+ let response = promptService.confirmEx(
+ null,
+ title,
+ message,
+ buttonFlags,
+ button0Title,
+ button1Title,
+ null,
+ null,
+ { value: 0 }
+ );
+ if (response == 1) {
+ throw new ExtensionError("User cancelled uninstall of extension");
+ }
+ }
+ let addon = await AddonManager.getAddonByID(extension.id);
+ let canUninstall = Boolean(
+ addon.permissions & AddonManager.PERM_CAN_UNINSTALL
+ );
+ if (!canUninstall) {
+ throw new ExtensionError("The add-on cannot be uninstalled");
+ }
+ addon.uninstall();
+ },
+
+ async setEnabled(id, enabled) {
+ let addon = await AddonManager.getAddonByID(id);
+ if (!addon) {
+ throw new ExtensionError(`No such addon ${id}`);
+ }
+ if (addon.type !== "theme") {
+ throw new ExtensionError("setEnabled applies only to theme addons");
+ }
+ if (addon.isSystem) {
+ throw new ExtensionError(
+ "setEnabled cannot be used with a system addon"
+ );
+ }
+ if (enabled) {
+ await addon.enable();
+ } else {
+ await addon.disable();
+ }
+ },
+
+ onDisabled: new EventManager({
+ context,
+ module: "management",
+ event: "onDisabled",
+ extensionApi: this,
+ }).api(),
+
+ onEnabled: new EventManager({
+ context,
+ module: "management",
+ event: "onEnabled",
+ extensionApi: this,
+ }).api(),
+
+ onInstalled: new EventManager({
+ context,
+ module: "management",
+ event: "onInstalled",
+ extensionApi: this,
+ }).api(),
+
+ onUninstalled: new EventManager({
+ context,
+ module: "management",
+ event: "onUninstalled",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-networkStatus.js b/toolkit/components/extensions/parent/ext-networkStatus.js
new file mode 100644
index 0000000000..7379d746f5
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-networkStatus.js
@@ -0,0 +1,85 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gNetworkLinkService",
+ "@mozilla.org/network/network-link-service;1",
+ "nsINetworkLinkService"
+);
+
+function getLinkType() {
+ switch (gNetworkLinkService.linkType) {
+ case gNetworkLinkService.LINK_TYPE_UNKNOWN:
+ return "unknown";
+ case gNetworkLinkService.LINK_TYPE_ETHERNET:
+ return "ethernet";
+ case gNetworkLinkService.LINK_TYPE_USB:
+ return "usb";
+ case gNetworkLinkService.LINK_TYPE_WIFI:
+ return "wifi";
+ case gNetworkLinkService.LINK_TYPE_WIMAX:
+ return "wimax";
+ case gNetworkLinkService.LINK_TYPE_MOBILE:
+ return "mobile";
+ default:
+ return "unknown";
+ }
+}
+
+function getLinkStatus() {
+ if (!gNetworkLinkService.linkStatusKnown) {
+ return "unknown";
+ }
+ return gNetworkLinkService.isLinkUp ? "up" : "down";
+}
+
+function getLinkInfo() {
+ return {
+ id: gNetworkLinkService.networkID || undefined,
+ status: getLinkStatus(),
+ type: getLinkType(),
+ };
+}
+
+this.networkStatus = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ networkStatus: {
+ getLinkInfo,
+ onConnectionChanged: new EventManager({
+ context,
+ name: "networkStatus.onConnectionChanged",
+ register: fire => {
+ let observerStatus = (subject, topic, data) => {
+ fire.async(getLinkInfo());
+ };
+
+ Services.obs.addObserver(
+ observerStatus,
+ "network:link-status-changed"
+ );
+ Services.obs.addObserver(
+ observerStatus,
+ "network:link-type-changed"
+ );
+ return () => {
+ Services.obs.removeObserver(
+ observerStatus,
+ "network:link-status-changed"
+ );
+ Services.obs.removeObserver(
+ observerStatus,
+ "network:link-type-changed"
+ );
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-notifications.js b/toolkit/components/extensions/parent/ext-notifications.js
new file mode 100644
index 0000000000..5b42e6c936
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-notifications.js
@@ -0,0 +1,188 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const ToolkitModules = {};
+
+ChromeUtils.defineESModuleGetters(ToolkitModules, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+var { ignoreEvent } = ExtensionCommon;
+
+// Manages a notification popup (notifications API) created by the extension.
+function Notification(context, notificationsMap, id, options) {
+ this.notificationsMap = notificationsMap;
+ this.id = id;
+ this.options = options;
+
+ let imageURL;
+ if (options.iconUrl) {
+ imageURL = context.extension.baseURI.resolve(options.iconUrl);
+ }
+
+ // Set before calling into nsIAlertsService, because the notification may be
+ // closed during the call.
+ notificationsMap.set(id, this);
+
+ try {
+ let svc = Cc["@mozilla.org/alerts-service;1"].getService(
+ Ci.nsIAlertsService
+ );
+ svc.showAlertNotification(
+ imageURL,
+ options.title,
+ options.message,
+ true, // textClickable
+ this.id,
+ this,
+ this.id,
+ undefined,
+ undefined,
+ undefined,
+ // Principal is not set because doing so reveals buttons to control
+ // notification preferences, which are currently not implemented for
+ // notifications triggered via this extension API (bug 1589693).
+ undefined,
+ context.incognito
+ );
+ } catch (e) {
+ // This will fail if alerts aren't available on the system.
+
+ this.observe(null, "alertfinished", id);
+ }
+}
+
+Notification.prototype = {
+ clear() {
+ try {
+ let svc = Cc["@mozilla.org/alerts-service;1"].getService(
+ Ci.nsIAlertsService
+ );
+ svc.closeAlert(this.id);
+ } catch (e) {
+ // This will fail if the OS doesn't support this function.
+ }
+ this.notificationsMap.delete(this.id);
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "alertclickcallback":
+ this.notificationsMap.emit("clicked", data);
+ break;
+ case "alertfinished":
+ this.notificationsMap.emit("closed", data);
+ this.notificationsMap.delete(this.id);
+ break;
+ case "alertshow":
+ this.notificationsMap.emit("shown", data);
+ break;
+ }
+ },
+};
+
+this.notifications = class extends ExtensionAPI {
+ constructor(extension) {
+ super(extension);
+
+ this.nextId = 0;
+ this.notificationsMap = new Map();
+ ToolkitModules.EventEmitter.decorate(this.notificationsMap);
+ }
+
+ onShutdown() {
+ for (let notification of this.notificationsMap.values()) {
+ notification.clear();
+ }
+ }
+
+ getAPI(context) {
+ let notificationsMap = this.notificationsMap;
+
+ return {
+ notifications: {
+ create: (notificationId, options) => {
+ if (!notificationId) {
+ notificationId = String(this.nextId++);
+ }
+
+ if (notificationsMap.has(notificationId)) {
+ notificationsMap.get(notificationId).clear();
+ }
+
+ new Notification(context, notificationsMap, notificationId, options);
+
+ return Promise.resolve(notificationId);
+ },
+
+ clear: function (notificationId) {
+ if (notificationsMap.has(notificationId)) {
+ notificationsMap.get(notificationId).clear();
+ return Promise.resolve(true);
+ }
+ return Promise.resolve(false);
+ },
+
+ getAll: function () {
+ let result = {};
+ notificationsMap.forEach((value, key) => {
+ result[key] = value.options;
+ });
+ return Promise.resolve(result);
+ },
+
+ onClosed: new EventManager({
+ context,
+ name: "notifications.onClosed",
+ register: fire => {
+ let listener = (event, notificationId) => {
+ // TODO Bug 1413188, Support the byUser argument.
+ fire.async(notificationId, true);
+ };
+
+ notificationsMap.on("closed", listener);
+ return () => {
+ notificationsMap.off("closed", listener);
+ };
+ },
+ }).api(),
+
+ onClicked: new EventManager({
+ context,
+ name: "notifications.onClicked",
+ register: fire => {
+ let listener = (event, notificationId) => {
+ fire.async(notificationId);
+ };
+
+ notificationsMap.on("clicked", listener);
+ return () => {
+ notificationsMap.off("clicked", listener);
+ };
+ },
+ }).api(),
+
+ onShown: new EventManager({
+ context,
+ name: "notifications.onShown",
+ register: fire => {
+ let listener = (event, notificationId) => {
+ fire.async(notificationId);
+ };
+
+ notificationsMap.on("shown", listener);
+ return () => {
+ notificationsMap.off("shown", listener);
+ };
+ },
+ }).api(),
+
+ // TODO Bug 1190681, implement button support.
+ onButtonClicked: ignoreEvent(context, "notifications.onButtonClicked"),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-permissions.js b/toolkit/components/extensions/parent/ext-permissions.js
new file mode 100644
index 0000000000..8639381de7
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-permissions.js
@@ -0,0 +1,191 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+});
+
+var { ExtensionError } = ExtensionUtils;
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "promptsEnabled",
+ "extensions.webextOptionalPermissionPrompts"
+);
+
+function normalizePermissions(perms) {
+ perms = { ...perms };
+ perms.permissions = perms.permissions.filter(
+ perm => !perm.startsWith("internal:")
+ );
+ return perms;
+}
+
+this.permissions = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ onAdded({ fire }) {
+ let { extension } = this;
+ let callback = (event, change) => {
+ if (change.extensionId == extension.id && change.added) {
+ let perms = normalizePermissions(change.added);
+ if (perms.permissions.length || perms.origins.length) {
+ fire.async(perms);
+ }
+ }
+ };
+
+ extensions.on("change-permissions", callback);
+ return {
+ unregister() {
+ extensions.off("change-permissions", callback);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onRemoved({ fire }) {
+ let { extension } = this;
+ let callback = (event, change) => {
+ if (change.extensionId == extension.id && change.removed) {
+ let perms = normalizePermissions(change.removed);
+ if (perms.permissions.length || perms.origins.length) {
+ fire.async(perms);
+ }
+ }
+ };
+
+ extensions.on("change-permissions", callback);
+ return {
+ unregister() {
+ extensions.off("change-permissions", callback);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ permissions: {
+ async request(perms) {
+ let { permissions, origins } = perms;
+
+ let { optionalPermissions } = context.extension;
+ for (let perm of permissions) {
+ if (!optionalPermissions.includes(perm)) {
+ throw new ExtensionError(
+ `Cannot request permission ${perm} since it was not declared in optional_permissions`
+ );
+ }
+ }
+
+ let optionalOrigins = context.extension.optionalOrigins;
+ for (let origin of origins) {
+ if (!optionalOrigins.subsumes(new MatchPattern(origin))) {
+ throw new ExtensionError(
+ `Cannot request origin permission for ${origin} since it was not declared in the manifest`
+ );
+ }
+ }
+
+ if (promptsEnabled) {
+ permissions = permissions.filter(
+ perm => !context.extension.hasPermission(perm)
+ );
+ origins = origins.filter(
+ origin =>
+ !context.extension.allowedOrigins.subsumes(
+ new MatchPattern(origin)
+ )
+ );
+
+ if (!permissions.length && !origins.length) {
+ return true;
+ }
+
+ let browser = context.pendingEventBrowser || context.xulBrowser;
+ let allow = await new Promise(resolve => {
+ let subject = {
+ wrappedJSObject: {
+ browser,
+ name: context.extension.name,
+ id: context.extension.id,
+ icon: context.extension.getPreferredIcon(32),
+ permissions: { permissions, origins },
+ resolve,
+ },
+ };
+ Services.obs.notifyObservers(
+ subject,
+ "webextension-optional-permission-prompt"
+ );
+ });
+ if (!allow) {
+ return false;
+ }
+ }
+
+ await ExtensionPermissions.add(extension.id, perms, extension);
+ return true;
+ },
+
+ async getAll() {
+ let perms = normalizePermissions(context.extension.activePermissions);
+ delete perms.apis;
+ return perms;
+ },
+
+ async contains(permissions) {
+ for (let perm of permissions.permissions) {
+ if (!context.extension.hasPermission(perm)) {
+ return false;
+ }
+ }
+
+ for (let origin of permissions.origins) {
+ if (
+ !context.extension.allowedOrigins.subsumes(
+ new MatchPattern(origin)
+ )
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ async remove(permissions) {
+ await ExtensionPermissions.remove(
+ extension.id,
+ permissions,
+ extension
+ );
+ return true;
+ },
+
+ onAdded: new EventManager({
+ context,
+ module: "permissions",
+ event: "onAdded",
+ extensionApi: this,
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ module: "permissions",
+ event: "onRemoved",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-privacy.js b/toolkit/components/extensions/parent/ext-privacy.js
new file mode 100644
index 0000000000..1c4bf05ff1
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-privacy.js
@@ -0,0 +1,516 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionPreferencesManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs"
+);
+
+var { ExtensionError } = ExtensionUtils;
+var { getSettingsAPI, getPrimedSettingsListener } = ExtensionPreferencesManager;
+
+const cookieSvc = Ci.nsICookieService;
+
+const getIntPref = p => Services.prefs.getIntPref(p, undefined);
+const getBoolPref = p => Services.prefs.getBoolPref(p, undefined);
+
+const TLS_MIN_PREF = "security.tls.version.min";
+const TLS_MAX_PREF = "security.tls.version.max";
+
+const cookieBehaviorValues = new Map([
+ ["allow_all", cookieSvc.BEHAVIOR_ACCEPT],
+ ["reject_third_party", cookieSvc.BEHAVIOR_REJECT_FOREIGN],
+ ["reject_all", cookieSvc.BEHAVIOR_REJECT],
+ ["allow_visited", cookieSvc.BEHAVIOR_LIMIT_FOREIGN],
+ ["reject_trackers", cookieSvc.BEHAVIOR_REJECT_TRACKER],
+ [
+ "reject_trackers_and_partition_foreign",
+ cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
+ ],
+]);
+
+function isTLSMinVersionLowerOrEQThan(version) {
+ return (
+ Services.prefs.getDefaultBranch("").getIntPref(TLS_MIN_PREF) <= version
+ );
+}
+
+const TLS_VERSIONS = [
+ { version: 1, name: "TLSv1", settable: isTLSMinVersionLowerOrEQThan(1) },
+ { version: 2, name: "TLSv1.1", settable: isTLSMinVersionLowerOrEQThan(2) },
+ { version: 3, name: "TLSv1.2", settable: true },
+ { version: 4, name: "TLSv1.3", settable: true },
+];
+
+// Add settings objects for supported APIs to the preferences manager.
+ExtensionPreferencesManager.addSetting("network.networkPredictionEnabled", {
+ permission: "privacy",
+ prefNames: [
+ "network.predictor.enabled",
+ "network.prefetch-next",
+ "network.http.speculative-parallel-limit",
+ "network.dns.disablePrefetch",
+ ],
+
+ setCallback(value) {
+ return {
+ "network.http.speculative-parallel-limit": value ? undefined : 0,
+ "network.dns.disablePrefetch": !value,
+ "network.predictor.enabled": value,
+ "network.prefetch-next": value,
+ };
+ },
+
+ getCallback() {
+ return (
+ getBoolPref("network.predictor.enabled") &&
+ getBoolPref("network.prefetch-next") &&
+ getIntPref("network.http.speculative-parallel-limit") > 0 &&
+ !getBoolPref("network.dns.disablePrefetch")
+ );
+ },
+});
+
+ExtensionPreferencesManager.addSetting("network.globalPrivacyControl", {
+ permission: "privacy",
+ prefNames: ["privacy.globalprivacycontrol.enabled"],
+ readOnly: true,
+
+ setCallback(value) {
+ return {
+ "privacy.globalprivacycontrol.enabled": value,
+ };
+ },
+
+ getCallback() {
+ return getBoolPref("privacy.globalprivacycontrol.enabled");
+ },
+});
+
+ExtensionPreferencesManager.addSetting("network.httpsOnlyMode", {
+ permission: "privacy",
+ prefNames: [
+ "dom.security.https_only_mode",
+ "dom.security.https_only_mode_pbm",
+ ],
+ readOnly: true,
+
+ setCallback(value) {
+ let prefs = {
+ "dom.security.https_only_mode": false,
+ "dom.security.https_only_mode_pbm": false,
+ };
+
+ switch (value) {
+ case "always":
+ prefs["dom.security.https_only_mode"] = true;
+ break;
+
+ case "private_browsing":
+ prefs["dom.security.https_only_mode_pbm"] = true;
+ break;
+
+ case "never":
+ break;
+ }
+
+ return prefs;
+ },
+
+ getCallback() {
+ if (getBoolPref("dom.security.https_only_mode")) {
+ return "always";
+ }
+ if (getBoolPref("dom.security.https_only_mode_pbm")) {
+ return "private_browsing";
+ }
+ return "never";
+ },
+});
+
+ExtensionPreferencesManager.addSetting("network.peerConnectionEnabled", {
+ permission: "privacy",
+ prefNames: ["media.peerconnection.enabled"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return getBoolPref("media.peerconnection.enabled");
+ },
+});
+
+ExtensionPreferencesManager.addSetting("network.webRTCIPHandlingPolicy", {
+ permission: "privacy",
+ prefNames: [
+ "media.peerconnection.ice.default_address_only",
+ "media.peerconnection.ice.no_host",
+ "media.peerconnection.ice.proxy_only_if_behind_proxy",
+ "media.peerconnection.ice.proxy_only",
+ ],
+
+ setCallback(value) {
+ let prefs = {};
+ switch (value) {
+ case "default":
+ // All prefs are already set to be reset.
+ break;
+
+ case "default_public_and_private_interfaces":
+ prefs["media.peerconnection.ice.default_address_only"] = true;
+ break;
+
+ case "default_public_interface_only":
+ prefs["media.peerconnection.ice.default_address_only"] = true;
+ prefs["media.peerconnection.ice.no_host"] = true;
+ break;
+
+ case "disable_non_proxied_udp":
+ prefs["media.peerconnection.ice.default_address_only"] = true;
+ prefs["media.peerconnection.ice.no_host"] = true;
+ prefs["media.peerconnection.ice.proxy_only_if_behind_proxy"] = true;
+ break;
+
+ case "proxy_only":
+ prefs["media.peerconnection.ice.proxy_only"] = true;
+ break;
+ }
+ return prefs;
+ },
+
+ getCallback() {
+ if (getBoolPref("media.peerconnection.ice.proxy_only")) {
+ return "proxy_only";
+ }
+
+ let default_address_only = getBoolPref(
+ "media.peerconnection.ice.default_address_only"
+ );
+ if (default_address_only) {
+ let no_host = getBoolPref("media.peerconnection.ice.no_host");
+ if (no_host) {
+ if (
+ getBoolPref("media.peerconnection.ice.proxy_only_if_behind_proxy")
+ ) {
+ return "disable_non_proxied_udp";
+ }
+ return "default_public_interface_only";
+ }
+ return "default_public_and_private_interfaces";
+ }
+
+ return "default";
+ },
+});
+
+ExtensionPreferencesManager.addSetting("services.passwordSavingEnabled", {
+ permission: "privacy",
+ prefNames: ["signon.rememberSignons"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return getBoolPref("signon.rememberSignons");
+ },
+});
+
+ExtensionPreferencesManager.addSetting("websites.cookieConfig", {
+ permission: "privacy",
+ prefNames: ["network.cookie.cookieBehavior"],
+
+ setCallback(value) {
+ const cookieBehavior = cookieBehaviorValues.get(value.behavior);
+
+ // Intentionally use Preferences.get("network.cookie.cookieBehavior") here
+ // to read the "real" preference value.
+ const needUpdate =
+ cookieBehavior !== getIntPref("network.cookie.cookieBehavior");
+ if (
+ needUpdate &&
+ getBoolPref("privacy.firstparty.isolate") &&
+ cookieBehavior === cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN
+ ) {
+ throw new ExtensionError(
+ `Invalid cookieConfig '${value.behavior}' when firstPartyIsolate is enabled`
+ );
+ }
+
+ if (typeof value.nonPersistentCookies === "boolean") {
+ Cu.reportError(
+ "'nonPersistentCookies' has been deprecated and it has no effect anymore."
+ );
+ }
+
+ return {
+ "network.cookie.cookieBehavior": cookieBehavior,
+ };
+ },
+
+ getCallback() {
+ let prefValue = getIntPref("network.cookie.cookieBehavior");
+ return {
+ behavior: Array.from(cookieBehaviorValues.entries()).find(
+ entry => entry[1] === prefValue
+ )[0],
+ // Bug 1754924 - this property is now deprecated.
+ nonPersistentCookies: false,
+ };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("websites.firstPartyIsolate", {
+ permission: "privacy",
+ prefNames: ["privacy.firstparty.isolate"],
+
+ setCallback(value) {
+ // Intentionally use Preferences.get("network.cookie.cookieBehavior") here
+ // to read the "real" preference value.
+ const cookieBehavior = getIntPref("network.cookie.cookieBehavior");
+
+ const needUpdate = value !== getBoolPref("privacy.firstparty.isolate");
+ if (
+ needUpdate &&
+ value &&
+ cookieBehavior === cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN
+ ) {
+ const behavior = Array.from(cookieBehaviorValues.entries()).find(
+ entry => entry[1] === cookieBehavior
+ )[0];
+ throw new ExtensionError(
+ `Can't enable firstPartyIsolate when cookieBehavior is '${behavior}'`
+ );
+ }
+
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return getBoolPref("privacy.firstparty.isolate");
+ },
+});
+
+ExtensionPreferencesManager.addSetting("websites.hyperlinkAuditingEnabled", {
+ permission: "privacy",
+ prefNames: ["browser.send_pings"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return getBoolPref("browser.send_pings");
+ },
+});
+
+ExtensionPreferencesManager.addSetting("websites.referrersEnabled", {
+ permission: "privacy",
+ prefNames: ["network.http.sendRefererHeader"],
+
+ // Values for network.http.sendRefererHeader:
+ // 0=don't send any, 1=send only on clicks, 2=send on image requests as well
+ // http://searchfox.org/mozilla-central/rev/61054508641ee76f9c49bcf7303ef3cfb6b410d2/modules/libpref/init/all.js#1585
+ setCallback(value) {
+ return { [this.prefNames[0]]: value ? 2 : 0 };
+ },
+
+ getCallback() {
+ return getIntPref("network.http.sendRefererHeader") !== 0;
+ },
+});
+
+ExtensionPreferencesManager.addSetting("websites.resistFingerprinting", {
+ permission: "privacy",
+ prefNames: ["privacy.resistFingerprinting"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+
+ getCallback() {
+ return getBoolPref("privacy.resistFingerprinting");
+ },
+});
+
+ExtensionPreferencesManager.addSetting("websites.trackingProtectionMode", {
+ permission: "privacy",
+ prefNames: [
+ "privacy.trackingprotection.enabled",
+ "privacy.trackingprotection.pbmode.enabled",
+ ],
+
+ setCallback(value) {
+ // Default to private browsing.
+ let prefs = {
+ "privacy.trackingprotection.enabled": false,
+ "privacy.trackingprotection.pbmode.enabled": true,
+ };
+
+ switch (value) {
+ case "private_browsing":
+ break;
+
+ case "always":
+ prefs["privacy.trackingprotection.enabled"] = true;
+ break;
+
+ case "never":
+ prefs["privacy.trackingprotection.pbmode.enabled"] = false;
+ break;
+ }
+
+ return prefs;
+ },
+
+ getCallback() {
+ if (getBoolPref("privacy.trackingprotection.enabled")) {
+ return "always";
+ } else if (getBoolPref("privacy.trackingprotection.pbmode.enabled")) {
+ return "private_browsing";
+ }
+ return "never";
+ },
+});
+
+ExtensionPreferencesManager.addSetting("network.tlsVersionRestriction", {
+ permission: "privacy",
+ prefNames: [TLS_MIN_PREF, TLS_MAX_PREF],
+
+ setCallback(value) {
+ function tlsStringToVersion(string) {
+ const version = TLS_VERSIONS.find(a => a.name === string);
+ if (version && version.settable) {
+ return version.version;
+ }
+
+ throw new ExtensionError(
+ `Setting TLS version ${string} is not allowed for security reasons.`
+ );
+ }
+
+ const prefs = {};
+
+ if (value.minimum) {
+ prefs[TLS_MIN_PREF] = tlsStringToVersion(value.minimum);
+ }
+
+ if (value.maximum) {
+ prefs[TLS_MAX_PREF] = tlsStringToVersion(value.maximum);
+ }
+
+ // If minimum has passed and it's greater than the max value.
+ if (prefs[TLS_MIN_PREF]) {
+ const max = prefs[TLS_MAX_PREF] || getIntPref(TLS_MAX_PREF);
+ if (max < prefs[TLS_MIN_PREF]) {
+ throw new ExtensionError(
+ `Setting TLS min version grater than the max version is not allowed.`
+ );
+ }
+ }
+
+ // If maximum has passed and it's lower than the min value.
+ else if (prefs[TLS_MAX_PREF]) {
+ const min = getIntPref(TLS_MIN_PREF);
+ if (min > prefs[TLS_MAX_PREF]) {
+ throw new ExtensionError(
+ `Setting TLS max version lower than the min version is not allowed.`
+ );
+ }
+ }
+
+ return prefs;
+ },
+
+ getCallback() {
+ function tlsVersionToString(pref) {
+ const value = getIntPref(pref);
+ const version = TLS_VERSIONS.find(a => a.version === value);
+ if (version) {
+ return version.name;
+ }
+ return "unknown";
+ }
+
+ return {
+ minimum: tlsVersionToString(TLS_MIN_PREF),
+ maximum: tlsVersionToString(TLS_MAX_PREF),
+ };
+ },
+
+ validate(extension) {
+ if (!extension.isPrivileged) {
+ throw new ExtensionError(
+ "tlsVersionRestriction can be set by privileged extensions only."
+ );
+ }
+ },
+});
+
+this.privacy = class extends ExtensionAPI {
+ primeListener(event, fire) {
+ let { extension } = this;
+ let listener = getPrimedSettingsListener({
+ extension,
+ name: event,
+ });
+ return listener(fire);
+ }
+
+ getAPI(context) {
+ function makeSettingsAPI(name) {
+ return getSettingsAPI({
+ context,
+ module: "privacy",
+ name,
+ });
+ }
+
+ return {
+ privacy: {
+ network: {
+ networkPredictionEnabled: makeSettingsAPI(
+ "network.networkPredictionEnabled"
+ ),
+ globalPrivacyControl: makeSettingsAPI("network.globalPrivacyControl"),
+ httpsOnlyMode: makeSettingsAPI("network.httpsOnlyMode"),
+ peerConnectionEnabled: makeSettingsAPI(
+ "network.peerConnectionEnabled"
+ ),
+ webRTCIPHandlingPolicy: makeSettingsAPI(
+ "network.webRTCIPHandlingPolicy"
+ ),
+ tlsVersionRestriction: makeSettingsAPI(
+ "network.tlsVersionRestriction"
+ ),
+ },
+
+ services: {
+ passwordSavingEnabled: makeSettingsAPI(
+ "services.passwordSavingEnabled"
+ ),
+ },
+
+ websites: {
+ cookieConfig: makeSettingsAPI("websites.cookieConfig"),
+ firstPartyIsolate: makeSettingsAPI("websites.firstPartyIsolate"),
+ hyperlinkAuditingEnabled: makeSettingsAPI(
+ "websites.hyperlinkAuditingEnabled"
+ ),
+ referrersEnabled: makeSettingsAPI("websites.referrersEnabled"),
+ resistFingerprinting: makeSettingsAPI(
+ "websites.resistFingerprinting"
+ ),
+ trackingProtectionMode: makeSettingsAPI(
+ "websites.trackingProtectionMode"
+ ),
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-protocolHandlers.js b/toolkit/components/extensions/parent/ext-protocolHandlers.js
new file mode 100644
index 0000000000..36cdf25d42
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-protocolHandlers.js
@@ -0,0 +1,100 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "handlerService",
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "protocolService",
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ "nsIExternalProtocolService"
+);
+
+const hasHandlerApp = handlerConfig => {
+ let protoInfo = protocolService.getProtocolHandlerInfo(
+ handlerConfig.protocol
+ );
+ let appHandlers = protoInfo.possibleApplicationHandlers;
+ for (let i = 0; i < appHandlers.length; i++) {
+ let handler = appHandlers.queryElementAt(i, Ci.nsISupports);
+ if (
+ handler instanceof Ci.nsIWebHandlerApp &&
+ handler.uriTemplate === handlerConfig.uriTemplate
+ ) {
+ return true;
+ }
+ }
+ return false;
+};
+
+this.protocolHandlers = class extends ExtensionAPI {
+ onManifestEntry(entryName) {
+ let { extension } = this;
+ let { manifest } = extension;
+
+ for (let handlerConfig of manifest.protocol_handlers) {
+ if (hasHandlerApp(handlerConfig)) {
+ continue;
+ }
+
+ let handler = Cc[
+ "@mozilla.org/uriloader/web-handler-app;1"
+ ].createInstance(Ci.nsIWebHandlerApp);
+ handler.name = handlerConfig.name;
+ handler.uriTemplate = handlerConfig.uriTemplate;
+
+ let protoInfo = protocolService.getProtocolHandlerInfo(
+ handlerConfig.protocol
+ );
+ let handlers = protoInfo.possibleApplicationHandlers;
+ if (protoInfo.preferredApplicationHandler || handlers.length) {
+ protoInfo.alwaysAskBeforeHandling = true;
+ } else {
+ protoInfo.preferredApplicationHandler = handler;
+ protoInfo.alwaysAskBeforeHandling = false;
+ }
+ handlers.appendElement(handler);
+ handlerService.store(protoInfo);
+ }
+ }
+
+ onShutdown(isAppShutdown) {
+ let { extension } = this;
+ let { manifest } = extension;
+
+ if (isAppShutdown) {
+ return;
+ }
+
+ for (let handlerConfig of manifest.protocol_handlers) {
+ let protoInfo = protocolService.getProtocolHandlerInfo(
+ handlerConfig.protocol
+ );
+ let appHandlers = protoInfo.possibleApplicationHandlers;
+ for (let i = 0; i < appHandlers.length; i++) {
+ let handler = appHandlers.queryElementAt(i, Ci.nsISupports);
+ if (
+ handler instanceof Ci.nsIWebHandlerApp &&
+ handler.uriTemplate === handlerConfig.uriTemplate
+ ) {
+ appHandlers.removeElementAt(i);
+ if (protoInfo.preferredApplicationHandler === handler) {
+ protoInfo.preferredApplicationHandler = null;
+ protoInfo.alwaysAskBeforeHandling = true;
+ }
+ handlerService.store(protoInfo);
+ break;
+ }
+ }
+ }
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-proxy.js b/toolkit/components/extensions/parent/ext-proxy.js
new file mode 100644
index 0000000000..86505f9423
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-proxy.js
@@ -0,0 +1,335 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ProxyChannelFilter: "resource://gre/modules/ProxyChannelFilter.sys.mjs",
+});
+
+// Delayed wakeup is tied to ExtensionParent.browserPaintedPromise, which is
+// when the first browser window has been painted. On Android, parts of the
+// browser can trigger requests without browser "window" (geckoview.xhtml).
+// Therefore we allow such proxy events to trigger wakeup.
+// On desktop, we do not wake up early, to minimize the amount of work before
+// a browser window is painted.
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "isEarlyWakeupOnRequestEnabled",
+ "extensions.webextensions.early_background_wakeup_on_request",
+ false
+);
+var { ExtensionPreferencesManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs"
+);
+
+var { ExtensionError } = ExtensionUtils;
+var { getSettingsAPI } = ExtensionPreferencesManager;
+
+const proxySvc = Ci.nsIProtocolProxyService;
+
+const PROXY_TYPES_MAP = new Map([
+ ["none", proxySvc.PROXYCONFIG_DIRECT],
+ ["autoDetect", proxySvc.PROXYCONFIG_WPAD],
+ ["system", proxySvc.PROXYCONFIG_SYSTEM],
+ ["manual", proxySvc.PROXYCONFIG_MANUAL],
+ ["autoConfig", proxySvc.PROXYCONFIG_PAC],
+]);
+
+const DEFAULT_PORTS = new Map([
+ ["http", 80],
+ ["ssl", 443],
+ ["socks", 1080],
+]);
+
+ExtensionPreferencesManager.addSetting("proxy.settings", {
+ permission: "proxy",
+ prefNames: [
+ "network.proxy.type",
+ "network.proxy.http",
+ "network.proxy.http_port",
+ "network.proxy.share_proxy_settings",
+ "network.proxy.ssl",
+ "network.proxy.ssl_port",
+ "network.proxy.socks",
+ "network.proxy.socks_port",
+ "network.proxy.socks_version",
+ "network.proxy.socks_remote_dns",
+ "network.proxy.no_proxies_on",
+ "network.proxy.autoconfig_url",
+ "signon.autologin.proxy",
+ "network.http.proxy.respect-be-conservative",
+ ],
+
+ setCallback(value) {
+ let prefs = {
+ "network.proxy.type": PROXY_TYPES_MAP.get(value.proxyType),
+ "signon.autologin.proxy": value.autoLogin,
+ "network.proxy.socks_remote_dns": value.proxyDNS,
+ "network.proxy.autoconfig_url": value.autoConfigUrl,
+ "network.proxy.share_proxy_settings": value.httpProxyAll,
+ "network.proxy.socks_version": value.socksVersion,
+ "network.proxy.no_proxies_on": value.passthrough,
+ "network.http.proxy.respect-be-conservative": value.respectBeConservative,
+ };
+
+ for (let prop of ["http", "ssl", "socks"]) {
+ if (value[prop]) {
+ let url = new URL(`http://${value[prop]}`);
+ prefs[`network.proxy.${prop}`] = url.hostname;
+ // Only fall back to defaults if no port provided.
+ let [, rawPort] = value[prop].split(":");
+ let port = parseInt(rawPort, 10) || DEFAULT_PORTS.get(prop);
+ prefs[`network.proxy.${prop}_port`] = port;
+ }
+ }
+
+ return prefs;
+ },
+});
+
+function registerProxyFilterEvent(
+ context,
+ extension,
+ fire,
+ filterProps,
+ extraInfoSpec = []
+) {
+ let listener = data => {
+ if (isEarlyWakeupOnRequestEnabled && fire.wakeup) {
+ // Starts the background script if it has not started, no-op otherwise.
+ extension.emit("start-background-script");
+ }
+ return fire.sync(data);
+ };
+
+ let filter = { ...filterProps };
+ if (filter.urls) {
+ let perms = new MatchPatternSet([
+ ...extension.allowedOrigins.patterns,
+ ...extension.optionalOrigins.patterns,
+ ]);
+ filter.urls = new MatchPatternSet(filter.urls);
+
+ if (!perms.overlapsAll(filter.urls)) {
+ Cu.reportError(
+ "The proxy.onRequest filter doesn't overlap with host permissions."
+ );
+ }
+ }
+
+ let proxyFilter = new ProxyChannelFilter(
+ context,
+ extension,
+ listener,
+ filter,
+ extraInfoSpec
+ );
+ return {
+ unregister: () => {
+ proxyFilter.destroy();
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ proxyFilter.context = _context;
+ },
+ };
+}
+
+this.proxy = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ onRequest({ fire, context }, params) {
+ return registerProxyFilterEvent(context, this.extension, fire, ...params);
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ let self = this;
+
+ return {
+ proxy: {
+ onRequest: new EventManager({
+ context,
+ module: "proxy",
+ event: "onRequest",
+ extensionApi: self,
+ }).api(),
+
+ // Leaving as non-persistent. By itself it's not useful since proxy-error
+ // is emitted from the proxy filter.
+ onError: new EventManager({
+ context,
+ name: "proxy.onError",
+ register: fire => {
+ let listener = (name, error) => {
+ fire.async(error);
+ };
+ extension.on("proxy-error", listener);
+ return () => {
+ extension.off("proxy-error", listener);
+ };
+ },
+ }).api(),
+
+ settings: Object.assign(
+ getSettingsAPI({
+ context,
+ name: "proxy.settings",
+ callback() {
+ let prefValue = Services.prefs.getIntPref("network.proxy.type");
+ let proxyConfig = {
+ proxyType: Array.from(PROXY_TYPES_MAP.entries()).find(
+ entry => entry[1] === prefValue
+ )[0],
+ autoConfigUrl: Services.prefs.getCharPref(
+ "network.proxy.autoconfig_url"
+ ),
+ autoLogin: Services.prefs.getBoolPref("signon.autologin.proxy"),
+ proxyDNS: Services.prefs.getBoolPref(
+ "network.proxy.socks_remote_dns"
+ ),
+ httpProxyAll: Services.prefs.getBoolPref(
+ "network.proxy.share_proxy_settings"
+ ),
+ socksVersion: Services.prefs.getIntPref(
+ "network.proxy.socks_version"
+ ),
+ passthrough: Services.prefs.getCharPref(
+ "network.proxy.no_proxies_on"
+ ),
+ };
+
+ if (extension.isPrivileged) {
+ proxyConfig.respectBeConservative = Services.prefs.getBoolPref(
+ "network.http.proxy.respect-be-conservative"
+ );
+ }
+
+ for (let prop of ["http", "ssl", "socks"]) {
+ let host = Services.prefs.getCharPref(`network.proxy.${prop}`);
+ let port = Services.prefs.getIntPref(
+ `network.proxy.${prop}_port`
+ );
+ proxyConfig[prop] = port ? `${host}:${port}` : host;
+ }
+
+ return proxyConfig;
+ },
+ // proxy.settings is unsupported on android.
+ validate() {
+ if (AppConstants.platform == "android") {
+ throw new ExtensionError(
+ `proxy.settings is not supported on android.`
+ );
+ }
+ },
+ }),
+ {
+ set: details => {
+ if (AppConstants.platform === "android") {
+ throw new ExtensionError(
+ "proxy.settings is not supported on android."
+ );
+ }
+
+ if (!extension.privateBrowsingAllowed) {
+ throw new ExtensionError(
+ "proxy.settings requires private browsing permission."
+ );
+ }
+
+ if (!Services.policies.isAllowed("changeProxySettings")) {
+ throw new ExtensionError(
+ "Proxy settings are being managed by the Policies manager."
+ );
+ }
+
+ let value = details.value;
+
+ // proxyType is optional and it should default to "system" when missing.
+ if (value.proxyType == null) {
+ value.proxyType = "system";
+ }
+
+ if (!PROXY_TYPES_MAP.has(value.proxyType)) {
+ throw new ExtensionError(
+ `${value.proxyType} is not a valid value for proxyType.`
+ );
+ }
+
+ if (value.httpProxyAll) {
+ // Match what about:preferences does with proxy settings
+ // since the proxy service does not check the value
+ // of share_proxy_settings.
+ value.ssl = value.http;
+ }
+
+ for (let prop of ["http", "ssl", "socks"]) {
+ let host = value[prop];
+ if (host) {
+ try {
+ // Fixup in case a full url is passed.
+ if (host.includes("://")) {
+ value[prop] = new URL(host).host;
+ } else {
+ // Validate the host value.
+ new URL(`http://${host}`);
+ }
+ } catch (e) {
+ throw new ExtensionError(
+ `${value[prop]} is not a valid value for ${prop}.`
+ );
+ }
+ }
+ }
+
+ if (value.proxyType === "autoConfig" || value.autoConfigUrl) {
+ try {
+ new URL(value.autoConfigUrl);
+ } catch (e) {
+ throw new ExtensionError(
+ `${value.autoConfigUrl} is not a valid value for autoConfigUrl.`
+ );
+ }
+ }
+
+ if (value.socksVersion !== undefined) {
+ if (
+ !Number.isInteger(value.socksVersion) ||
+ value.socksVersion < 4 ||
+ value.socksVersion > 5
+ ) {
+ throw new ExtensionError(
+ `${value.socksVersion} is not a valid value for socksVersion.`
+ );
+ }
+ }
+
+ if (
+ value.respectBeConservative !== undefined &&
+ !extension.isPrivileged &&
+ Services.prefs.getBoolPref(
+ "network.http.proxy.respect-be-conservative"
+ ) != value.respectBeConservative
+ ) {
+ throw new ExtensionError(
+ `respectBeConservative can be set by privileged extensions only.`
+ );
+ }
+
+ return ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "proxy.settings",
+ value
+ );
+ },
+ }
+ ),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-runtime.js b/toolkit/components/extensions/parent/ext-runtime.js
new file mode 100644
index 0000000000..f4f9ea6616
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-runtime.js
@@ -0,0 +1,310 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// This file expects tabTracker to be defined in the global scope (e.g.
+// by ext-browser.js or ext-android.js).
+/* global tabTracker */
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gRuntimeTimeout",
+ "extensions.webextensions.runtime.timeout",
+ 5000
+);
+
+this.runtime = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // Despite not being part of PERSISTENT_EVENTS, the following events are
+ // still triggered (after waking up the background context if needed):
+ // - runtime.onConnect
+ // - runtime.onConnectExternal
+ // - runtime.onMessage
+ // - runtime.onMessageExternal
+ // For details, see bug 1852317 and test_ext_eventpage_messaging_wakeup.js.
+
+ onInstalled({ fire }) {
+ let { extension } = this;
+ let temporary = !!extension.addonData.temporarilyInstalled;
+
+ let listener = () => {
+ switch (extension.startupReason) {
+ case "APP_STARTUP":
+ if (AddonManagerPrivate.browserUpdated) {
+ fire.sync({ reason: "browser_update", temporary });
+ }
+ break;
+ case "ADDON_INSTALL":
+ fire.sync({ reason: "install", temporary });
+ break;
+ case "ADDON_UPGRADE":
+ fire.sync({
+ reason: "update",
+ previousVersion: extension.addonData.oldVersion,
+ temporary,
+ });
+ break;
+ }
+ };
+ extension.on("background-first-run", listener);
+ return {
+ unregister() {
+ extension.off("background-first-run", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onUpdateAvailable({ fire }) {
+ let { extension } = this;
+ let instanceID = extension.addonData.instanceID;
+ AddonManager.addUpgradeListener(instanceID, upgrade => {
+ extension.upgrade = upgrade;
+ let details = {
+ version: upgrade.version,
+ };
+ fire.sync(details);
+ });
+ return {
+ unregister() {
+ AddonManager.removeUpgradeListener(instanceID);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onPerformanceWarning({ fire }) {
+ let { extension } = this;
+
+ let observer = (subject, topic) => {
+ let report = subject.QueryInterface(Ci.nsIHangReport);
+
+ if (report?.addonId !== extension.id) {
+ return;
+ }
+
+ const performanceWarningEventDetails = {
+ category: "content_script",
+ severity: "high",
+ description:
+ "Slow extension content script caused a page hang, user was warned.",
+ };
+
+ let scriptBrowser = report.scriptBrowser;
+ let nativeTab =
+ scriptBrowser?.ownerGlobal.gBrowser?.getTabForBrowser(scriptBrowser);
+ if (nativeTab) {
+ performanceWarningEventDetails.tabId = tabTracker.getId(nativeTab);
+ }
+
+ fire.async(performanceWarningEventDetails);
+ };
+
+ Services.obs.addObserver(observer, "process-hang-report");
+ return {
+ unregister: () => {
+ Services.obs.removeObserver(observer, "process-hang-report");
+ },
+ convert(_fire, context) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ return {
+ runtime: {
+ // onStartup is special-cased in ext-backgroundPages to cause
+ // an immediate startup. We do not prime onStartup.
+ onStartup: new EventManager({
+ context,
+ module: "runtime",
+ event: "onStartup",
+ register: fire => {
+ if (context.incognito || extension.startupReason != "APP_STARTUP") {
+ // This event should not fire if we are operating in a private profile.
+ return () => {};
+ }
+ let listener = () => {
+ return fire.sync();
+ };
+
+ extension.on("background-first-run", listener);
+
+ return () => {
+ extension.off("background-first-run", listener);
+ };
+ },
+ }).api(),
+
+ onInstalled: new EventManager({
+ context,
+ module: "runtime",
+ event: "onInstalled",
+ extensionApi: this,
+ }).api(),
+
+ onUpdateAvailable: new EventManager({
+ context,
+ module: "runtime",
+ event: "onUpdateAvailable",
+ extensionApi: this,
+ }).api(),
+
+ onSuspend: new EventManager({
+ context,
+ name: "runtime.onSuspend",
+ resetIdleOnEvent: false,
+ register: fire => {
+ let listener = async () => {
+ let timedOut = false;
+ async function promiseFire() {
+ try {
+ await fire.async();
+ } catch (e) {}
+ }
+ await Promise.race([
+ promiseFire(),
+ ExtensionUtils.promiseTimeout(gRuntimeTimeout).then(() => {
+ timedOut = true;
+ }),
+ ]);
+ if (timedOut) {
+ Cu.reportError(
+ `runtime.onSuspend in ${extension.id} took too long`
+ );
+ }
+ };
+ extension.on("background-script-suspend", listener);
+ return () => {
+ extension.off("background-script-suspend", listener);
+ };
+ },
+ }).api(),
+
+ onSuspendCanceled: new EventManager({
+ context,
+ name: "runtime.onSuspendCanceled",
+ register: fire => {
+ let listener = () => {
+ fire.async();
+ };
+ extension.on("background-script-suspend-canceled", listener);
+ return () => {
+ extension.off("background-script-suspend-canceled", listener);
+ };
+ },
+ }).api(),
+
+ onPerformanceWarning: new EventManager({
+ context,
+ module: "runtime",
+ event: "onPerformanceWarning",
+ extensionApi: this,
+ }).api(),
+
+ reload: async () => {
+ if (extension.upgrade) {
+ // If there is a pending update, install it now.
+ extension.upgrade.install();
+ } else {
+ // Otherwise, reload the current extension.
+ let addon = await AddonManager.getAddonByID(extension.id);
+ addon.reload();
+ }
+ },
+
+ get lastError() {
+ // TODO(robwu): Figure out how to make sure that errors in the parent
+ // process are propagated to the child process.
+ // lastError should not be accessed from the parent.
+ return context.lastError;
+ },
+
+ getBrowserInfo: function () {
+ const { name, vendor, version, appBuildID } = Services.appinfo;
+ const info = { name, vendor, version, buildID: appBuildID };
+ return Promise.resolve(info);
+ },
+
+ getPlatformInfo: function () {
+ return Promise.resolve(ExtensionParent.PlatformInfo);
+ },
+
+ openOptionsPage: function () {
+ if (!extension.manifest.options_ui) {
+ return Promise.reject({ message: "No `options_ui` declared" });
+ }
+
+ // This expects openOptionsPage to be defined in the file using this,
+ // e.g. the browser/ version of ext-runtime.js
+ /* global openOptionsPage:false */
+ return openOptionsPage(extension).then(() => {});
+ },
+
+ setUninstallURL: function (url) {
+ if (url === null || url.length === 0) {
+ extension.uninstallURL = null;
+ return Promise.resolve();
+ }
+
+ let uri;
+ try {
+ uri = new URL(url);
+ } catch (e) {
+ return Promise.reject({
+ message: `Invalid URL: ${JSON.stringify(url)}`,
+ });
+ }
+
+ if (uri.protocol != "http:" && uri.protocol != "https:") {
+ return Promise.reject({
+ message: "url must have the scheme http or https",
+ });
+ }
+
+ extension.uninstallURL = url;
+ return Promise.resolve();
+ },
+
+ // This function is not exposed to the extension js code and it is only
+ // used by the alert function redefined into the background pages to be
+ // able to open the BrowserConsole from the main process.
+ openBrowserConsole() {
+ if (AppConstants.platform !== "android") {
+ DevToolsShim.openBrowserConsole();
+ }
+ },
+
+ async internalWakeupBackground() {
+ const { background } = extension.manifest;
+ if (
+ background &&
+ (background.page || background.scripts) &&
+ // Note: if background.service_worker is specified, it takes
+ // precedence over page/scripts, and persistentBackground is false.
+ !extension.persistentBackground
+ ) {
+ await extension.wakeupBackground();
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-scripting.js b/toolkit/components/extensions/parent/ext-scripting.js
new file mode 100644
index 0000000000..baa05f3aad
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-scripting.js
@@ -0,0 +1,365 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ ExtensionScriptingStore,
+ makeInternalContentScript,
+ makePublicContentScript,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionScriptingStore.sys.mjs"
+);
+
+var { ExtensionError, parseMatchPatterns } = ExtensionUtils;
+
+// Map<Extension, Map<string, number>> - For each extension, we keep a map
+// where the key is a user-provided script ID, the value is an internal
+// generated integer.
+const gScriptIdsMap = new Map();
+
+/**
+ * Inserts a script or style in the given tab, and returns a promise which
+ * resolves when the operation has completed.
+ *
+ * @param {BaseContext} context
+ * The extension context for which to perform the injection.
+ * @param {object} details
+ * The details object, specifying what to inject, where, and when.
+ * Derived from the ScriptInjection or CSSInjection types.
+ * @param {string} kind
+ * The kind of data being injected. Possible choices: "js" or "css".
+ * @param {string} method
+ * The name of the method which was called to trigger the injection.
+ * Used to generate appropriate error messages on failure.
+ *
+ * @returns {Promise}
+ * Resolves to the result of the execution, once it has completed.
+ */
+const execute = (context, details, kind, method) => {
+ const { tabManager } = context.extension;
+
+ let options = {
+ jsPaths: [],
+ cssPaths: [],
+ removeCSS: method == "removeCSS",
+ extensionId: context.extension.id,
+ };
+
+ const { tabId, frameIds, allFrames } = details.target;
+ const tab = tabManager.get(tabId);
+
+ options.hasActiveTabPermission = tab.hasActiveTabPermission;
+ options.matches = tab.extension.allowedOrigins.patterns.map(
+ host => host.pattern
+ );
+
+ const codeKey = kind === "js" ? "func" : "css";
+ if ((details.files === null) == (details[codeKey] === null)) {
+ throw new ExtensionError(
+ `Exactly one of files and ${codeKey} must be specified.`
+ );
+ }
+
+ if (details[codeKey]) {
+ options[`${kind}Code`] = details[codeKey];
+ }
+
+ if (details.files) {
+ for (const file of details.files) {
+ let url = context.uri.resolve(file);
+ if (!tab.extension.isExtensionURL(url)) {
+ throw new ExtensionError(
+ "Files to be injected must be within the extension"
+ );
+ }
+ options[`${kind}Paths`].push(url);
+ }
+ }
+
+ if (allFrames && frameIds) {
+ throw new ExtensionError("Cannot specify both 'allFrames' and 'frameIds'.");
+ }
+
+ if (allFrames) {
+ options.allFrames = allFrames;
+ } else if (frameIds) {
+ options.frameIds = frameIds;
+ } else {
+ options.frameIds = [0];
+ }
+
+ options.runAt = details.injectImmediately
+ ? "document_start"
+ : "document_idle";
+ options.matchAboutBlank = true;
+ options.wantReturnValue = true;
+ // With this option set to `true`, we'll receive executeScript() results with
+ // `frameId/result` properties and an `error` property will also be returned
+ // in case of an error.
+ options.returnResultsWithFrameIds = kind === "js";
+
+ if (details.origin) {
+ options.cssOrigin = details.origin.toLowerCase();
+ } else {
+ options.cssOrigin = "author";
+ }
+
+ // There is no need to execute anything when we have an empty list of frame
+ // IDs because (1) it isn't invalid and (2) nothing will get executed.
+ if (options.frameIds && options.frameIds.length === 0) {
+ return [];
+ }
+
+ // This function is derived from `_execute()` in `parent/ext-tabs-base.js`,
+ // make sure to keep both in sync when relevant.
+ return tab.queryContent("Execute", options);
+};
+
+const ensureValidScriptId = id => {
+ if (!id.length || id.startsWith("_")) {
+ throw new ExtensionError("Invalid content script id.");
+ }
+};
+
+const ensureValidScriptParams = (extension, script) => {
+ if (!script.js?.length && !script.css?.length) {
+ throw new ExtensionError("At least one js or css must be specified.");
+ }
+
+ if (!script.matches?.length) {
+ throw new ExtensionError("matches must be specified.");
+ }
+
+ // This will throw if a match pattern is invalid.
+ parseMatchPatterns(script.matches, {
+ // This only works with MV2, not MV3. See Bug 1780507 for more information.
+ restrictSchemes: extension.restrictSchemes,
+ });
+
+ if (script.excludeMatches) {
+ // This will throw if a match pattern is invalid.
+ parseMatchPatterns(script.excludeMatches, {
+ // This only works with MV2, not MV3. See Bug 1780507 for more information.
+ restrictSchemes: extension.restrictSchemes,
+ });
+ }
+};
+
+this.scripting = class extends ExtensionAPI {
+ constructor(extension) {
+ super(extension);
+
+ // We initialize the scriptIdsMap for the extension with the scriptIds of
+ // the store because this store initializes the extension before we
+ // construct the scripting API here (and we need those IDs for some of the
+ // API methods below).
+ gScriptIdsMap.set(
+ extension,
+ ExtensionScriptingStore.getInitialScriptIdsMap(extension)
+ );
+ }
+
+ onShutdown() {
+ // When the extension is unloaded, the following happens:
+ //
+ // 1. The shared memory is cleared in the parent, see [1]
+ // 2. The policy is marked as invalid, see [2]
+ //
+ // The following are not explicitly cleaned up:
+ //
+ // - `extension.registeredContentScripts
+ // - `ExtensionProcessScript.registeredContentScripts` +
+ // `policy.contentScripts` (via `policy.unregisterContentScripts`)
+ //
+ // This means the script won't run again, but there is still potential for
+ // memory leaks if there is a reference to `extension` or `policy`
+ // somewhere.
+ //
+ // [1]: https://searchfox.org/mozilla-central/rev/211649f071259c4c733b4cafa94c44481c5caacc/toolkit/components/extensions/Extension.jsm#2974-2976
+ // [2]: https://searchfox.org/mozilla-central/rev/211649f071259c4c733b4cafa94c44481c5caacc/toolkit/components/extensions/ExtensionProcessScript.jsm#239
+
+ gScriptIdsMap.delete(this.extension);
+ }
+
+ getAPI(context) {
+ const { extension } = context;
+
+ return {
+ scripting: {
+ executeScriptInternal: async details => {
+ return execute(context, details, "js", "executeScript");
+ },
+
+ insertCSS: async details => {
+ return execute(context, details, "css", "insertCSS").then(() => {});
+ },
+
+ removeCSS: async details => {
+ return execute(context, details, "css", "removeCSS").then(() => {});
+ },
+
+ registerContentScripts: async scripts => {
+ // Map<string, number>
+ const scriptIdsMap = gScriptIdsMap.get(extension);
+ // Map<string, { scriptId: number, options: Object }>
+ const scriptsToRegister = new Map();
+
+ for (const script of scripts) {
+ ensureValidScriptId(script.id);
+
+ if (scriptIdsMap.has(script.id)) {
+ throw new ExtensionError(
+ `Content script with id "${script.id}" is already registered.`
+ );
+ }
+
+ if (scriptsToRegister.has(script.id)) {
+ throw new ExtensionError(
+ `Script ID "${script.id}" found more than once in 'scripts' array.`
+ );
+ }
+
+ ensureValidScriptParams(extension, script);
+
+ scriptsToRegister.set(
+ script.id,
+ makeInternalContentScript(extension, script)
+ );
+ }
+
+ for (const [id, { scriptId, options }] of scriptsToRegister) {
+ scriptIdsMap.set(id, scriptId);
+ extension.registeredContentScripts.set(scriptId, options);
+ }
+ extension.updateContentScripts();
+
+ ExtensionScriptingStore.persistAll(extension);
+
+ await extension.broadcast("Extension:RegisterContentScripts", {
+ id: extension.id,
+ scripts: Array.from(scriptsToRegister.values()),
+ });
+ },
+
+ getRegisteredContentScripts: async details => {
+ // Map<string, number>
+ const scriptIdsMap = gScriptIdsMap.get(extension);
+
+ return Array.from(scriptIdsMap.entries())
+ .filter(
+ ([id, scriptId]) => !details?.ids || details.ids.includes(id)
+ )
+ .map(([id, scriptId]) => {
+ const options = extension.registeredContentScripts.get(scriptId);
+
+ return makePublicContentScript(extension, options);
+ });
+ },
+
+ unregisterContentScripts: async details => {
+ // Map<string, number>
+ const scriptIdsMap = gScriptIdsMap.get(extension);
+
+ let ids = [];
+
+ if (details?.ids) {
+ for (const id of details.ids) {
+ ensureValidScriptId(id);
+
+ if (!scriptIdsMap.has(id)) {
+ throw new ExtensionError(
+ `Content script with id "${id}" does not exist.`
+ );
+ }
+ }
+
+ ids = details.ids;
+ } else {
+ ids = Array.from(scriptIdsMap.keys());
+ }
+
+ if (ids.length === 0) {
+ return;
+ }
+
+ const scriptIds = [];
+ for (const id of ids) {
+ const scriptId = scriptIdsMap.get(id);
+
+ extension.registeredContentScripts.delete(scriptId);
+ scriptIdsMap.delete(id);
+ scriptIds.push(scriptId);
+ }
+ extension.updateContentScripts();
+
+ ExtensionScriptingStore.persistAll(extension);
+
+ await extension.broadcast("Extension:UnregisterContentScripts", {
+ id: extension.id,
+ scriptIds,
+ });
+ },
+
+ updateContentScripts: async scripts => {
+ // Map<string, number>
+ const scriptIdsMap = gScriptIdsMap.get(extension);
+ // Map<string, { scriptId: number, options: Object }>
+ const scriptsToUpdate = new Map();
+
+ for (const script of scripts) {
+ ensureValidScriptId(script.id);
+
+ if (!scriptIdsMap.has(script.id)) {
+ throw new ExtensionError(
+ `Content script with id "${script.id}" does not exist.`
+ );
+ }
+
+ if (scriptsToUpdate.has(script.id)) {
+ throw new ExtensionError(
+ `Script ID "${script.id}" found more than once in 'scripts' array.`
+ );
+ }
+
+ // Retrieve the existing script options.
+ const scriptId = scriptIdsMap.get(script.id);
+ const options = extension.registeredContentScripts.get(scriptId);
+
+ // Use existing values if not specified in the update.
+ script.allFrames ??= options.allFrames;
+ script.css ??= options.cssPaths;
+ script.excludeMatches ??= options.excludeMatches;
+ script.js ??= options.jsPaths;
+ script.matches ??= options.matches;
+ script.runAt ??= options.runAt;
+ script.persistAcrossSessions ??= options.persistAcrossSessions;
+
+ ensureValidScriptParams(extension, script);
+
+ scriptsToUpdate.set(script.id, {
+ ...makeInternalContentScript(extension, script),
+ // Re-use internal script ID.
+ scriptId,
+ });
+ }
+
+ for (const { scriptId, options } of scriptsToUpdate.values()) {
+ extension.registeredContentScripts.set(scriptId, options);
+ }
+ extension.updateContentScripts();
+
+ ExtensionScriptingStore.persistAll(extension);
+
+ await extension.broadcast("Extension:UpdateContentScripts", {
+ id: extension.id,
+ scripts: Array.from(scriptsToUpdate.values()),
+ });
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-storage.js b/toolkit/components/extensions/parent/ext-storage.js
new file mode 100644
index 0000000000..350ca0acfa
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-storage.js
@@ -0,0 +1,366 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs",
+ ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs",
+ NativeManifests: "resource://gre/modules/NativeManifests.sys.mjs",
+ extensionStorageSession: "resource://gre/modules/ExtensionStorage.sys.mjs",
+});
+
+var { ExtensionError } = ExtensionUtils;
+var { ignoreEvent } = ExtensionCommon;
+
+ChromeUtils.defineLazyGetter(this, "extensionStorageSync", () => {
+ // TODO bug 1637465: Remove Kinto-based implementation.
+ if (Services.prefs.getBoolPref("webextensions.storage.sync.kinto")) {
+ const { extensionStorageSyncKinto } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs"
+ );
+ return extensionStorageSyncKinto;
+ }
+
+ const { extensionStorageSync } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageSync.sys.mjs"
+ );
+ return extensionStorageSync;
+});
+
+const enforceNoTemporaryAddon = extensionId => {
+ const EXCEPTION_MESSAGE =
+ "The storage API will not work with a temporary addon ID. " +
+ "Please add an explicit addon ID to your manifest. " +
+ "For more information see https://mzl.la/3lPk1aE.";
+ if (AddonManagerPrivate.isTemporaryInstallID(extensionId)) {
+ throw new ExtensionError(EXCEPTION_MESSAGE);
+ }
+};
+
+// WeakMap[extension -> Promise<SerializableMap?>]
+const managedStorage = new WeakMap();
+
+const lookupManagedStorage = async (extensionId, context) => {
+ if (Services.policies) {
+ let extensionPolicy = Services.policies.getExtensionPolicy(extensionId);
+ if (extensionPolicy) {
+ return ExtensionStorage._serializableMap(extensionPolicy);
+ }
+ }
+ let info = await NativeManifests.lookupManifest(
+ "storage",
+ extensionId,
+ context
+ );
+ if (info) {
+ return ExtensionStorage._serializableMap(info.manifest.data);
+ }
+ return null;
+};
+
+this.storage = class extends ExtensionAPIPersistent {
+ constructor(extension) {
+ super(extension);
+
+ const messageName = `Extension:StorageLocalOnChanged:${extension.uuid}`;
+ Services.ppmm.addMessageListener(messageName, this);
+ this.clearStorageChangedListener = () => {
+ Services.ppmm.removeMessageListener(messageName, this);
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ onChanged({ context, fire }) {
+ let unregisterLocal = this.registerLocalChangedListener(changes => {
+ // |changes| is already serialized. Send the raw value, so that it can
+ // be deserialized by the onChanged handler in child/ext-storage.js.
+ fire.raw(changes, "local");
+ });
+
+ // Session storage is not exposed to content scripts, and `context` does
+ // not exist while setting up persistent listeners for an event page.
+ let unregisterSession;
+ if (
+ !context ||
+ context.envType === "addon_parent" ||
+ context.envType === "devtools_parent"
+ ) {
+ unregisterSession = extensionStorageSession.registerListener(
+ this.extension,
+ changes => fire.async(changes, "session")
+ );
+ }
+
+ let unregisterSync = this.registerSyncChangedListener(changes => {
+ fire.async(changes, "sync");
+ });
+
+ return {
+ unregister() {
+ unregisterLocal();
+ unregisterSession?.();
+ unregisterSync();
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ "local.onChanged"({ fire }) {
+ let unregister = this.registerLocalChangedListener(changes => {
+ // |changes| is already serialized. Send the raw value, so that it can
+ // be deserialized by the onChanged handler in child/ext-storage.js.
+ fire.raw(changes);
+ });
+ return {
+ unregister,
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ "session.onChanged"({ fire }) {
+ let unregister = extensionStorageSession.registerListener(
+ this.extension,
+ changes => fire.async(changes)
+ );
+
+ return {
+ unregister,
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ "sync.onChanged"({ fire }) {
+ let unregister = this.registerSyncChangedListener(changes => {
+ fire.async(changes);
+ });
+ return {
+ unregister,
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ };
+
+ registerLocalChangedListener(onStorageLocalChanged) {
+ const extensionId = this.extension.id;
+ ExtensionStorage.addOnChangedListener(extensionId, onStorageLocalChanged);
+ ExtensionStorageIDB.addOnChangedListener(
+ extensionId,
+ onStorageLocalChanged
+ );
+ return () => {
+ ExtensionStorage.removeOnChangedListener(
+ extensionId,
+ onStorageLocalChanged
+ );
+ ExtensionStorageIDB.removeOnChangedListener(
+ extensionId,
+ onStorageLocalChanged
+ );
+ };
+ }
+
+ registerSyncChangedListener(onStorageSyncChanged) {
+ const { extension } = this;
+ let closeCallback;
+ // The ExtensionStorageSyncKinto implementation of addOnChangedListener
+ // relies on context.callOnClose (via ExtensionStorageSync.registerInUse)
+ // to keep track of active users of the storage. We don't need to pass a
+ // real BaseContext instance, a dummy object with the callOnClose method
+ // works too. This enables us to register a primed listener before any
+ // context is available.
+ // TODO bug 1637465: Remove this when the Kinto backend is dropped.
+ let dummyContextForKinto = {
+ callOnClose({ close }) {
+ closeCallback = close;
+ },
+ };
+ extensionStorageSync.addOnChangedListener(
+ extension,
+ onStorageSyncChanged,
+ dummyContextForKinto
+ );
+ return () => {
+ extensionStorageSync.removeOnChangedListener(
+ extension,
+ onStorageSyncChanged
+ );
+ // May be void if ExtensionStorageSyncKinto.jsm was not used.
+ // ExtensionStorageSync.jsm does not use the context.
+ closeCallback?.();
+ };
+ }
+
+ onShutdown() {
+ const { clearStorageChangedListener } = this;
+ this.clearStorageChangedListener = null;
+
+ if (clearStorageChangedListener) {
+ clearStorageChangedListener();
+ }
+ }
+
+ receiveMessage({ name, data }) {
+ if (name !== `Extension:StorageLocalOnChanged:${this.extension.uuid}`) {
+ return;
+ }
+
+ ExtensionStorageIDB.notifyListeners(this.extension.id, data);
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ storage: {
+ local: {
+ async callMethodInParentProcess(method, args) {
+ const res = await ExtensionStorageIDB.selectBackend({ extension });
+ if (!res.backendEnabled) {
+ return ExtensionStorage[method](extension.id, ...args);
+ }
+
+ const persisted = extension.hasPermission("unlimitedStorage");
+ const db = await ExtensionStorageIDB.open(
+ res.storagePrincipal.deserialize(this, true),
+ persisted
+ );
+ try {
+ const changes = await db[method](...args);
+ if (changes) {
+ ExtensionStorageIDB.notifyListeners(extension.id, changes);
+ }
+ return changes;
+ } catch (err) {
+ const normalizedError = ExtensionStorageIDB.normalizeStorageError(
+ {
+ error: err,
+ extensionId: extension.id,
+ storageMethod: method,
+ }
+ ).message;
+ return Promise.reject({
+ message: String(normalizedError),
+ });
+ }
+ },
+ // Private storage.local JSONFile backend methods (used internally by the child
+ // ext-storage.js module).
+ JSONFileBackend: {
+ get(spec) {
+ return ExtensionStorage.get(extension.id, spec);
+ },
+ set(items) {
+ return ExtensionStorage.set(extension.id, items);
+ },
+ remove(keys) {
+ return ExtensionStorage.remove(extension.id, keys);
+ },
+ clear() {
+ return ExtensionStorage.clear(extension.id);
+ },
+ },
+ // Private storage.local IDB backend methods (used internally by the child ext-storage.js
+ // module).
+ IDBBackend: {
+ selectBackend() {
+ return ExtensionStorageIDB.selectBackend(context);
+ },
+ },
+ onChanged: new EventManager({
+ context,
+ module: "storage",
+ event: "local.onChanged",
+ extensionApi: this,
+ }).api(),
+ },
+
+ session: {
+ get(items) {
+ return extensionStorageSession.get(extension, items);
+ },
+ set(items) {
+ extensionStorageSession.set(extension, items);
+ },
+ remove(keys) {
+ extensionStorageSession.remove(extension, keys);
+ },
+ clear() {
+ extensionStorageSession.clear(extension);
+ },
+ onChanged: new EventManager({
+ context,
+ module: "storage",
+ event: "session.onChanged",
+ extensionApi: this,
+ }).api(),
+ },
+
+ sync: {
+ get(spec) {
+ enforceNoTemporaryAddon(extension.id);
+ return extensionStorageSync.get(extension, spec, context);
+ },
+ set(items) {
+ enforceNoTemporaryAddon(extension.id);
+ return extensionStorageSync.set(extension, items, context);
+ },
+ remove(keys) {
+ enforceNoTemporaryAddon(extension.id);
+ return extensionStorageSync.remove(extension, keys, context);
+ },
+ clear() {
+ enforceNoTemporaryAddon(extension.id);
+ return extensionStorageSync.clear(extension, context);
+ },
+ getBytesInUse(keys) {
+ enforceNoTemporaryAddon(extension.id);
+ return extensionStorageSync.getBytesInUse(extension, keys, context);
+ },
+ onChanged: new EventManager({
+ context,
+ module: "storage",
+ event: "sync.onChanged",
+ extensionApi: this,
+ }).api(),
+ },
+
+ managed: {
+ async get(keys) {
+ enforceNoTemporaryAddon(extension.id);
+ let lookup = managedStorage.get(extension);
+
+ if (!lookup) {
+ lookup = lookupManagedStorage(extension.id, context);
+ managedStorage.set(extension, lookup);
+ }
+
+ let data = await lookup;
+ if (!data) {
+ return Promise.reject({
+ message: "Managed storage manifest not found",
+ });
+ }
+ return ExtensionStorage._filterProperties(extension.id, data, keys);
+ },
+ // managed storage is currently initialized once.
+ onChanged: ignoreEvent(context, "storage.managed.onChanged"),
+ },
+
+ onChanged: new EventManager({
+ context,
+ module: "storage",
+ event: "onChanged",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-tabs-base.js b/toolkit/components/extensions/parent/ext-tabs-base.js
new file mode 100644
index 0000000000..64ca9c0627
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-tabs-base.js
@@ -0,0 +1,2377 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/* globals EventEmitter */
+
+ChromeUtils.defineESModuleGetters(this, {
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "containersEnabled",
+ "privacy.userContext.enabled"
+);
+
+var { DefaultMap, DefaultWeakMap, ExtensionError, parseMatchPatterns } =
+ ExtensionUtils;
+
+var { defineLazyGetter } = ExtensionCommon;
+
+/**
+ * The platform-specific type of native tab objects, which are wrapped by
+ * TabBase instances.
+ *
+ * @typedef {object | XULElement} NativeTab
+ */
+
+/**
+ * @typedef {object} MutedInfo
+ * @property {boolean} muted
+ * True if the tab is currently muted, false otherwise.
+ * @property {string} [reason]
+ * The reason the tab is muted. Either "user", if the tab was muted by a
+ * user, or "extension", if it was muted by an extension.
+ * @property {string} [extensionId]
+ * If the tab was muted by an extension, contains the internal ID of that
+ * extension.
+ */
+
+/**
+ * A platform-independent base class for extension-specific wrappers around
+ * native tab objects.
+ *
+ * @param {Extension} extension
+ * The extension object for which this wrapper is being created. Used to
+ * determine permissions for access to certain properties and
+ * functionality.
+ * @param {NativeTab} nativeTab
+ * The native tab object which is being wrapped. The type of this object
+ * varies by platform.
+ * @param {integer} id
+ * The numeric ID of this tab object. This ID should be the same for
+ * every extension, and for the lifetime of the tab.
+ */
+class TabBase {
+ constructor(extension, nativeTab, id) {
+ this.extension = extension;
+ this.tabManager = extension.tabManager;
+ this.id = id;
+ this.nativeTab = nativeTab;
+ this.activeTabWindowID = null;
+
+ if (!extension.privateBrowsingAllowed && this._incognito) {
+ throw new ExtensionError(`Invalid tab ID: ${id}`);
+ }
+ }
+
+ /**
+ * Capture the visible area of this tab, and return the result as a data: URI.
+ *
+ * @param {BaseContext} context
+ * The extension context for which to perform the capture.
+ * @param {number} zoom
+ * The current zoom for the page.
+ * @param {object} [options]
+ * The options with which to perform the capture.
+ * @param {string} [options.format = "png"]
+ * The image format in which to encode the captured data. May be one of
+ * "png" or "jpeg".
+ * @param {integer} [options.quality = 92]
+ * The quality at which to encode the captured image data, ranging from
+ * 0 to 100. Has no effect for the "png" format.
+ * @param {DOMRectInit} [options.rect]
+ * Area of the document to render, in CSS pixels, relative to the page.
+ * If null, the currently visible viewport is rendered.
+ * @param {number} [options.scale]
+ * The scale to render at, defaults to devicePixelRatio.
+ * @returns {Promise<string>}
+ */
+ async capture(context, zoom, options) {
+ let win = this.browser.ownerGlobal;
+ let scale = options?.scale || win.devicePixelRatio;
+ let rect = options?.rect && win.DOMRect.fromRect(options.rect);
+
+ // We only allow mozilla addons to use the resetScrollPosition option,
+ // since it's not standardized.
+ let resetScrollPosition = false;
+ if (!context.extension.restrictSchemes) {
+ resetScrollPosition = !!options?.resetScrollPosition;
+ }
+
+ let wgp = this.browsingContext.currentWindowGlobal;
+ let image = await wgp.drawSnapshot(
+ rect,
+ scale * zoom,
+ "white",
+ resetScrollPosition
+ );
+
+ let doc = Services.appShell.hiddenDOMWindow.document;
+ let canvas = doc.createElement("canvas");
+ canvas.width = image.width;
+ canvas.height = image.height;
+
+ let ctx = canvas.getContext("2d", { alpha: false });
+ ctx.drawImage(image, 0, 0);
+ image.close();
+
+ return canvas.toDataURL(`image/${options?.format}`, options?.quality / 100);
+ }
+
+ /**
+ * @property {integer | null} innerWindowID
+ * The last known innerWindowID loaded into this tab's docShell. This
+ * property must remain in sync with the last known values of
+ * properties such as `url` and `title`. Any operations on the content
+ * of an out-of-process tab will automatically fail if the
+ * innerWindowID of the tab when the message is received does not match
+ * the value of this property when the message was sent.
+ * @readonly
+ */
+ get innerWindowID() {
+ return this.browser.innerWindowID;
+ }
+
+ /**
+ * @property {boolean} hasTabPermission
+ * Returns true if the extension has permission to access restricted
+ * properties of this tab, such as `url`, `title`, and `favIconUrl`.
+ * @readonly
+ */
+ get hasTabPermission() {
+ return (
+ this.extension.hasPermission("tabs") ||
+ this.hasActiveTabPermission ||
+ this.matchesHostPermission
+ );
+ }
+
+ /**
+ * @property {boolean} hasActiveTabPermission
+ * Returns true if the extension has the "activeTab" permission, and
+ * has been granted access to this tab due to a user executing an
+ * extension action.
+ *
+ * If true, the extension may load scripts and CSS into this tab, and
+ * access restricted properties, such as its `url`.
+ * @readonly
+ */
+ get hasActiveTabPermission() {
+ return (
+ (this.extension.originControls ||
+ this.extension.hasPermission("activeTab")) &&
+ this.activeTabWindowID != null &&
+ this.activeTabWindowID === this.innerWindowID
+ );
+ }
+
+ /**
+ * @property {boolean} matchesHostPermission
+ * Returns true if the extensions host permissions match the current tab url.
+ * @readonly
+ */
+ get matchesHostPermission() {
+ return this.extension.allowedOrigins.matches(this._uri);
+ }
+
+ /**
+ * @property {boolean} incognito
+ * Returns true if this is a private browsing tab, false otherwise.
+ * @readonly
+ */
+ get _incognito() {
+ return PrivateBrowsingUtils.isBrowserPrivate(this.browser);
+ }
+
+ /**
+ * @property {string} _url
+ * Returns the current URL of this tab. Does not do any permission
+ * checks.
+ * @readonly
+ */
+ get _url() {
+ return this.browser.currentURI.spec;
+ }
+
+ /**
+ * @property {string | null} url
+ * Returns the current URL of this tab if the extension has permission
+ * to read it, or null otherwise.
+ * @readonly
+ */
+ get url() {
+ if (this.hasTabPermission) {
+ return this._url;
+ }
+ }
+
+ /**
+ * @property {nsIURI} _uri
+ * Returns the current URI of this tab.
+ * @readonly
+ */
+ get _uri() {
+ return this.browser.currentURI;
+ }
+
+ /**
+ * @property {string} _title
+ * Returns the current title of this tab. Does not do any permission
+ * checks.
+ * @readonly
+ */
+ get _title() {
+ return this.browser.contentTitle || this.nativeTab.label;
+ }
+
+ /**
+ * @property {nsIURI | null} title
+ * Returns the current title of this tab if the extension has permission
+ * to read it, or null otherwise.
+ * @readonly
+ */
+ get title() {
+ if (this.hasTabPermission) {
+ return this._title;
+ }
+ }
+
+ /**
+ * @property {string} _favIconUrl
+ * Returns the current favicon URL of this tab. Does not do any permission
+ * checks.
+ * @readonly
+ * @abstract
+ */
+ get _favIconUrl() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {nsIURI | null} faviconUrl
+ * Returns the current faviron URL of this tab if the extension has permission
+ * to read it, or null otherwise.
+ * @readonly
+ */
+ get favIconUrl() {
+ if (this.hasTabPermission) {
+ return this._favIconUrl;
+ }
+ }
+
+ /**
+ * @property {integer} lastAccessed
+ * Returns the last time the tab was accessed as the number of
+ * milliseconds since epoch.
+ * @readonly
+ * @abstract
+ */
+ get lastAccessed() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {boolean} audible
+ * Returns true if the tab is currently playing audio, false otherwise.
+ * @readonly
+ * @abstract
+ */
+ get audible() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {boolean} autoDiscardable
+ * Returns true if the tab can be discarded on memory pressure, false otherwise.
+ * @readonly
+ * @abstract
+ */
+ get autoDiscardable() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {XULElement} browser
+ * Returns the XUL browser for the given tab.
+ * @readonly
+ * @abstract
+ */
+ get browser() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {BrowsingContext} browsingContext
+ * Returns the BrowsingContext for the given tab.
+ * @readonly
+ */
+ get browsingContext() {
+ return this.browser?.browsingContext;
+ }
+
+ /**
+ * @property {FrameLoader} frameLoader
+ * Returns the frameloader for the given tab.
+ * @readonly
+ */
+ get frameLoader() {
+ return this.browser && this.browser.frameLoader;
+ }
+
+ /**
+ * @property {string} cookieStoreId
+ * Returns the cookie store identifier for the given tab.
+ * @readonly
+ * @abstract
+ */
+ get cookieStoreId() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {integer} openerTabId
+ * Returns the ID of the tab which opened this one.
+ * @readonly
+ */
+ get openerTabId() {
+ return null;
+ }
+
+ /**
+ * @property {integer} discarded
+ * Returns true if the tab is discarded.
+ * @readonly
+ * @abstract
+ */
+ get discarded() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {integer} height
+ * Returns the pixel height of the visible area of the tab.
+ * @readonly
+ * @abstract
+ */
+ get height() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {integer} hidden
+ * Returns true if the tab is hidden.
+ * @readonly
+ * @abstract
+ */
+ get hidden() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {integer} index
+ * Returns the index of the tab in its window's tab list.
+ * @readonly
+ * @abstract
+ */
+ get index() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {MutedInfo} mutedInfo
+ * Returns information about the tab's current audio muting status.
+ * @readonly
+ * @abstract
+ */
+ get mutedInfo() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {SharingState} sharingState
+ * Returns object with tab sharingState.
+ * @readonly
+ * @abstract
+ */
+ get sharingState() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {boolean} pinned
+ * Returns true if the tab is pinned, false otherwise.
+ * @readonly
+ * @abstract
+ */
+ get pinned() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {boolean} active
+ * Returns true if the tab is the currently-selected tab, false
+ * otherwise.
+ * @readonly
+ * @abstract
+ */
+ get active() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {boolean} highlighted
+ * Returns true if the tab is highlighted.
+ * @readonly
+ * @abstract
+ */
+ get highlighted() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {string} status
+ * Returns the current loading status of the tab. May be either
+ * "loading" or "complete".
+ * @readonly
+ * @abstract
+ */
+ get status() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {integer} height
+ * Returns the pixel height of the visible area of the tab.
+ * @readonly
+ * @abstract
+ */
+ get width() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {DOMWindow} window
+ * Returns the browser window to which the tab belongs.
+ * @readonly
+ * @abstract
+ */
+ get window() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {integer} window
+ * Returns the numeric ID of the browser window to which the tab belongs.
+ * @readonly
+ * @abstract
+ */
+ get windowId() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {boolean} attention
+ * Returns true if the tab is drawing attention.
+ * @readonly
+ * @abstract
+ */
+ get attention() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {boolean} isArticle
+ * Returns true if the document in the tab can be rendered in reader
+ * mode.
+ * @readonly
+ * @abstract
+ */
+ get isArticle() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {boolean} isInReaderMode
+ * Returns true if the document in the tab is being rendered in reader
+ * mode.
+ * @readonly
+ * @abstract
+ */
+ get isInReaderMode() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {integer} successorTabId
+ * @readonly
+ * @abstract
+ */
+ get successorTabId() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns true if this tab matches the the given query info object. Omitted
+ * or null have no effect on the match.
+ *
+ * @param {object} queryInfo
+ * The query info against which to match.
+ * @param {boolean} [queryInfo.active]
+ * Matches against the exact value of the tab's `active` attribute.
+ * @param {boolean} [queryInfo.audible]
+ * Matches against the exact value of the tab's `audible` attribute.
+ * @param {boolean} [queryInfo.autoDiscardable]
+ * Matches against the exact value of the tab's `autoDiscardable` attribute.
+ * @param {string} [queryInfo.cookieStoreId]
+ * Matches against the exact value of the tab's `cookieStoreId` attribute.
+ * @param {boolean} [queryInfo.discarded]
+ * Matches against the exact value of the tab's `discarded` attribute.
+ * @param {boolean} [queryInfo.hidden]
+ * Matches against the exact value of the tab's `hidden` attribute.
+ * @param {boolean} [queryInfo.highlighted]
+ * Matches against the exact value of the tab's `highlighted` attribute.
+ * @param {integer} [queryInfo.index]
+ * Matches against the exact value of the tab's `index` attribute.
+ * @param {boolean} [queryInfo.muted]
+ * Matches against the exact value of the tab's `mutedInfo.muted` attribute.
+ * @param {boolean} [queryInfo.pinned]
+ * Matches against the exact value of the tab's `pinned` attribute.
+ * @param {string} [queryInfo.status]
+ * Matches against the exact value of the tab's `status` attribute.
+ * @param {string} [queryInfo.title]
+ * Matches against the exact value of the tab's `title` attribute.
+ * @param {string|boolean } [queryInfo.screen]
+ * Matches against the exact value of the tab's `sharingState.screen` attribute, or use true to match any screen sharing tab.
+ * @param {boolean} [queryInfo.camera]
+ * Matches against the exact value of the tab's `sharingState.camera` attribute.
+ * @param {boolean} [queryInfo.microphone]
+ * Matches against the exact value of the tab's `sharingState.microphone` attribute.
+ *
+ * Note: Per specification, this should perform a pattern match, rather
+ * than an exact value match, and will do so in the future.
+ * @param {MatchPattern} [queryInfo.url]
+ * Requires the tab's URL to match the given MatchPattern object.
+ *
+ * @returns {boolean}
+ * True if the tab matches the query.
+ */
+ matches(queryInfo) {
+ const PROPS = [
+ "active",
+ "audible",
+ "autoDiscardable",
+ "discarded",
+ "hidden",
+ "highlighted",
+ "index",
+ "openerTabId",
+ "pinned",
+ "status",
+ ];
+
+ function checkProperty(prop, obj) {
+ return queryInfo[prop] != null && queryInfo[prop] !== obj[prop];
+ }
+
+ if (PROPS.some(prop => checkProperty(prop, this))) {
+ return false;
+ }
+
+ if (checkProperty("muted", this.mutedInfo)) {
+ return false;
+ }
+
+ let state = this.sharingState;
+ if (["camera", "microphone"].some(prop => checkProperty(prop, state))) {
+ return false;
+ }
+ // query for screen can be boolean (ie. any) or string (ie. specific).
+ if (queryInfo.screen !== null) {
+ let match =
+ typeof queryInfo.screen == "boolean"
+ ? queryInfo.screen === !!state.screen
+ : queryInfo.screen === state.screen;
+ if (!match) {
+ return false;
+ }
+ }
+
+ if (queryInfo.cookieStoreId) {
+ if (!queryInfo.cookieStoreId.includes(this.cookieStoreId)) {
+ return false;
+ }
+ }
+
+ if (queryInfo.url || queryInfo.title) {
+ if (!this.hasTabPermission) {
+ return false;
+ }
+ // Using _uri and _title instead of url/title to avoid repeated permission checks.
+ if (queryInfo.url && !queryInfo.url.matches(this._uri)) {
+ return false;
+ }
+ if (queryInfo.title && !queryInfo.title.matches(this._title)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Converts this tab object to a JSON-compatible object containing the values
+ * of its properties which the extension is permitted to access, in the format
+ * required to be returned by WebExtension APIs.
+ *
+ * @param {object} [fallbackTabSize]
+ * A geometry data if the lazy geometry data for this tab hasn't been
+ * initialized yet.
+ * @returns {object}
+ */
+ convert(fallbackTabSize = null) {
+ let result = {
+ id: this.id,
+ index: this.index,
+ windowId: this.windowId,
+ highlighted: this.highlighted,
+ active: this.active,
+ attention: this.attention,
+ pinned: this.pinned,
+ status: this.status,
+ hidden: this.hidden,
+ discarded: this.discarded,
+ incognito: this.incognito,
+ width: this.width,
+ height: this.height,
+ lastAccessed: this.lastAccessed,
+ audible: this.audible,
+ autoDiscardable: this.autoDiscardable,
+ mutedInfo: this.mutedInfo,
+ isArticle: this.isArticle,
+ isInReaderMode: this.isInReaderMode,
+ sharingState: this.sharingState,
+ successorTabId: this.successorTabId,
+ cookieStoreId: this.cookieStoreId,
+ };
+
+ // If the tab has not been fully layed-out yet, fallback to the geometry
+ // from a different tab (usually the currently active tab).
+ if (fallbackTabSize && (!result.width || !result.height)) {
+ result.width = fallbackTabSize.width;
+ result.height = fallbackTabSize.height;
+ }
+
+ let opener = this.openerTabId;
+ if (opener) {
+ result.openerTabId = opener;
+ }
+
+ if (this.hasTabPermission) {
+ for (let prop of ["url", "title", "favIconUrl"]) {
+ // We use the underscored variants here to avoid the redundant
+ // permissions checks imposed on the public properties.
+ let val = this[`_${prop}`];
+ if (val) {
+ result[prop] = val;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Query each content process hosting subframes of the tab, return results.
+ *
+ * @param {string} message
+ * @param {object} options
+ * These options are also sent to the message handler in the
+ * `ExtensionContentChild`.
+ * @param {number[]} options.frameIds
+ * When omitted, all frames will be queried.
+ * @param {boolean} options.returnResultsWithFrameIds
+ * @returns {Promise[]}
+ */
+ async queryContent(message, options) {
+ let { frameIds } = options;
+
+ /** @type {Map<nsIDOMProcessParent, innerWindowId[]>} */
+ let byProcess = new DefaultMap(() => []);
+ // We use this set to know which frame IDs are potentially invalid (as in
+ // not found when visiting the tab's BC tree below) when frameIds is a
+ // non-empty list of frame IDs.
+ let frameIdsSet = new Set(frameIds);
+
+ // Recursively walk the tab's BC tree, find all frames, group by process.
+ function visit(bc) {
+ let win = bc.currentWindowGlobal;
+ let frameId = bc.parent ? bc.id : 0;
+
+ if (win?.domProcess && (!frameIds || frameIdsSet.has(frameId))) {
+ byProcess.get(win.domProcess).push(win.innerWindowId);
+ frameIdsSet.delete(frameId);
+ }
+
+ if (!frameIds || frameIdsSet.size > 0) {
+ bc.children.forEach(visit);
+ }
+ }
+ visit(this.browsingContext);
+
+ if (frameIdsSet.size > 0) {
+ throw new ExtensionError(
+ `Invalid frame IDs: [${Array.from(frameIdsSet).join(", ")}].`
+ );
+ }
+
+ let promises = Array.from(byProcess.entries(), ([proc, windows]) =>
+ proc.getActor("ExtensionContent").sendQuery(message, { windows, options })
+ );
+
+ let results = await Promise.all(promises).catch(err => {
+ if (err.name === "DataCloneError") {
+ let fileName = options.jsPaths.slice(-1)[0] || "<anonymous code>";
+ let message = `Script '${fileName}' result is non-structured-clonable data`;
+ return Promise.reject({ message, fileName });
+ }
+ throw err;
+ });
+ results = results.flat();
+
+ if (!results.length) {
+ let errorMessage = "Missing host permission for the tab";
+ if (!frameIds || frameIds.length > 1 || frameIds[0] !== 0) {
+ errorMessage += " or frames";
+ }
+
+ throw new ExtensionError(errorMessage);
+ }
+
+ if (frameIds && frameIds.length === 1 && results.length > 1) {
+ throw new ExtensionError("Internal error: multiple windows matched");
+ }
+
+ return results;
+ }
+
+ /**
+ * Inserts a script or stylesheet in the given tab, and returns a promise
+ * which resolves when the operation has completed.
+ *
+ * @param {BaseContext} context
+ * The extension context for which to perform the injection.
+ * @param {InjectDetails} details
+ * The InjectDetails object, specifying what to inject, where, and
+ * when.
+ * @param {string} kind
+ * The kind of data being injected. Either "script" or "css".
+ * @param {string} method
+ * The name of the method which was called to trigger the injection.
+ * Used to generate appropriate error messages on failure.
+ *
+ * @returns {Promise}
+ * Resolves to the result of the execution, once it has completed.
+ * @private
+ */
+ _execute(context, details, kind, method) {
+ let options = {
+ jsPaths: [],
+ cssPaths: [],
+ removeCSS: method == "removeCSS",
+ extensionId: context.extension.id,
+ };
+
+ // We require a `code` or a `file` property, but we can't accept both.
+ if ((details.code === null) == (details.file === null)) {
+ return Promise.reject({
+ message: `${method} requires either a 'code' or a 'file' property, but not both`,
+ });
+ }
+
+ if (details.frameId !== null && details.allFrames) {
+ return Promise.reject({
+ message: `'frameId' and 'allFrames' are mutually exclusive`,
+ });
+ }
+
+ options.hasActiveTabPermission = this.hasActiveTabPermission;
+ options.matches = this.extension.allowedOrigins.patterns.map(
+ host => host.pattern
+ );
+
+ if (details.code !== null) {
+ options[`${kind}Code`] = details.code;
+ }
+ if (details.file !== null) {
+ let url = context.uri.resolve(details.file);
+ if (!this.extension.isExtensionURL(url)) {
+ return Promise.reject({
+ message: "Files to be injected must be within the extension",
+ });
+ }
+ options[`${kind}Paths`].push(url);
+ }
+
+ if (details.allFrames) {
+ options.allFrames = true;
+ } else if (details.frameId !== null) {
+ options.frameIds = [details.frameId];
+ } else if (!details.allFrames) {
+ options.frameIds = [0];
+ }
+
+ if (details.matchAboutBlank) {
+ options.matchAboutBlank = details.matchAboutBlank;
+ }
+ if (details.runAt !== null) {
+ options.runAt = details.runAt;
+ } else {
+ options.runAt = "document_idle";
+ }
+ if (details.cssOrigin !== null) {
+ options.cssOrigin = details.cssOrigin;
+ } else {
+ options.cssOrigin = "author";
+ }
+
+ options.wantReturnValue = true;
+
+ // The scripting API (defined in `parent/ext-scripting.js`) has its own
+ // `execute()` function that calls `queryContent()` as well. Make sure to
+ // keep both in sync when relevant.
+ return this.queryContent("Execute", options);
+ }
+
+ /**
+ * Executes a script in the tab's content window, and returns a Promise which
+ * resolves to the result of the evaluation, or rejects to the value of any
+ * error the injection generates.
+ *
+ * @param {BaseContext} context
+ * The extension context for which to inject the script.
+ * @param {InjectDetails} details
+ * The InjectDetails object, specifying what to inject, where, and
+ * when.
+ *
+ * @returns {Promise}
+ * Resolves to the result of the evaluation of the given script, once
+ * it has completed, or rejects with any error the evaluation
+ * generates.
+ */
+ executeScript(context, details) {
+ return this._execute(context, details, "js", "executeScript");
+ }
+
+ /**
+ * Injects CSS into the tab's content window, and returns a Promise which
+ * resolves when the injection is complete.
+ *
+ * @param {BaseContext} context
+ * The extension context for which to inject the script.
+ * @param {InjectDetails} details
+ * The InjectDetails object, specifying what to inject, and where.
+ *
+ * @returns {Promise}
+ * Resolves when the injection has completed.
+ */
+ insertCSS(context, details) {
+ return this._execute(context, details, "css", "insertCSS").then(() => {});
+ }
+
+ /**
+ * Removes CSS which was previously into the tab's content window via
+ * `insertCSS`, and returns a Promise which resolves when the operation is
+ * complete.
+ *
+ * @param {BaseContext} context
+ * The extension context for which to remove the CSS.
+ * @param {InjectDetails} details
+ * The InjectDetails object, specifying what to remove, and from where.
+ *
+ * @returns {Promise}
+ * Resolves when the operation has completed.
+ */
+ removeCSS(context, details) {
+ return this._execute(context, details, "css", "removeCSS").then(() => {});
+ }
+}
+
+defineLazyGetter(TabBase.prototype, "incognito", function () {
+ return this._incognito;
+});
+
+// Note: These must match the values in windows.json.
+const WINDOW_ID_NONE = -1;
+const WINDOW_ID_CURRENT = -2;
+
+/**
+ * A platform-independent base class for extension-specific wrappers around
+ * native browser windows
+ *
+ * @param {Extension} extension
+ * The extension object for which this wrapper is being created.
+ * @param {DOMWindow} window
+ * The browser DOM window which is being wrapped.
+ * @param {integer} id
+ * The numeric ID of this DOM window object. This ID should be the same for
+ * every extension, and for the lifetime of the window.
+ */
+class WindowBase {
+ constructor(extension, window, id) {
+ if (!extension.canAccessWindow(window)) {
+ throw new ExtensionError("extension cannot access window");
+ }
+ this.extension = extension;
+ this.window = window;
+ this.id = id;
+ }
+
+ /**
+ * @property {nsIAppWindow} appWindow
+ * The nsIAppWindow object for this browser window.
+ * @readonly
+ */
+ get appWindow() {
+ return this.window.docShell.treeOwner
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow);
+ }
+
+ /**
+ * Returns true if this window is the current window for the given extension
+ * context, false otherwise.
+ *
+ * @param {BaseContext} context
+ * The extension context for which to perform the check.
+ *
+ * @returns {boolean}
+ */
+ isCurrentFor(context) {
+ if (context && context.currentWindow) {
+ return this.window === context.currentWindow;
+ }
+ return this.isLastFocused;
+ }
+
+ /**
+ * @property {string} type
+ * The type of the window, as defined by the WebExtension API. May be
+ * either "normal" or "popup".
+ * @readonly
+ */
+ get type() {
+ let { chromeFlags } = this.appWindow;
+
+ if (chromeFlags & Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG) {
+ return "popup";
+ }
+
+ return "normal";
+ }
+
+ /**
+ * Converts this window object to a JSON-compatible object which may be
+ * returned to an extension, in the format required to be returned by
+ * WebExtension APIs.
+ *
+ * @param {object} [getInfo]
+ * An optional object, the properties of which determine what data is
+ * available on the result object.
+ * @param {boolean} [getInfo.populate]
+ * Of true, the result object will contain a `tabs` property,
+ * containing an array of converted Tab objects, one for each tab in
+ * the window.
+ *
+ * @returns {object}
+ */
+ convert(getInfo) {
+ let result = {
+ id: this.id,
+ focused: this.focused,
+ top: this.top,
+ left: this.left,
+ width: this.width,
+ height: this.height,
+ incognito: this.incognito,
+ type: this.type,
+ state: this.state,
+ alwaysOnTop: this.alwaysOnTop,
+ title: this.title,
+ };
+
+ if (getInfo && getInfo.populate) {
+ result.tabs = Array.from(this.getTabs(), tab => tab.convert());
+ }
+
+ return result;
+ }
+
+ /**
+ * Returns true if this window matches the the given query info object. Omitted
+ * or null have no effect on the match.
+ *
+ * @param {object} queryInfo
+ * The query info against which to match.
+ * @param {boolean} [queryInfo.currentWindow]
+ * Matches against against the return value of `isCurrentFor()` for the
+ * given context.
+ * @param {boolean} [queryInfo.lastFocusedWindow]
+ * Matches against the exact value of the window's `isLastFocused` attribute.
+ * @param {boolean} [queryInfo.windowId]
+ * Matches against the exact value of the window's ID, taking into
+ * account the special WINDOW_ID_CURRENT value.
+ * @param {string} [queryInfo.windowType]
+ * Matches against the exact value of the window's `type` attribute.
+ * @param {BaseContext} context
+ * The extension context for which the matching is being performed.
+ * Used to determine the current window for relevant properties.
+ *
+ * @returns {boolean}
+ * True if the window matches the query.
+ */
+ matches(queryInfo, context) {
+ if (
+ queryInfo.lastFocusedWindow !== null &&
+ queryInfo.lastFocusedWindow !== this.isLastFocused
+ ) {
+ return false;
+ }
+
+ if (queryInfo.windowType !== null && queryInfo.windowType !== this.type) {
+ return false;
+ }
+
+ if (queryInfo.windowId !== null) {
+ if (queryInfo.windowId === WINDOW_ID_CURRENT) {
+ if (!this.isCurrentFor(context)) {
+ return false;
+ }
+ } else if (queryInfo.windowId !== this.id) {
+ return false;
+ }
+ }
+
+ if (
+ queryInfo.currentWindow !== null &&
+ queryInfo.currentWindow !== this.isCurrentFor(context)
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @property {boolean} focused
+ * Returns true if the browser window is currently focused.
+ * @readonly
+ * @abstract
+ */
+ get focused() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {integer} top
+ * Returns the pixel offset of the top of the window from the top of
+ * the screen.
+ * @readonly
+ * @abstract
+ */
+ get top() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {integer} left
+ * Returns the pixel offset of the left of the window from the left of
+ * the screen.
+ * @readonly
+ * @abstract
+ */
+ get left() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {integer} width
+ * Returns the pixel width of the window.
+ * @readonly
+ * @abstract
+ */
+ get width() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {integer} height
+ * Returns the pixel height of the window.
+ * @readonly
+ * @abstract
+ */
+ get height() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {boolean} incognito
+ * Returns true if this is a private browsing window, false otherwise.
+ * @readonly
+ * @abstract
+ */
+ get incognito() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {boolean} alwaysOnTop
+ * Returns true if this window is constrained to always remain above
+ * other windows.
+ * @readonly
+ * @abstract
+ */
+ get alwaysOnTop() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {boolean} isLastFocused
+ * Returns true if this is the browser window which most recently had
+ * focus.
+ * @readonly
+ * @abstract
+ */
+ get isLastFocused() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {string} state
+ * Returns or sets the current state of this window, as determined by
+ * `getState()`.
+ * @abstract
+ */
+ get state() {
+ throw new Error("Not implemented");
+ }
+
+ set state(state) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {nsIURI | null} title
+ * Returns the current title of this window if the extension has permission
+ * to read it, or null otherwise.
+ * @readonly
+ */
+ get title() {
+ // activeTab may be null when a new window is adopting an existing tab as its first tab
+ // (See Bug 1458918 for rationale).
+ if (this.activeTab && this.activeTab.hasTabPermission) {
+ return this._title;
+ }
+ }
+
+ // The JSDoc validator does not support @returns tags in abstract functions or
+ // star functions without return statements.
+ /* eslint-disable valid-jsdoc */
+ /**
+ * Returns the window state of the given window.
+ *
+ * @param {DOMWindow} window
+ * The window for which to return a state.
+ *
+ * @returns {string}
+ * The window's state. One of "normal", "minimized", "maximized",
+ * "fullscreen", or "docked".
+ * @static
+ * @abstract
+ */
+ static getState(window) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns an iterator of TabBase objects for each tab in this window.
+ *
+ * @returns {Iterator<TabBase>}
+ */
+ getTabs() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns an iterator of TabBase objects for each highlighted tab in this window.
+ *
+ * @returns {Iterator<TabBase>}
+ */
+ getHighlightedTabs() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {TabBase} The window's currently active tab.
+ */
+ get activeTab() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns the window's tab at the specified index.
+ *
+ * @param {integer} index
+ * The index of the desired tab.
+ *
+ * @returns {TabBase|undefined}
+ */
+ getTabAtIndex(index) {
+ throw new Error("Not implemented");
+ }
+ /* eslint-enable valid-jsdoc */
+}
+
+Object.assign(WindowBase, { WINDOW_ID_NONE, WINDOW_ID_CURRENT });
+
+/**
+ * The parameter type of "tab-attached" events, which are emitted when a
+ * pre-existing tab is attached to a new window.
+ *
+ * @typedef {object} TabAttachedEvent
+ * @property {NativeTab} tab
+ * The native tab object in the window to which the tab is being
+ * attached. This may be a different object than was used to represent
+ * the tab in the old window.
+ * @property {integer} tabId
+ * The ID of the tab being attached.
+ * @property {integer} newWindowId
+ * The ID of the window to which the tab is being attached.
+ * @property {integer} newPosition
+ * The position of the tab in the tab list of the new window.
+ */
+
+/**
+ * The parameter type of "tab-detached" events, which are emitted when a
+ * pre-existing tab is detached from a window, in order to be attached to a new
+ * window.
+ *
+ * @typedef {object} TabDetachedEvent
+ * @property {NativeTab} tab
+ * The native tab object in the window from which the tab is being
+ * detached. This may be a different object than will be used to
+ * represent the tab in the new window.
+ * @property {NativeTab} adoptedBy
+ * The native tab object in the window to which the tab will be attached,
+ * and is adopting the contents of this tab. This may be a different
+ * object than the tab in the previous window.
+ * @property {integer} tabId
+ * The ID of the tab being detached.
+ * @property {integer} oldWindowId
+ * The ID of the window from which the tab is being detached.
+ * @property {integer} oldPosition
+ * The position of the tab in the tab list of the window from which it is
+ * being detached.
+ */
+
+/**
+ * The parameter type of "tab-created" events, which are emitted when a
+ * new tab is created.
+ *
+ * @typedef {object} TabCreatedEvent
+ * @property {NativeTab} tab
+ * The native tab object for the tab which is being created.
+ */
+
+/**
+ * The parameter type of "tab-removed" events, which are emitted when a
+ * tab is removed and destroyed.
+ *
+ * @typedef {object} TabRemovedEvent
+ * @property {NativeTab} tab
+ * The native tab object for the tab which is being removed.
+ * @property {integer} tabId
+ * The ID of the tab being removed.
+ * @property {integer} windowId
+ * The ID of the window from which the tab is being removed.
+ * @property {boolean} isWindowClosing
+ * True if the tab is being removed because the window is closing.
+ */
+
+/**
+ * An object containing basic, extension-independent information about the window
+ * and tab that a XUL <browser> belongs to.
+ *
+ * @typedef {object} BrowserData
+ * @property {integer} tabId
+ * The numeric ID of the tab that a <browser> belongs to, or -1 if it
+ * does not belong to a tab.
+ * @property {integer} windowId
+ * The numeric ID of the browser window that a <browser> belongs to, or -1
+ * if it does not belong to a browser window.
+ */
+
+/**
+ * A platform-independent base class for the platform-specific TabTracker
+ * classes, which track the opening and closing of tabs, and manage the mapping
+ * of them between numeric IDs and native tab objects.
+ *
+ * Instances of this class are EventEmitters which emit the following events,
+ * each with an argument of the given type:
+ *
+ * - "tab-attached" {@link TabAttacheEvent}
+ * - "tab-detached" {@link TabDetachedEvent}
+ * - "tab-created" {@link TabCreatedEvent}
+ * - "tab-removed" {@link TabRemovedEvent}
+ */
+class TabTrackerBase extends EventEmitter {
+ on(...args) {
+ if (!this.initialized) {
+ this.init();
+ }
+
+ return super.on(...args); // eslint-disable-line mozilla/balanced-listeners
+ }
+
+ /**
+ * Called to initialize the tab tracking listeners the first time that an
+ * event listener is added.
+ *
+ * @protected
+ * @abstract
+ */
+ init() {
+ throw new Error("Not implemented");
+ }
+
+ // The JSDoc validator does not support @returns tags in abstract functions or
+ // star functions without return statements.
+ /* eslint-disable valid-jsdoc */
+ /**
+ * Returns the numeric ID for the given native tab.
+ *
+ * @param {NativeTab} nativeTab
+ * The native tab for which to return an ID.
+ *
+ * @returns {integer}
+ * The tab's numeric ID.
+ * @abstract
+ */
+ getId(nativeTab) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns the native tab with the given numeric ID.
+ *
+ * @param {integer} tabId
+ * The numeric ID of the tab to return.
+ * @param {*} default_
+ * The value to return if no tab exists with the given ID.
+ *
+ * @returns {NativeTab}
+ * @throws {ExtensionError}
+ * If no tab exists with the given ID and a default return value is not
+ * provided.
+ * @abstract
+ */
+ getTab(tabId, default_ = undefined) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns basic information about the tab and window that the given browser
+ * belongs to.
+ *
+ * @param {XULElement} browser
+ * The XUL browser element for which to return data.
+ *
+ * @returns {BrowserData}
+ * @abstract
+ */
+ /* eslint-enable valid-jsdoc */
+ getBrowserData(browser) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * @property {NativeTab} activeTab
+ * Returns the native tab object for the active tab in the
+ * most-recently focused window, or null if no live tabs currently
+ * exist.
+ * @abstract
+ */
+ get activeTab() {
+ throw new Error("Not implemented");
+ }
+}
+
+/**
+ * A browser progress listener instance which calls a given listener function
+ * whenever the status of the given browser changes.
+ *
+ * @param {function(object): void} listener
+ * A function to be called whenever the status of a tab's top-level
+ * browser. It is passed an object with a `browser` property pointing to
+ * the XUL browser, and a `status` property with a string description of
+ * the browser's status.
+ * @private
+ */
+class StatusListener {
+ constructor(listener) {
+ this.listener = listener;
+ }
+
+ onStateChange(browser, webProgress, request, stateFlags, statusCode) {
+ if (!webProgress.isTopLevel) {
+ return;
+ }
+
+ let status;
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) {
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ status = "loading";
+ } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ status = "complete";
+ }
+ } else if (
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ statusCode == Cr.NS_BINDING_ABORTED
+ ) {
+ status = "complete";
+ }
+
+ if (status) {
+ this.listener({ browser, status });
+ }
+ }
+
+ onLocationChange(browser, webProgress, request, locationURI, flags) {
+ if (webProgress.isTopLevel) {
+ let status = webProgress.isLoadingDocument ? "loading" : "complete";
+ this.listener({ browser, status, url: locationURI.spec });
+ }
+ }
+}
+
+/**
+ * A platform-independent base class for the platform-specific WindowTracker
+ * classes, which track the opening and closing of windows, and manage the
+ * mapping of them between numeric IDs and native tab objects.
+ */
+class WindowTrackerBase extends EventEmitter {
+ constructor() {
+ super();
+
+ this._handleWindowOpened = this._handleWindowOpened.bind(this);
+
+ this._openListeners = new Set();
+ this._closeListeners = new Set();
+
+ this._listeners = new DefaultMap(() => new Set());
+
+ this._statusListeners = new DefaultWeakMap(listener => {
+ return new StatusListener(listener);
+ });
+
+ this._windowIds = new DefaultWeakMap(window => {
+ return window.docShell.outerWindowID;
+ });
+ }
+
+ isBrowserWindow(window) {
+ let { documentElement } = window.document;
+
+ return documentElement.getAttribute("windowtype") === "navigator:browser";
+ }
+
+ // The JSDoc validator does not support @returns tags in abstract functions or
+ // star functions without return statements.
+ /* eslint-disable valid-jsdoc */
+ /**
+ * Returns an iterator for all currently active browser windows.
+ *
+ * @param {boolean} [includeInomplete = false]
+ * If true, include browser windows which are not yet fully loaded.
+ * Otherwise, only include windows which are.
+ *
+ * @returns {Iterator<DOMWindow>}
+ */
+ /* eslint-enable valid-jsdoc */
+ *browserWindows(includeIncomplete = false) {
+ // The window type parameter is only available once the window's document
+ // element has been created. This means that, when looking for incomplete
+ // browser windows, we need to ignore the type entirely for windows which
+ // haven't finished loading, since we would otherwise skip browser windows
+ // in their early loading stages.
+ // This is particularly important given that the "domwindowcreated" event
+ // fires for browser windows when they're in that in-between state, and just
+ // before we register our own "domwindowcreated" listener.
+
+ for (let window of Services.wm.getEnumerator("")) {
+ let ok = includeIncomplete;
+ if (window.document.readyState === "complete") {
+ ok = this.isBrowserWindow(window);
+ }
+
+ if (ok) {
+ yield window;
+ }
+ }
+ }
+
+ /**
+ * @property {DOMWindow|null} topWindow
+ * The currently active, or topmost, browser window, or null if no
+ * browser window is currently open.
+ * @readonly
+ */
+ get topWindow() {
+ return Services.wm.getMostRecentWindow("navigator:browser");
+ }
+
+ /**
+ * @property {DOMWindow|null} topWindow
+ * The currently active, or topmost, browser window that is not
+ * private browsing, or null if no browser window is currently open.
+ * @readonly
+ */
+ get topNonPBWindow() {
+ return Services.wm.getMostRecentNonPBWindow("navigator:browser");
+ }
+
+ /**
+ * Returns the top window accessible by the extension.
+ *
+ * @param {BaseContext} context
+ * The extension context for which to return the current window.
+ *
+ * @returns {DOMWindow|null}
+ */
+ getTopWindow(context) {
+ if (context && !context.privateBrowsingAllowed) {
+ return this.topNonPBWindow;
+ }
+ return this.topWindow;
+ }
+
+ /**
+ * Returns the numeric ID for the given browser window.
+ *
+ * @param {DOMWindow} window
+ * The DOM window for which to return an ID.
+ *
+ * @returns {integer}
+ * The window's numeric ID.
+ */
+ getId(window) {
+ return this._windowIds.get(window);
+ }
+
+ /**
+ * Returns the browser window to which the given context belongs, or the top
+ * browser window if the context does not belong to a browser window.
+ *
+ * @param {BaseContext} context
+ * The extension context for which to return the current window.
+ *
+ * @returns {DOMWindow|null}
+ */
+ getCurrentWindow(context) {
+ return (context && context.currentWindow) || this.getTopWindow(context);
+ }
+
+ /**
+ * Returns the browser window with the given ID.
+ *
+ * @param {integer} id
+ * The ID of the window to return.
+ * @param {BaseContext} context
+ * The extension context for which the matching is being performed.
+ * Used to determine the current window for relevant properties.
+ * @param {boolean} [strict = true]
+ * If false, undefined will be returned instead of throwing an error
+ * in case no window exists with the given ID.
+ *
+ * @returns {DOMWindow|undefined}
+ * @throws {ExtensionError}
+ * If no window exists with the given ID and `strict` is true.
+ */
+ getWindow(id, context, strict = true) {
+ if (id === WINDOW_ID_CURRENT) {
+ return this.getCurrentWindow(context);
+ }
+
+ let window = Services.wm.getOuterWindowWithId(id);
+ if (
+ window &&
+ !window.closed &&
+ (window.document.readyState !== "complete" ||
+ this.isBrowserWindow(window))
+ ) {
+ if (!context || context.canAccessWindow(window)) {
+ // Tolerate incomplete windows because isBrowserWindow is only reliable
+ // once the window is fully loaded.
+ return window;
+ }
+ }
+
+ if (strict) {
+ throw new ExtensionError(`Invalid window ID: ${id}`);
+ }
+ }
+
+ /**
+ * @property {boolean} _haveListeners
+ * Returns true if any window open or close listeners are currently
+ * registered.
+ * @private
+ */
+ get _haveListeners() {
+ return this._openListeners.size > 0 || this._closeListeners.size > 0;
+ }
+
+ /**
+ * Register the given listener function to be called whenever a new browser
+ * window is opened.
+ *
+ * @param {function(DOMWindow): void} listener
+ * The listener function to register.
+ */
+ addOpenListener(listener) {
+ if (!this._haveListeners) {
+ Services.ww.registerNotification(this);
+ }
+
+ this._openListeners.add(listener);
+
+ for (let window of this.browserWindows(true)) {
+ if (window.document.readyState !== "complete") {
+ window.addEventListener("load", this);
+ }
+ }
+ }
+
+ /**
+ * Unregister a listener function registered in a previous addOpenListener
+ * call.
+ *
+ * @param {function(DOMWindow): void} listener
+ * The listener function to unregister.
+ */
+ removeOpenListener(listener) {
+ this._openListeners.delete(listener);
+
+ if (!this._haveListeners) {
+ Services.ww.unregisterNotification(this);
+ }
+ }
+
+ /**
+ * Register the given listener function to be called whenever a browser
+ * window is closed.
+ *
+ * @param {function(DOMWindow): void} listener
+ * The listener function to register.
+ */
+ addCloseListener(listener) {
+ if (!this._haveListeners) {
+ Services.ww.registerNotification(this);
+ }
+
+ this._closeListeners.add(listener);
+ }
+
+ /**
+ * Unregister a listener function registered in a previous addCloseListener
+ * call.
+ *
+ * @param {function(DOMWindow): void} listener
+ * The listener function to unregister.
+ */
+ removeCloseListener(listener) {
+ this._closeListeners.delete(listener);
+
+ if (!this._haveListeners) {
+ Services.ww.unregisterNotification(this);
+ }
+ }
+
+ /**
+ * Handles load events for recently-opened windows, and adds additional
+ * listeners which may only be safely added when the window is fully loaded.
+ *
+ * @param {Event} event
+ * A DOM event to handle.
+ * @private
+ */
+ handleEvent(event) {
+ if (event.type === "load") {
+ event.currentTarget.removeEventListener(event.type, this);
+
+ let window = event.target.defaultView;
+ if (!this.isBrowserWindow(window)) {
+ return;
+ }
+
+ for (let listener of this._openListeners) {
+ try {
+ listener(window);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ }
+ }
+
+ /**
+ * Observes "domwindowopened" and "domwindowclosed" events, notifies the
+ * appropriate listeners, and adds necessary additional listeners to the new
+ * windows.
+ *
+ * @param {DOMWindow} window
+ * A DOM window.
+ * @param {string} topic
+ * The topic being observed.
+ * @private
+ */
+ observe(window, topic) {
+ if (topic === "domwindowclosed") {
+ if (!this.isBrowserWindow(window)) {
+ return;
+ }
+
+ window.removeEventListener("load", this);
+ for (let listener of this._closeListeners) {
+ try {
+ listener(window);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ } else if (topic === "domwindowopened") {
+ window.addEventListener("load", this);
+ }
+ }
+
+ /**
+ * Add an event listener to be called whenever the given DOM event is received
+ * at the top level of any browser window.
+ *
+ * @param {string} type
+ * The type of event to listen for. May be any valid DOM event name, or
+ * one of the following special cases:
+ *
+ * - "progress": Adds a tab progress listener to every browser window.
+ * - "status": Adds a StatusListener to every tab of every browser
+ * window.
+ * - "domwindowopened": Acts as an alias for addOpenListener.
+ * - "domwindowclosed": Acts as an alias for addCloseListener.
+ * @param {Function | object} listener
+ * The listener to invoke in response to the given events.
+ *
+ * @returns {undefined}
+ */
+ addListener(type, listener) {
+ if (type === "domwindowopened") {
+ return this.addOpenListener(listener);
+ } else if (type === "domwindowclosed") {
+ return this.addCloseListener(listener);
+ }
+
+ if (this._listeners.size === 0) {
+ this.addOpenListener(this._handleWindowOpened);
+ }
+
+ if (type === "status") {
+ listener = this._statusListeners.get(listener);
+ type = "progress";
+ }
+
+ this._listeners.get(type).add(listener);
+
+ // Register listener on all existing windows.
+ for (let window of this.browserWindows()) {
+ this._addWindowListener(window, type, listener);
+ }
+ }
+
+ /**
+ * Removes an event listener previously registered via an addListener call.
+ *
+ * @param {string} type
+ * The type of event to stop listening for.
+ * @param {Function | object} listener
+ * The listener to remove.
+ *
+ * @returns {undefined}
+ */
+ removeListener(type, listener) {
+ if (type === "domwindowopened") {
+ return this.removeOpenListener(listener);
+ } else if (type === "domwindowclosed") {
+ return this.removeCloseListener(listener);
+ }
+
+ if (type === "status") {
+ listener = this._statusListeners.get(listener);
+ type = "progress";
+ }
+
+ let listeners = this._listeners.get(type);
+ listeners.delete(listener);
+
+ if (listeners.size === 0) {
+ this._listeners.delete(type);
+ if (this._listeners.size === 0) {
+ this.removeOpenListener(this._handleWindowOpened);
+ }
+ }
+
+ // Unregister listener from all existing windows.
+ let useCapture = type === "focus" || type === "blur";
+ for (let window of this.browserWindows()) {
+ if (type === "progress") {
+ this.removeProgressListener(window, listener);
+ } else {
+ window.removeEventListener(type, listener, useCapture);
+ }
+ }
+ }
+
+ /**
+ * Adds a listener for the given event to the given window.
+ *
+ * @param {DOMWindow} window
+ * The browser window to which to add the listener.
+ * @param {string} eventType
+ * The type of DOM event to listen for, or "progress" to add a tab
+ * progress listener.
+ * @param {Function | object} listener
+ * The listener to add.
+ * @private
+ */
+ _addWindowListener(window, eventType, listener) {
+ let useCapture = eventType === "focus" || eventType === "blur";
+
+ if (eventType === "progress") {
+ this.addProgressListener(window, listener);
+ } else {
+ window.addEventListener(eventType, listener, useCapture);
+ }
+ }
+
+ /**
+ * A private method which is called whenever a new browser window is opened,
+ * and adds the necessary listeners to it.
+ *
+ * @param {DOMWindow} window
+ * The window being opened.
+ * @private
+ */
+ _handleWindowOpened(window) {
+ for (let [eventType, listeners] of this._listeners) {
+ for (let listener of listeners) {
+ this._addWindowListener(window, eventType, listener);
+ }
+ }
+ }
+
+ /**
+ * Adds a tab progress listener to the given browser window.
+ *
+ * @param {DOMWindow} window
+ * The browser window to which to add the listener.
+ * @param {object} listener
+ * The tab progress listener to add.
+ * @abstract
+ */
+ addProgressListener(window, listener) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Removes a tab progress listener from the given browser window.
+ *
+ * @param {DOMWindow} window
+ * The browser window from which to remove the listener.
+ * @param {object} listener
+ * The tab progress listener to remove.
+ * @abstract
+ */
+ removeProgressListener(window, listener) {
+ throw new Error("Not implemented");
+ }
+}
+
+/**
+ * Manages native tabs, their wrappers, and their dynamic permissions for a
+ * particular extension.
+ *
+ * @param {Extension} extension
+ * The extension for which to manage tabs.
+ */
+class TabManagerBase {
+ constructor(extension) {
+ this.extension = extension;
+
+ this._tabs = new DefaultWeakMap(tab => this.wrapTab(tab));
+ }
+
+ /**
+ * If the extension has requested activeTab permission, grant it those
+ * permissions for the current inner window in the given native tab.
+ *
+ * @param {NativeTab} nativeTab
+ * The native tab for which to grant permissions.
+ */
+ addActiveTabPermission(nativeTab) {
+ let tab = this.getWrapper(nativeTab);
+ if (
+ this.extension.hasPermission("activeTab") ||
+ (this.extension.originControls &&
+ this.extension.optionalOrigins.matches(tab._uri))
+ ) {
+ // Note that, unlike Chrome, we don't currently clear this permission with
+ // the tab navigates. If the inner window is revived from BFCache before
+ // we've granted this permission to a new inner window, the extension
+ // maintains its permissions for it.
+ tab.activeTabWindowID = tab.innerWindowID;
+ }
+ }
+
+ /**
+ * Revoke the extension's activeTab permissions for the current inner window
+ * of the given native tab.
+ *
+ * @param {NativeTab} nativeTab
+ * The native tab for which to revoke permissions.
+ */
+ revokeActiveTabPermission(nativeTab) {
+ this.getWrapper(nativeTab).activeTabWindowID = null;
+ }
+
+ /**
+ * Returns true if the extension has requested activeTab permission, and has
+ * been granted permissions for the current inner window if this tab.
+ *
+ * @param {NativeTab} nativeTab
+ * The native tab for which to check permissions.
+ * @returns {boolean}
+ * True if the extension has activeTab permissions for this tab.
+ */
+ hasActiveTabPermission(nativeTab) {
+ return this.getWrapper(nativeTab).hasActiveTabPermission;
+ }
+
+ /**
+ * Activate MV3 content scripts if the extension has activeTab or an
+ * (ungranted) host permission.
+ *
+ * @param {NativeTab} nativeTab
+ */
+ activateScripts(nativeTab) {
+ let tab = this.getWrapper(nativeTab);
+ if (
+ this.extension.originControls &&
+ !tab.matchesHostPermission &&
+ (this.extension.optionalOrigins.matches(tab._uri) ||
+ this.extension.hasPermission("activeTab")) &&
+ (this.extension.contentScripts.length ||
+ this.extension.registeredContentScripts.size)
+ ) {
+ tab.queryContent("ActivateScripts", { id: this.extension.id });
+ }
+ }
+
+ /**
+ * Returns true if the extension has permissions to access restricted
+ * properties of the given native tab. In practice, this means that it has
+ * either requested the "tabs" permission or has activeTab permissions for the
+ * given tab.
+ *
+ * NOTE: Never use this method on an object that is not a native tab
+ * for the current platform: this method implicitly generates a wrapper
+ * for the passed nativeTab parameter and the platform-specific tabTracker
+ * instance is likely to store it in a map which is cleared only when the
+ * tab is closed (and so, if nativeTab is not a real native tab, it will
+ * never be cleared from the platform-specific tabTracker instance),
+ * See Bug 1458918 for a rationale.
+ *
+ * @param {NativeTab} nativeTab
+ * The native tab for which to check permissions.
+ * @returns {boolean}
+ * True if the extension has permissions for this tab.
+ */
+ hasTabPermission(nativeTab) {
+ return this.getWrapper(nativeTab).hasTabPermission;
+ }
+
+ /**
+ * Returns this extension's TabBase wrapper for the given native tab. This
+ * method will always return the same wrapper object for any given native tab.
+ *
+ * @param {NativeTab} nativeTab
+ * The tab for which to return a wrapper.
+ *
+ * @returns {TabBase|undefined}
+ * The wrapper for this tab.
+ */
+ getWrapper(nativeTab) {
+ if (this.canAccessTab(nativeTab)) {
+ return this._tabs.get(nativeTab);
+ }
+ }
+
+ /**
+ * Determines access using extension context.
+ *
+ * @param {NativeTab} nativeTab
+ * The tab to check access on.
+ * @returns {boolean}
+ * True if the extension has permissions for this tab.
+ * @protected
+ * @abstract
+ */
+ canAccessTab(nativeTab) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Converts the given native tab to a JSON-compatible object, in the format
+ * required to be returned by WebExtension APIs, which may be safely passed to
+ * extension code.
+ *
+ * @param {NativeTab} nativeTab
+ * The native tab to convert.
+ * @param {object} [fallbackTabSize]
+ * A geometry data if the lazy geometry data for this tab hasn't been
+ * initialized yet.
+ *
+ * @returns {object}
+ */
+ convert(nativeTab, fallbackTabSize = null) {
+ return this.getWrapper(nativeTab).convert(fallbackTabSize);
+ }
+
+ // The JSDoc validator does not support @returns tags in abstract functions or
+ // star functions without return statements.
+ /* eslint-disable valid-jsdoc */
+ /**
+ * Returns an iterator of TabBase objects which match the given query info.
+ *
+ * @param {object | null} [queryInfo = null]
+ * An object containing properties on which to filter. May contain any
+ * properties which are recognized by {@link TabBase#matches} or
+ * {@link WindowBase#matches}. Unknown properties will be ignored.
+ * @param {BaseContext|null} [context = null]
+ * The extension context for which the matching is being performed.
+ * Used to determine the current window for relevant properties.
+ *
+ * @returns {Iterator<TabBase>}
+ */
+ *query(queryInfo = null, context = null) {
+ if (queryInfo) {
+ if (queryInfo.url !== null) {
+ queryInfo.url = parseMatchPatterns([].concat(queryInfo.url), {
+ restrictSchemes: false,
+ });
+ }
+
+ if (queryInfo.cookieStoreId !== null) {
+ queryInfo.cookieStoreId = [].concat(queryInfo.cookieStoreId);
+ }
+
+ if (queryInfo.title !== null) {
+ try {
+ queryInfo.title = new MatchGlob(queryInfo.title);
+ } catch (e) {
+ throw new ExtensionError(`Invalid title: ${queryInfo.title}`);
+ }
+ }
+ }
+ function* candidates(windowWrapper) {
+ if (queryInfo) {
+ let { active, highlighted, index } = queryInfo;
+ if (active === true) {
+ let { activeTab } = windowWrapper;
+ if (activeTab) {
+ yield activeTab;
+ }
+ return;
+ }
+ if (index != null) {
+ let tabWrapper = windowWrapper.getTabAtIndex(index);
+ if (tabWrapper) {
+ yield tabWrapper;
+ }
+ return;
+ }
+ if (highlighted === true) {
+ yield* windowWrapper.getHighlightedTabs();
+ return;
+ }
+ }
+ yield* windowWrapper.getTabs();
+ }
+ let windowWrappers = this.extension.windowManager.query(queryInfo, context);
+ for (let windowWrapper of windowWrappers) {
+ for (let tabWrapper of candidates(windowWrapper)) {
+ if (!queryInfo || tabWrapper.matches(queryInfo)) {
+ yield tabWrapper;
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns a TabBase wrapper for the tab with the given ID.
+ *
+ * @param {integer} tabId
+ * The ID of the tab for which to return a wrapper.
+ *
+ * @returns {TabBase}
+ * @throws {ExtensionError}
+ * If no tab exists with the given ID.
+ * @abstract
+ */
+ get(tabId) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns a new TabBase instance wrapping the given native tab.
+ *
+ * @param {NativeTab} nativeTab
+ * The native tab for which to return a wrapper.
+ *
+ * @returns {TabBase}
+ * @protected
+ * @abstract
+ */
+ /* eslint-enable valid-jsdoc */
+ wrapTab(nativeTab) {
+ throw new Error("Not implemented");
+ }
+}
+
+/**
+ * Manages native browser windows and their wrappers for a particular extension.
+ *
+ * @param {Extension} extension
+ * The extension for which to manage windows.
+ */
+class WindowManagerBase {
+ constructor(extension) {
+ this.extension = extension;
+
+ this._windows = new DefaultWeakMap(window => this.wrapWindow(window));
+ }
+
+ /**
+ * Converts the given browser window to a JSON-compatible object, in the
+ * format required to be returned by WebExtension APIs, which may be safely
+ * passed to extension code.
+ *
+ * @param {DOMWindow} window
+ * The browser window to convert.
+ * @param {*} args
+ * Additional arguments to be passed to {@link WindowBase#convert}.
+ *
+ * @returns {object}
+ */
+ convert(window, ...args) {
+ return this.getWrapper(window).convert(...args);
+ }
+
+ /**
+ * Returns this extension's WindowBase wrapper for the given browser window.
+ * This method will always return the same wrapper object for any given
+ * browser window.
+ *
+ * @param {DOMWindow} window
+ * The browser window for which to return a wrapper.
+ *
+ * @returns {WindowBase|undefined}
+ * The wrapper for this tab.
+ */
+ getWrapper(window) {
+ if (this.extension.canAccessWindow(window)) {
+ return this._windows.get(window);
+ }
+ }
+
+ /**
+ * Returns whether this window can be accessed by the extension in the given
+ * context.
+ *
+ * @param {DOMWindow} window
+ * The browser window that is being tested
+ * @param {BaseContext|null} context
+ * The extension context for which this test is being performed.
+ * @returns {boolean}
+ */
+ canAccessWindow(window, context) {
+ return (
+ (context && context.canAccessWindow(window)) ||
+ this.extension.canAccessWindow(window)
+ );
+ }
+
+ // The JSDoc validator does not support @returns tags in abstract functions or
+ // star functions without return statements.
+ /* eslint-disable valid-jsdoc */
+ /**
+ * Returns an iterator of WindowBase objects which match the given query info.
+ *
+ * @param {object | null} [queryInfo = null]
+ * An object containing properties on which to filter. May contain any
+ * properties which are recognized by {@link WindowBase#matches}.
+ * Unknown properties will be ignored.
+ * @param {BaseContext|null} [context = null]
+ * The extension context for which the matching is being performed.
+ * Used to determine the current window for relevant properties.
+ *
+ * @returns {Iterator<WindowBase>}
+ */
+ *query(queryInfo = null, context = null) {
+ function* candidates(windowManager) {
+ if (queryInfo) {
+ let { currentWindow, windowId, lastFocusedWindow } = queryInfo;
+ if (currentWindow === true && windowId == null) {
+ windowId = WINDOW_ID_CURRENT;
+ }
+ if (windowId != null) {
+ let window = global.windowTracker.getWindow(windowId, context, false);
+ if (window) {
+ yield windowManager.getWrapper(window);
+ }
+ return;
+ }
+ if (lastFocusedWindow === true) {
+ let window = global.windowTracker.getTopWindow(context);
+ if (window) {
+ yield windowManager.getWrapper(window);
+ }
+ return;
+ }
+ }
+ yield* windowManager.getAll(context);
+ }
+ for (let windowWrapper of candidates(this)) {
+ if (!queryInfo || windowWrapper.matches(queryInfo, context)) {
+ yield windowWrapper;
+ }
+ }
+ }
+
+ /**
+ * Returns a WindowBase wrapper for the browser window with the given ID.
+ *
+ * @param {integer} windowId
+ * The ID of the browser window for which to return a wrapper.
+ * @param {BaseContext} context
+ * The extension context for which the matching is being performed.
+ * Used to determine the current window for relevant properties.
+ *
+ * @returns {WindowBase}
+ * @throws {ExtensionError}
+ * If no window exists with the given ID.
+ * @abstract
+ */
+ get(windowId, context) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns an iterator of WindowBase wrappers for each currently existing
+ * browser window.
+ *
+ * @returns {Iterator<WindowBase>}
+ * @abstract
+ */
+ getAll() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns a new WindowBase instance wrapping the given browser window.
+ *
+ * @param {DOMWindow} window
+ * The browser window for which to return a wrapper.
+ *
+ * @returns {WindowBase}
+ * @protected
+ * @abstract
+ */
+ wrapWindow(window) {
+ throw new Error("Not implemented");
+ }
+ /* eslint-enable valid-jsdoc */
+}
+
+function getUserContextIdForCookieStoreId(
+ extension,
+ cookieStoreId,
+ isPrivateBrowsing
+) {
+ if (!extension.hasPermission("cookies")) {
+ throw new ExtensionError(
+ `No permission for cookieStoreId: ${cookieStoreId}`
+ );
+ }
+
+ if (!isValidCookieStoreId(cookieStoreId)) {
+ throw new ExtensionError(`Illegal cookieStoreId: ${cookieStoreId}`);
+ }
+
+ if (isPrivateBrowsing && !isPrivateCookieStoreId(cookieStoreId)) {
+ throw new ExtensionError(
+ `Illegal to set non-private cookieStoreId in a private window`
+ );
+ }
+
+ if (!isPrivateBrowsing && isPrivateCookieStoreId(cookieStoreId)) {
+ throw new ExtensionError(
+ `Illegal to set private cookieStoreId in a non-private window`
+ );
+ }
+
+ if (isContainerCookieStoreId(cookieStoreId)) {
+ if (PrivateBrowsingUtils.permanentPrivateBrowsing) {
+ // Container tabs are not supported in perma-private browsing mode - bug 1320757
+ throw new ExtensionError(
+ `Contextual identities are unavailable in permanent private browsing mode`
+ );
+ }
+ if (!containersEnabled) {
+ throw new ExtensionError(`Contextual identities are currently disabled`);
+ }
+ let userContextId = getContainerForCookieStoreId(cookieStoreId);
+ if (!userContextId) {
+ throw new ExtensionError(
+ `No cookie store exists with ID ${cookieStoreId}`
+ );
+ }
+ if (!extension.canAccessContainer(userContextId)) {
+ throw new ExtensionError(`Cannot access ${cookieStoreId}`);
+ }
+ return userContextId;
+ }
+
+ return Services.scriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
+}
+
+Object.assign(global, {
+ TabTrackerBase,
+ TabManagerBase,
+ TabBase,
+ WindowTrackerBase,
+ WindowManagerBase,
+ WindowBase,
+ getUserContextIdForCookieStoreId,
+});
diff --git a/toolkit/components/extensions/parent/ext-telemetry.js b/toolkit/components/extensions/parent/ext-telemetry.js
new file mode 100644
index 0000000000..cff568a038
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-telemetry.js
@@ -0,0 +1,195 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs",
+ TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs",
+});
+
+const SCALAR_TYPES = {
+ count: Ci.nsITelemetry.SCALAR_TYPE_COUNT,
+ string: Ci.nsITelemetry.SCALAR_TYPE_STRING,
+ boolean: Ci.nsITelemetry.SCALAR_TYPE_BOOLEAN,
+};
+
+// Currently unsupported on Android: blocked on 1220177.
+// See 1280234 c67 for discussion.
+function desktopCheck() {
+ if (AppConstants.MOZ_BUILD_APP !== "browser") {
+ throw new ExtensionUtils.ExtensionError(
+ "This API is only supported on desktop"
+ );
+ }
+}
+
+this.telemetry = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+ return {
+ telemetry: {
+ submitPing(type, payload, options) {
+ desktopCheck();
+ const manifest = extension.manifest;
+ if (manifest.telemetry) {
+ throw new ExtensionUtils.ExtensionError(
+ "Encryption settings are defined, use submitEncryptedPing instead."
+ );
+ }
+
+ try {
+ TelemetryController.submitExternalPing(type, payload, options);
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ submitEncryptedPing(payload, options) {
+ desktopCheck();
+
+ const manifest = extension.manifest;
+ if (!manifest.telemetry) {
+ throw new ExtensionUtils.ExtensionError(
+ "Encrypted telemetry pings require ping_type and public_key to be set in manifest."
+ );
+ }
+
+ if (!(options.schemaName && options.schemaVersion)) {
+ throw new ExtensionUtils.ExtensionError(
+ "Encrypted telemetry pings require schema name and version to be set in options object."
+ );
+ }
+
+ try {
+ const type = manifest.telemetry.ping_type;
+
+ // Optional manifest entries.
+ if (manifest.telemetry.study_name) {
+ options.studyName = manifest.telemetry.study_name;
+ }
+ options.addPioneerId = manifest.telemetry.pioneer_id === true;
+
+ // Required manifest entries.
+ options.useEncryption = true;
+ options.publicKey = manifest.telemetry.public_key.key;
+ options.encryptionKeyId = manifest.telemetry.public_key.id;
+ options.schemaNamespace = manifest.telemetry.schemaNamespace;
+
+ TelemetryController.submitExternalPing(type, payload, options);
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ canUpload() {
+ desktopCheck();
+ // Note: remove the ternary and direct pref check when
+ // TelemetryController.canUpload() is implemented (bug 1440089).
+ try {
+ const result =
+ "canUpload" in TelemetryController
+ ? TelemetryController.canUpload()
+ : Services.prefs.getBoolPref(
+ TelemetryUtils.Preferences.FhrUploadEnabled,
+ false
+ );
+ return result;
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ scalarAdd(name, value) {
+ desktopCheck();
+ try {
+ Services.telemetry.scalarAdd(name, value);
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ scalarSet(name, value) {
+ desktopCheck();
+ try {
+ Services.telemetry.scalarSet(name, value);
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ scalarSetMaximum(name, value) {
+ desktopCheck();
+ try {
+ Services.telemetry.scalarSetMaximum(name, value);
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ keyedScalarAdd(name, key, value) {
+ desktopCheck();
+ try {
+ Services.telemetry.keyedScalarAdd(name, key, value);
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ keyedScalarSet(name, key, value) {
+ desktopCheck();
+ try {
+ Services.telemetry.keyedScalarSet(name, key, value);
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ keyedScalarSetMaximum(name, key, value) {
+ desktopCheck();
+ try {
+ Services.telemetry.keyedScalarSetMaximum(name, key, value);
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ recordEvent(category, method, object, value, extra) {
+ desktopCheck();
+ try {
+ Services.telemetry.recordEvent(
+ category,
+ method,
+ object,
+ value,
+ extra
+ );
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ registerScalars(category, data) {
+ desktopCheck();
+ try {
+ // For each scalar in `data`, replace scalar.kind with
+ // the appropriate nsITelemetry constant.
+ Object.keys(data).forEach(scalar => {
+ data[scalar].kind = SCALAR_TYPES[data[scalar].kind];
+ });
+ Services.telemetry.registerScalars(category, data);
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ setEventRecordingEnabled(category, enabled) {
+ desktopCheck();
+ try {
+ Services.telemetry.setEventRecordingEnabled(category, enabled);
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ registerEvents(category, data) {
+ desktopCheck();
+ try {
+ Services.telemetry.registerEvents(category, data);
+ } catch (ex) {
+ throw new ExtensionUtils.ExtensionError(ex);
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-theme.js b/toolkit/components/extensions/parent/ext-theme.js
new file mode 100644
index 0000000000..1280563dd0
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-theme.js
@@ -0,0 +1,529 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global windowTracker, EventManager, EventEmitter */
+
+/* eslint-disable complexity */
+
+ChromeUtils.defineESModuleGetters(this, {
+ LightweightThemeManager:
+ "resource://gre/modules/LightweightThemeManager.sys.mjs",
+});
+
+const onUpdatedEmitter = new EventEmitter();
+
+// Represents an empty theme for convenience of use
+const emptyTheme = {
+ details: { colors: null, images: null, properties: null },
+};
+
+let defaultTheme = emptyTheme;
+// Map[windowId -> Theme instance]
+let windowOverrides = new Map();
+
+/**
+ * Class representing either a global theme affecting all windows or an override on a specific window.
+ * Any extension updating the theme with a new global theme will replace the singleton defaultTheme.
+ */
+class Theme {
+ /**
+ * Creates a theme instance.
+ *
+ * @param {object} options
+ * @param {string} options.extension Extension that created the theme.
+ * @param {Integer} options.windowId The windowId where the theme is applied.
+ * @param {object} options.details
+ * @param {object} options.darkDetails
+ * @param {object} options.experiment
+ * @param {object} options.startupData
+ */
+ constructor({
+ extension,
+ details,
+ darkDetails,
+ windowId,
+ experiment,
+ startupData,
+ }) {
+ this.extension = extension;
+ this.details = details;
+ this.darkDetails = darkDetails;
+ this.windowId = windowId;
+
+ if (startupData && startupData.lwtData) {
+ Object.assign(this, startupData);
+ } else {
+ // TODO(ntim): clean this in bug 1550090
+ this.lwtStyles = {};
+ this.lwtDarkStyles = null;
+ if (darkDetails) {
+ this.lwtDarkStyles = {};
+ }
+
+ if (experiment) {
+ if (extension.canUseThemeExperiment()) {
+ this.lwtStyles.experimental = {
+ colors: {},
+ images: {},
+ properties: {},
+ };
+ if (this.lwtDarkStyles) {
+ this.lwtDarkStyles.experimental = {
+ colors: {},
+ images: {},
+ properties: {},
+ };
+ }
+ const { baseURI } = this.extension;
+ if (experiment.stylesheet) {
+ experiment.stylesheet = baseURI.resolve(experiment.stylesheet);
+ }
+ this.experiment = experiment;
+ } else {
+ const { logger } = this.extension;
+ logger.warn("This extension is not allowed to run theme experiments");
+ return;
+ }
+ }
+ }
+ this.load();
+ }
+
+ /**
+ * Loads a theme by reading the properties from the extension's manifest.
+ * This method will override any currently applied theme.
+ */
+ load() {
+ if (!this.lwtData) {
+ this.loadDetails(this.details, this.lwtStyles);
+ if (this.darkDetails) {
+ this.loadDetails(this.darkDetails, this.lwtDarkStyles);
+ }
+
+ this.lwtData = {
+ theme: this.lwtStyles,
+ darkTheme: this.lwtDarkStyles,
+ };
+
+ if (this.experiment) {
+ this.lwtData.experiment = this.experiment;
+ }
+
+ this.extension.startupData = {
+ lwtData: this.lwtData,
+ lwtStyles: this.lwtStyles,
+ lwtDarkStyles: this.lwtDarkStyles,
+ experiment: this.experiment,
+ };
+ this.extension.saveStartupData();
+ }
+
+ if (this.windowId) {
+ this.lwtData.window = windowTracker.getWindow(
+ this.windowId
+ ).docShell.outerWindowID;
+ windowOverrides.set(this.windowId, this);
+ } else {
+ windowOverrides.clear();
+ defaultTheme = this;
+ LightweightThemeManager.fallbackThemeData = this.lwtData;
+ }
+ onUpdatedEmitter.emit("theme-updated", this.details, this.windowId);
+
+ Services.obs.notifyObservers(
+ this.lwtData,
+ "lightweight-theme-styling-update"
+ );
+ }
+
+ /**
+ * @param {object} details Details
+ * @param {object} styles Styles object in which to store the colors.
+ */
+ loadDetails(details, styles) {
+ if (details.colors) {
+ this.loadColors(details.colors, styles);
+ }
+
+ if (details.images) {
+ this.loadImages(details.images, styles);
+ }
+
+ if (details.properties) {
+ this.loadProperties(details.properties, styles);
+ }
+
+ this.loadMetadata(this.extension, styles);
+ }
+
+ /**
+ * Helper method for loading colors found in the extension's manifest.
+ *
+ * @param {object} colors Dictionary mapping color properties to values.
+ * @param {object} styles Styles object in which to store the colors.
+ */
+ loadColors(colors, styles) {
+ for (let color of Object.keys(colors)) {
+ let val = colors[color];
+
+ if (!val) {
+ continue;
+ }
+
+ let cssColor = val;
+ if (Array.isArray(val)) {
+ cssColor =
+ "rgb" + (val.length > 3 ? "a" : "") + "(" + val.join(",") + ")";
+ }
+
+ switch (color) {
+ case "frame":
+ styles.accentcolor = cssColor;
+ break;
+ case "frame_inactive":
+ styles.accentcolorInactive = cssColor;
+ break;
+ case "tab_background_text":
+ styles.textcolor = cssColor;
+ break;
+ case "toolbar":
+ styles.toolbarColor = cssColor;
+ break;
+ case "toolbar_text":
+ case "bookmark_text":
+ styles.toolbar_text = cssColor;
+ break;
+ case "icons":
+ styles.icon_color = cssColor;
+ break;
+ case "icons_attention":
+ styles.icon_attention_color = cssColor;
+ break;
+ case "tab_background_separator":
+ case "tab_loading":
+ case "tab_text":
+ case "tab_line":
+ case "tab_selected":
+ case "toolbar_field":
+ case "toolbar_field_text":
+ case "toolbar_field_border":
+ case "toolbar_field_focus":
+ case "toolbar_field_text_focus":
+ case "toolbar_field_border_focus":
+ case "toolbar_top_separator":
+ case "toolbar_bottom_separator":
+ case "toolbar_vertical_separator":
+ case "button_background_hover":
+ case "button_background_active":
+ case "popup":
+ case "popup_text":
+ case "popup_border":
+ case "popup_highlight":
+ case "popup_highlight_text":
+ case "ntp_background":
+ case "ntp_card_background":
+ case "ntp_text":
+ case "sidebar":
+ case "sidebar_border":
+ case "sidebar_text":
+ case "sidebar_highlight":
+ case "sidebar_highlight_text":
+ case "toolbar_field_highlight":
+ case "toolbar_field_highlight_text":
+ styles[color] = cssColor;
+ break;
+ default:
+ if (
+ this.experiment &&
+ this.experiment.colors &&
+ color in this.experiment.colors
+ ) {
+ styles.experimental.colors[color] = cssColor;
+ } else {
+ const { logger } = this.extension;
+ logger.warn(`Unrecognized theme property found: colors.${color}`);
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Helper method for loading images found in the extension's manifest.
+ *
+ * @param {object} images Dictionary mapping image properties to values.
+ * @param {object} styles Styles object in which to store the colors.
+ */
+ loadImages(images, styles) {
+ const { baseURI, logger } = this.extension;
+
+ for (let image of Object.keys(images)) {
+ let val = images[image];
+
+ if (!val) {
+ continue;
+ }
+
+ switch (image) {
+ case "additional_backgrounds": {
+ let backgroundImages = val.map(img => baseURI.resolve(img));
+ styles.additionalBackgrounds = backgroundImages;
+ break;
+ }
+ case "theme_frame": {
+ let resolvedURL = baseURI.resolve(val);
+ styles.headerURL = resolvedURL;
+ break;
+ }
+ default: {
+ if (
+ this.experiment &&
+ this.experiment.images &&
+ image in this.experiment.images
+ ) {
+ styles.experimental.images[image] = baseURI.resolve(val);
+ } else {
+ logger.warn(`Unrecognized theme property found: images.${image}`);
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper method for preparing properties found in the extension's manifest.
+ * Properties are commonly used to specify more advanced behavior of colors,
+ * images or icons.
+ *
+ * @param {object} properties Dictionary mapping properties to values.
+ * @param {object} styles Styles object in which to store the colors.
+ */
+ loadProperties(properties, styles) {
+ let additionalBackgroundsCount =
+ (styles.additionalBackgrounds && styles.additionalBackgrounds.length) ||
+ 0;
+ const assertValidAdditionalBackgrounds = (property, valueCount) => {
+ const { logger } = this.extension;
+ if (!additionalBackgroundsCount) {
+ logger.warn(
+ `The '${property}' property takes effect only when one ` +
+ `or more additional background images are specified using the 'additional_backgrounds' property.`
+ );
+ return false;
+ }
+ if (additionalBackgroundsCount !== valueCount) {
+ logger.warn(
+ `The amount of values specified for '${property}' ` +
+ `(${valueCount}) is not equal to the amount of additional background ` +
+ `images (${additionalBackgroundsCount}), which may lead to unexpected results.`
+ );
+ }
+ return true;
+ };
+
+ for (let property of Object.getOwnPropertyNames(properties)) {
+ let val = properties[property];
+
+ if (!val) {
+ continue;
+ }
+
+ switch (property) {
+ case "additional_backgrounds_alignment": {
+ if (!assertValidAdditionalBackgrounds(property, val.length)) {
+ break;
+ }
+
+ styles.backgroundsAlignment = val.join(",");
+ break;
+ }
+ case "additional_backgrounds_tiling": {
+ if (!assertValidAdditionalBackgrounds(property, val.length)) {
+ break;
+ }
+
+ let tiling = [];
+ for (let i = 0, l = styles.additionalBackgrounds.length; i < l; ++i) {
+ tiling.push(val[i] || "no-repeat");
+ }
+ styles.backgroundsTiling = tiling.join(",");
+ break;
+ }
+ case "color_scheme":
+ case "content_color_scheme": {
+ styles[property] = val;
+ break;
+ }
+ default: {
+ if (
+ this.experiment &&
+ this.experiment.properties &&
+ property in this.experiment.properties
+ ) {
+ styles.experimental.properties[property] = val;
+ } else {
+ const { logger } = this.extension;
+ logger.warn(
+ `Unrecognized theme property found: properties.${property}`
+ );
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper method for loading extension metadata required by downstream
+ * consumers.
+ *
+ * @param {object} extension Extension object.
+ * @param {object} styles Styles object in which to store the colors.
+ */
+ loadMetadata(extension, styles) {
+ styles.id = extension.id;
+ styles.version = extension.version;
+ }
+
+ static unload(windowId) {
+ let lwtData = {
+ theme: null,
+ };
+
+ if (windowId) {
+ lwtData.window = windowTracker.getWindow(windowId).docShell.outerWindowID;
+ windowOverrides.delete(windowId);
+ } else {
+ windowOverrides.clear();
+ defaultTheme = emptyTheme;
+ LightweightThemeManager.fallbackThemeData = null;
+ }
+ onUpdatedEmitter.emit("theme-updated", {}, windowId);
+
+ Services.obs.notifyObservers(lwtData, "lightweight-theme-styling-update");
+ }
+}
+
+this.theme = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ onUpdated({ fire, context }) {
+ let callback = (event, theme, windowId) => {
+ if (windowId) {
+ // Force access validation for incognito mode by getting the window.
+ if (windowTracker.getWindow(windowId, context, false)) {
+ fire.async({ theme, windowId });
+ }
+ } else {
+ fire.async({ theme });
+ }
+ };
+
+ onUpdatedEmitter.on("theme-updated", callback);
+ return {
+ unregister() {
+ onUpdatedEmitter.off("theme-updated", callback);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ };
+
+ onManifestEntry(entryName) {
+ let { extension } = this;
+ let { manifest } = extension;
+
+ defaultTheme = new Theme({
+ extension,
+ details: manifest.theme,
+ darkDetails: manifest.dark_theme,
+ experiment: manifest.theme_experiment,
+ startupData: extension.startupData,
+ });
+ }
+
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+
+ let { extension } = this;
+ for (let [windowId, theme] of windowOverrides) {
+ if (theme.extension === extension) {
+ Theme.unload(windowId);
+ }
+ }
+
+ if (defaultTheme.extension === extension) {
+ Theme.unload();
+ }
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ theme: {
+ getCurrent: windowId => {
+ // Take last focused window when no ID is supplied.
+ if (!windowId) {
+ windowId = windowTracker.getId(windowTracker.topWindow);
+ }
+ // Force access validation for incognito mode by getting the window.
+ if (!windowTracker.getWindow(windowId, context)) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+
+ if (windowOverrides.has(windowId)) {
+ return Promise.resolve(windowOverrides.get(windowId).details);
+ }
+ return Promise.resolve(defaultTheme.details);
+ },
+ update: (windowId, details) => {
+ if (windowId) {
+ const browserWindow = windowTracker.getWindow(windowId, context);
+ if (!browserWindow) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+ }
+
+ new Theme({
+ extension,
+ details,
+ windowId,
+ experiment: this.extension.manifest.theme_experiment,
+ });
+ },
+ reset: windowId => {
+ if (windowId) {
+ const browserWindow = windowTracker.getWindow(windowId, context);
+ if (!browserWindow) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+
+ let theme = windowOverrides.get(windowId) || defaultTheme;
+ if (theme.extension !== extension) {
+ return;
+ }
+ } else if (defaultTheme.extension !== extension) {
+ return;
+ }
+
+ Theme.unload(windowId);
+ },
+ onUpdated: new EventManager({
+ context,
+ module: "theme",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-toolkit.js b/toolkit/components/extensions/parent/ext-toolkit.js
new file mode 100644
index 0000000000..c672cb96c0
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-toolkit.js
@@ -0,0 +1,130 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// These are defined on "global" which is used for the same scopes as the other
+// ext-*.js files.
+/* exported getCookieStoreIdForTab, getCookieStoreIdForContainer,
+ getContainerForCookieStoreId,
+ isValidCookieStoreId, isContainerCookieStoreId,
+ EventManager, URL */
+/* global getCookieStoreIdForTab:false,
+ getCookieStoreIdForContainer:false,
+ getContainerForCookieStoreId: false,
+ isValidCookieStoreId:false, isContainerCookieStoreId:false,
+ isDefaultCookieStoreId: false, isPrivateCookieStoreId:false,
+ EventManager: false */
+
+ChromeUtils.defineESModuleGetters(this, {
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+global.EventEmitter = ExtensionCommon.EventEmitter;
+global.EventManager = ExtensionCommon.EventManager;
+
+/* globals DEFAULT_STORE, PRIVATE_STORE, CONTAINER_STORE */
+
+global.DEFAULT_STORE = "firefox-default";
+global.PRIVATE_STORE = "firefox-private";
+global.CONTAINER_STORE = "firefox-container-";
+
+global.getCookieStoreIdForTab = function (data, tab) {
+ if (data.incognito) {
+ return PRIVATE_STORE;
+ }
+
+ if (tab.userContextId) {
+ return getCookieStoreIdForContainer(tab.userContextId);
+ }
+
+ return DEFAULT_STORE;
+};
+
+global.getCookieStoreIdForOriginAttributes = function (originAttributes) {
+ if (originAttributes.privateBrowsingId) {
+ return PRIVATE_STORE;
+ }
+
+ if (originAttributes.userContextId) {
+ return getCookieStoreIdForContainer(originAttributes.userContextId);
+ }
+
+ return DEFAULT_STORE;
+};
+
+global.isPrivateCookieStoreId = function (storeId) {
+ return storeId == PRIVATE_STORE;
+};
+
+global.isDefaultCookieStoreId = function (storeId) {
+ return storeId == DEFAULT_STORE;
+};
+
+global.isContainerCookieStoreId = function (storeId) {
+ return storeId !== null && storeId.startsWith(CONTAINER_STORE);
+};
+
+global.getCookieStoreIdForContainer = function (containerId) {
+ return CONTAINER_STORE + containerId;
+};
+
+global.getContainerForCookieStoreId = function (storeId) {
+ if (!isContainerCookieStoreId(storeId)) {
+ return null;
+ }
+
+ let containerId = storeId.substring(CONTAINER_STORE.length);
+
+ if (AppConstants.platform === "android") {
+ return parseInt(containerId, 10);
+ } // TODO: Bug 1643740, support ContextualIdentityService on Android
+
+ if (ContextualIdentityService.getPublicIdentityFromId(containerId)) {
+ return parseInt(containerId, 10);
+ }
+
+ return null;
+};
+
+global.isValidCookieStoreId = function (storeId) {
+ return (
+ isDefaultCookieStoreId(storeId) ||
+ isPrivateCookieStoreId(storeId) ||
+ isContainerCookieStoreId(storeId)
+ );
+};
+
+global.getOriginAttributesPatternForCookieStoreId = function (cookieStoreId) {
+ if (isDefaultCookieStoreId(cookieStoreId)) {
+ return {
+ userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID,
+ privateBrowsingId:
+ Ci.nsIScriptSecurityManager.DEFAULT_PRIVATE_BROWSING_ID,
+ };
+ }
+ if (isPrivateCookieStoreId(cookieStoreId)) {
+ return {
+ userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID,
+ privateBrowsingId: 1,
+ };
+ }
+ if (isContainerCookieStoreId(cookieStoreId)) {
+ let userContextId = getContainerForCookieStoreId(cookieStoreId);
+ if (userContextId !== null) {
+ return { userContextId };
+ }
+ }
+
+ throw new ExtensionError("Invalid cookieStoreId");
+};
diff --git a/toolkit/components/extensions/parent/ext-userScripts.js b/toolkit/components/extensions/parent/ext-userScripts.js
new file mode 100644
index 0000000000..9c008a4e8d
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-userScripts.js
@@ -0,0 +1,158 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+/**
+ * Represents (in the main browser process) a user script.
+ *
+ * @param {UserScriptOptions} details
+ * The options object related to the user script
+ * (which has the properties described in the user_scripts.json
+ * JSON API schema file).
+ */
+class UserScriptParent {
+ constructor(details) {
+ this.scriptId = details.scriptId;
+ this.options = this._convertOptions(details);
+ }
+
+ destroy() {
+ if (this.destroyed) {
+ throw new Error("Unable to destroy UserScriptParent twice");
+ }
+
+ this.destroyed = true;
+ this.options = null;
+ }
+
+ _convertOptions(details) {
+ const options = {
+ matches: details.matches,
+ excludeMatches: details.excludeMatches,
+ includeGlobs: details.includeGlobs,
+ excludeGlobs: details.excludeGlobs,
+ allFrames: details.allFrames,
+ matchAboutBlank: details.matchAboutBlank,
+ runAt: details.runAt || "document_idle",
+ jsPaths: details.js,
+ userScriptOptions: {
+ scriptMetadata: details.scriptMetadata,
+ },
+ originAttributesPatterns: null,
+ };
+
+ if (details.cookieStoreId != null) {
+ const cookieStoreIds = Array.isArray(details.cookieStoreId)
+ ? details.cookieStoreId
+ : [details.cookieStoreId];
+ options.originAttributesPatterns = cookieStoreIds.map(cookieStoreId =>
+ getOriginAttributesPatternForCookieStoreId(cookieStoreId)
+ );
+ }
+
+ return options;
+ }
+
+ serialize() {
+ return this.options;
+ }
+}
+
+this.userScripts = class extends ExtensionAPI {
+ constructor(...args) {
+ super(...args);
+
+ // Map<scriptId -> UserScriptParent>
+ this.userScriptsMap = new Map();
+ }
+
+ getAPI(context) {
+ const { extension } = context;
+
+ // Set of the scriptIds registered from this context.
+ const registeredScriptIds = new Set();
+
+ const unregisterContentScripts = scriptIds => {
+ if (scriptIds.length === 0) {
+ return Promise.resolve();
+ }
+
+ for (let scriptId of scriptIds) {
+ registeredScriptIds.delete(scriptId);
+ extension.registeredContentScripts.delete(scriptId);
+ this.userScriptsMap.delete(scriptId);
+ }
+ extension.updateContentScripts();
+
+ return context.extension.broadcast("Extension:UnregisterContentScripts", {
+ id: context.extension.id,
+ scriptIds,
+ });
+ };
+
+ // Unregister all the scriptId related to a context when it is closed,
+ // and revoke all the created blob urls once the context is destroyed.
+ context.callOnClose({
+ close() {
+ unregisterContentScripts(Array.from(registeredScriptIds));
+ },
+ });
+
+ return {
+ userScripts: {
+ register: async details => {
+ for (let origin of details.matches) {
+ if (!extension.allowedOrigins.subsumes(new MatchPattern(origin))) {
+ throw new ExtensionError(
+ `Permission denied to register a user script for ${origin}`
+ );
+ }
+ }
+
+ const userScript = new UserScriptParent(details);
+ const { scriptId } = userScript;
+
+ this.userScriptsMap.set(scriptId, userScript);
+ registeredScriptIds.add(scriptId);
+
+ const scriptOptions = userScript.serialize();
+
+ extension.registeredContentScripts.set(scriptId, scriptOptions);
+ extension.updateContentScripts();
+
+ await extension.broadcast("Extension:RegisterContentScripts", {
+ id: extension.id,
+ scripts: [{ scriptId, options: scriptOptions }],
+ });
+
+ return scriptId;
+ },
+
+ // This method is not available to the extension code, the extension code
+ // doesn't have access to the internally used scriptId, on the contrary
+ // the extension code will call script.unregister on the script API object
+ // that is resolved from the register API method returned promise.
+ unregister: async scriptId => {
+ const userScript = this.userScriptsMap.get(scriptId);
+ if (!userScript) {
+ throw new Error(`No such user script ID: ${scriptId}`);
+ }
+
+ userScript.destroy();
+
+ await unregisterContentScripts([scriptId]);
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-webNavigation.js b/toolkit/components/extensions/parent/ext-webNavigation.js
new file mode 100644
index 0000000000..c65b61041b
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-webNavigation.js
@@ -0,0 +1,276 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// This file expects tabTracker to be defined in the global scope (e.g.
+// by ext-browser.js or ext-android.js).
+/* global tabTracker */
+
+ChromeUtils.defineESModuleGetters(this, {
+ MatchURLFilters: "resource://gre/modules/MatchURLFilters.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ WebNavigation: "resource://gre/modules/WebNavigation.sys.mjs",
+ WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs",
+});
+
+var { ExtensionError } = ExtensionUtils;
+
+const defaultTransitionTypes = {
+ topFrame: "link",
+ subFrame: "auto_subframe",
+};
+
+const frameTransitions = {
+ anyFrame: {
+ qualifiers: ["server_redirect", "client_redirect", "forward_back"],
+ },
+ topFrame: {
+ types: ["reload", "form_submit"],
+ },
+};
+
+const tabTransitions = {
+ topFrame: {
+ qualifiers: ["from_address_bar"],
+ types: ["auto_bookmark", "typed", "keyword", "generated", "link"],
+ },
+ subFrame: {
+ types: ["manual_subframe"],
+ },
+};
+
+const isTopLevelFrame = ({ frameId, parentFrameId }) => {
+ return frameId == 0 && parentFrameId == -1;
+};
+
+const fillTransitionProperties = (eventName, src, dst) => {
+ if (
+ eventName == "onCommitted" ||
+ eventName == "onHistoryStateUpdated" ||
+ eventName == "onReferenceFragmentUpdated"
+ ) {
+ let frameTransitionData = src.frameTransitionData || {};
+ let tabTransitionData = src.tabTransitionData || {};
+
+ let transitionType,
+ transitionQualifiers = [];
+
+ // Fill transition properties for any frame.
+ for (let qualifier of frameTransitions.anyFrame.qualifiers) {
+ if (frameTransitionData[qualifier]) {
+ transitionQualifiers.push(qualifier);
+ }
+ }
+
+ if (isTopLevelFrame(dst)) {
+ for (let type of frameTransitions.topFrame.types) {
+ if (frameTransitionData[type]) {
+ transitionType = type;
+ }
+ }
+
+ for (let qualifier of tabTransitions.topFrame.qualifiers) {
+ if (tabTransitionData[qualifier]) {
+ transitionQualifiers.push(qualifier);
+ }
+ }
+
+ for (let type of tabTransitions.topFrame.types) {
+ if (tabTransitionData[type]) {
+ transitionType = type;
+ }
+ }
+
+ // If transitionType is not defined, defaults it to "link".
+ if (!transitionType) {
+ transitionType = defaultTransitionTypes.topFrame;
+ }
+ } else {
+ // If it is sub-frame, transitionType defaults it to "auto_subframe",
+ // "manual_subframe" is set only in case of a recent user interaction.
+ transitionType = tabTransitionData.link
+ ? "manual_subframe"
+ : defaultTransitionTypes.subFrame;
+ }
+
+ // Fill the transition properties in the webNavigation event object.
+ dst.transitionType = transitionType;
+ dst.transitionQualifiers = transitionQualifiers;
+ }
+};
+
+this.webNavigation = class extends ExtensionAPIPersistent {
+ makeEventHandler(event) {
+ let { extension } = this;
+ let { tabManager } = extension;
+ return ({ fire }, params) => {
+ // Don't create a MatchURLFilters instance if the listener does not include any filter.
+ let [urlFilters] = params;
+ let filters = urlFilters ? new MatchURLFilters(urlFilters.url) : null;
+
+ let listener = data => {
+ if (!data.browser) {
+ return;
+ }
+ if (
+ !extension.privateBrowsingAllowed &&
+ PrivateBrowsingUtils.isBrowserPrivate(data.browser)
+ ) {
+ return;
+ }
+ if (filters && !filters.matches(data.url)) {
+ return;
+ }
+
+ let data2 = {
+ url: data.url,
+ timeStamp: Date.now(),
+ };
+
+ if (event == "onErrorOccurred") {
+ data2.error = data.error;
+ }
+
+ if (data.frameId != undefined) {
+ data2.frameId = data.frameId;
+ data2.parentFrameId = data.parentFrameId;
+ }
+
+ if (data.sourceFrameId != undefined) {
+ data2.sourceFrameId = data.sourceFrameId;
+ }
+
+ // Do not send a webNavigation event when the data.browser is related to a tab from a
+ // new window opened to adopt an existent tab (See Bug 1443221 for a rationale).
+ const chromeWin = data.browser.ownerGlobal;
+
+ if (
+ chromeWin &&
+ chromeWin.gBrowser &&
+ chromeWin.gBrowserInit &&
+ chromeWin.gBrowserInit.isAdoptingTab() &&
+ chromeWin.gBrowser.selectedBrowser === data.browser
+ ) {
+ return;
+ }
+
+ // Fills in tabId typically.
+ Object.assign(data2, tabTracker.getBrowserData(data.browser));
+ if (data2.tabId < 0) {
+ return;
+ }
+ let tab = tabTracker.getTab(data2.tabId);
+ if (!tabManager.canAccessTab(tab)) {
+ return;
+ }
+
+ if (data.sourceTabBrowser) {
+ data2.sourceTabId = tabTracker.getBrowserData(
+ data.sourceTabBrowser
+ ).tabId;
+ }
+
+ fillTransitionProperties(event, data, data2);
+
+ fire.async(data2);
+ };
+
+ WebNavigation[event].addListener(listener);
+ return {
+ unregister() {
+ WebNavigation[event].removeListener(listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ };
+ }
+
+ makeEventManagerAPI(event, context) {
+ let self = this;
+ return new EventManager({
+ context,
+ module: "webNavigation",
+ event,
+ register(fire, ...params) {
+ let fn = self.makeEventHandler(event);
+ return fn({ fire }, params).unregister;
+ },
+ }).api();
+ }
+
+ PERSISTENT_EVENTS = {
+ onBeforeNavigate: this.makeEventHandler("onBeforeNavigate"),
+ onCommitted: this.makeEventHandler("onCommitted"),
+ onDOMContentLoaded: this.makeEventHandler("onDOMContentLoaded"),
+ onCompleted: this.makeEventHandler("onCompleted"),
+ onErrorOccurred: this.makeEventHandler("onErrorOccurred"),
+ onReferenceFragmentUpdated: this.makeEventHandler(
+ "onReferenceFragmentUpdated"
+ ),
+ onHistoryStateUpdated: this.makeEventHandler("onHistoryStateUpdated"),
+ onCreatedNavigationTarget: this.makeEventHandler(
+ "onCreatedNavigationTarget"
+ ),
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ return {
+ webNavigation: {
+ // onTabReplaced does nothing, it exists for compat.
+ onTabReplaced: new EventManager({
+ context,
+ name: "webNavigation.onTabReplaced",
+ register: fire => {
+ return () => {};
+ },
+ }).api(),
+ onBeforeNavigate: this.makeEventManagerAPI("onBeforeNavigate", context),
+ onCommitted: this.makeEventManagerAPI("onCommitted", context),
+ onDOMContentLoaded: this.makeEventManagerAPI(
+ "onDOMContentLoaded",
+ context
+ ),
+ onCompleted: this.makeEventManagerAPI("onCompleted", context),
+ onErrorOccurred: this.makeEventManagerAPI("onErrorOccurred", context),
+ onReferenceFragmentUpdated: this.makeEventManagerAPI(
+ "onReferenceFragmentUpdated",
+ context
+ ),
+ onHistoryStateUpdated: this.makeEventManagerAPI(
+ "onHistoryStateUpdated",
+ context
+ ),
+ onCreatedNavigationTarget: this.makeEventManagerAPI(
+ "onCreatedNavigationTarget",
+ context
+ ),
+ getAllFrames({ tabId }) {
+ let tab = tabManager.get(tabId);
+ if (tab.discarded) {
+ return null;
+ }
+ let frames = WebNavigationFrames.getAllFrames(tab.browsingContext);
+ return frames.map(fd => ({ tabId, ...fd }));
+ },
+ getFrame({ tabId, frameId }) {
+ let tab = tabManager.get(tabId);
+ if (tab.discarded) {
+ return null;
+ }
+ let fd = WebNavigationFrames.getFrame(tab.browsingContext, frameId);
+ if (!fd) {
+ throw new ExtensionError(`No frame found with frameId: ${frameId}`);
+ }
+ return { tabId, ...fd };
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-webRequest.js b/toolkit/components/extensions/parent/ext-webRequest.js
new file mode 100644
index 0000000000..4f0ea90abd
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-webRequest.js
@@ -0,0 +1,206 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ WebRequest: "resource://gre/modules/WebRequest.sys.mjs",
+});
+
+var { parseMatchPatterns } = ExtensionUtils;
+
+// The guts of a WebRequest event handler. Takes care of converting
+// |details| parameter when invoking listeners.
+function registerEvent(
+ extension,
+ eventName,
+ fire,
+ filter,
+ info,
+ remoteTab = null
+) {
+ let listener = async data => {
+ let event = data.serialize(eventName);
+ if (data.registerTraceableChannel) {
+ // If this is a primed listener, no tabParent was passed in here,
+ // but the convert() callback later in this function will be called
+ // when the background page is started. Force that to happen here
+ // after which we'll have a valid tabParent.
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ data.registerTraceableChannel(extension.policy, remoteTab);
+ }
+
+ return fire.sync(event);
+ };
+
+ let filter2 = {};
+ if (filter.urls) {
+ let perms = new MatchPatternSet([
+ ...extension.allowedOrigins.patterns,
+ ...extension.optionalOrigins.patterns,
+ ]);
+
+ filter2.urls = parseMatchPatterns(filter.urls);
+
+ if (!perms.overlapsAll(filter2.urls)) {
+ Cu.reportError(
+ "The webRequest.addListener filter doesn't overlap with host permissions."
+ );
+ }
+ }
+ if (filter.types) {
+ filter2.types = filter.types;
+ }
+ if (filter.tabId !== undefined) {
+ filter2.tabId = filter.tabId;
+ }
+ if (filter.windowId !== undefined) {
+ filter2.windowId = filter.windowId;
+ }
+ if (filter.incognito !== undefined) {
+ filter2.incognito = filter.incognito;
+ }
+
+ let blockingAllowed = extension.hasPermission("webRequestBlocking");
+
+ let info2 = [];
+ if (info) {
+ for (let desc of info) {
+ if (desc == "blocking" && !blockingAllowed) {
+ // This is usually checked in the child process (based on the API schemas, where these options
+ // should be checked with the "webRequestBlockingPermissionRequired" postprocess property),
+ // but it is worth to also check it here just in case a new webRequest has been added and
+ // it has not yet using the expected postprocess property).
+ Cu.reportError(
+ "Using webRequest.addListener with the blocking option " +
+ "requires the 'webRequestBlocking' permission."
+ );
+ } else {
+ info2.push(desc);
+ }
+ }
+ }
+
+ let listenerDetails = {
+ addonId: extension.id,
+ policy: extension.policy,
+ blockingAllowed,
+ };
+ WebRequest[eventName].addListener(listener, filter2, info2, listenerDetails);
+
+ return {
+ unregister: () => {
+ WebRequest[eventName].removeListener(listener);
+ },
+ convert(_fire, context) {
+ fire = _fire;
+ remoteTab = context.xulBrowser.frameLoader.remoteTab;
+ },
+ };
+}
+
+function makeWebRequestEventAPI(context, event, extensionApi) {
+ return new EventManager({
+ context,
+ module: "webRequest",
+ event,
+ extensionApi,
+ }).api();
+}
+
+function makeWebRequestEventRegistrar(event) {
+ return function ({ fire, context }, params) {
+ // ExtensionAPIPersistent makes sure this function will be bound
+ // to the ExtensionAPIPersistent instance.
+ const { extension } = this;
+
+ const [filter, info] = params;
+
+ // When we are registering the real listener coming from the extension context,
+ // we should get the additional remoteTab parameter value from the extension context
+ // (which is then used by the registerTraceableChannel helper to register stream
+ // filters to the channel and associate them to the extension context that has
+ // created it and will be handling the filter onstart/ondata/onend events).
+ let remoteTab;
+ if (context) {
+ remoteTab = context.xulBrowser.frameLoader.remoteTab;
+ }
+
+ return registerEvent(extension, event, fire, filter, info, remoteTab);
+ };
+}
+
+this.webRequest = class extends ExtensionAPIPersistent {
+ primeListener(event, fire, params, isInStartup) {
+ // During early startup if the listener does not use blocking we do not prime it.
+ if (!isInStartup || params[1]?.includes("blocking")) {
+ return super.primeListener(event, fire, params, isInStartup);
+ }
+ }
+
+ PERSISTENT_EVENTS = {
+ onBeforeRequest: makeWebRequestEventRegistrar("onBeforeRequest"),
+ onBeforeSendHeaders: makeWebRequestEventRegistrar("onBeforeSendHeaders"),
+ onSendHeaders: makeWebRequestEventRegistrar("onSendHeaders"),
+ onHeadersReceived: makeWebRequestEventRegistrar("onHeadersReceived"),
+ onAuthRequired: makeWebRequestEventRegistrar("onAuthRequired"),
+ onBeforeRedirect: makeWebRequestEventRegistrar("onBeforeRedirect"),
+ onResponseStarted: makeWebRequestEventRegistrar("onResponseStarted"),
+ onErrorOccurred: makeWebRequestEventRegistrar("onErrorOccurred"),
+ onCompleted: makeWebRequestEventRegistrar("onCompleted"),
+ };
+
+ getAPI(context) {
+ return {
+ webRequest: {
+ onBeforeRequest: makeWebRequestEventAPI(
+ context,
+ "onBeforeRequest",
+ this
+ ),
+ onBeforeSendHeaders: makeWebRequestEventAPI(
+ context,
+ "onBeforeSendHeaders",
+ this
+ ),
+ onSendHeaders: makeWebRequestEventAPI(context, "onSendHeaders", this),
+ onHeadersReceived: makeWebRequestEventAPI(
+ context,
+ "onHeadersReceived",
+ this
+ ),
+ onAuthRequired: makeWebRequestEventAPI(context, "onAuthRequired", this),
+ onBeforeRedirect: makeWebRequestEventAPI(
+ context,
+ "onBeforeRedirect",
+ this
+ ),
+ onResponseStarted: makeWebRequestEventAPI(
+ context,
+ "onResponseStarted",
+ this
+ ),
+ onErrorOccurred: makeWebRequestEventAPI(
+ context,
+ "onErrorOccurred",
+ this
+ ),
+ onCompleted: makeWebRequestEventAPI(context, "onCompleted", this),
+ getSecurityInfo: function (requestId, options = {}) {
+ return WebRequest.getSecurityInfo({
+ id: requestId,
+ policy: context.extension.policy,
+ remoteTab: context.xulBrowser.frameLoader.remoteTab,
+ options,
+ });
+ },
+ handlerBehaviorChanged: function () {
+ // TODO: Flush all caches.
+ },
+ },
+ };
+ }
+};