diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/extensions/parent | |
parent | Initial commit. (diff) | |
download | firefox-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')
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. + }, + }, + }; + } +}; |