diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:44:51 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:44:51 +0000 |
commit | 9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/components/extensions/parent/ext-backgroundPage.js | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/extensions/parent/ext-backgroundPage.js')
-rw-r--r-- | toolkit/components/extensions/parent/ext-backgroundPage.js | 690 |
1 files changed, 690 insertions, 0 deletions
diff --git a/toolkit/components/extensions/parent/ext-backgroundPage.js b/toolkit/components/extensions/parent/ext-backgroundPage.js new file mode 100644 index 0000000000..725be65122 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-backgroundPage.js @@ -0,0 +1,690 @@ +/* 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, + promiseExtensionViewLoaded, + watchExtensionWorkerContextLoaded, +} = ExtensionParent; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.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) +); + +// 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, the SUSPENDING is not used. + */ +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 = promiseExtensionViewLoaded(this.browser); + this.browser.fixupAndLoadURIString(this.url, { + triggeringPrincipal: extension.principal, + }); + + context = await contextPromise; + + 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 => { + 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 + ); + + 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); + }); + } +} + +this.backgroundPage = class extends ExtensionAPI { + async build() { + if (this.bgInstance) { + return; + } + + let { extension } = this; + let { manifest } = extension; + extension.backgroundState = BACKGROUND_STATE.STARTING; + + this.isWorker = Boolean(manifest.background.service_worker); + + let BackgroundClass = this.isWorker ? BackgroundWorker : BackgroundPage; + + this.bgInstance = new BackgroundClass(extension, manifest.background); + let context; + try { + context = await this.bgInstance.build(); + // Top level execution already happened, RUNNING is + // a touch after the fact. + if (context && this.extension) { + extension.backgroundState = BACKGROUND_STATE.RUNNING; + } + } catch (e) { + Cu.reportError(e); + if (extension.persistentListeners) { + // Clear the primed listeners, but leave them persisted. + EventManager.clearPrimedListeners(extension, false); + } + extension.backgroundState = BACKGROUND_STATE.STOPPED; + extension.emit("background-script-aborted"); + 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 (extension.persistentListeners) { + // |this.extension| may be null if the extension was shut down. + // In that case, we still want to clear the primed listeners, + // but not update the persistent listeners in the startupData. + EventManager.clearPrimedListeners(extension, !!this.extension); + } + + if (!context || !this.extension) { + extension.backgroundState = BACKGROUND_STATE.STOPPED; + extension.emit("background-script-aborted"); + return; + } + if (!context.unloaded) { + notifyBackgroundScriptStatus(extension.id, true); + context.callOnClose({ + close() { + notifyBackgroundScriptStatus(extension.id, false); + }, + }); + } + + extension.emit("background-script-started"); + } + + 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; + } + + async primeBackground(isInStartup = true) { + let { extension } = this; + + if (this.bgInstance) { + Cu.reportError(`background script exists before priming ${extension.id}`); + } + this.bgInstance = null; + + // 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; + } + + // 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) { + 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"; + break; + case "hasActiveNativeAppPorts": + category = "reset_nativeapp"; + break; + case "hasActiveStreamFilter": + category = "reset_streamfilter"; + break; + case "pendingListeners": + category = "reset_listeners"; + break; + } + + 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); + + 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); + this.onShutdown(false); + + // TODO(Bug 1790087): record similar telemetry for background service worker. + if (!this.isWorker) { + ExtensionTelemetry.eventPageIdleResult.histogramAdd({ + extension, + category: "suspend", + }); + } + + EventManager.clearPrimedListeners(this.extension, false); + // Setup background startup listeners for next primed event. + await this.primeBackground(false); + }; + + // 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 ( + isInStartup && + (extension.testNoDelayedStartup || + extension.startupReason !== "APP_STARTUP" || + extension.updateReason) + ) { + return this.build(); + } + + EventManager.primeListeners(extension, isInStartup); + + extension.once("start-background-script", async () => { + if (!this.extension) { + // 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. Note that we have + // to touch browserPaintedPromise here to initialize the listener + // or else we can miss it if the event occurs after the first + // window is painted but before #2 + // 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"); + }); + } + + onShutdown(isAppShutdown) { + this.extension.backgroundState = BACKGROUND_STATE.STOPPED; + // Ensure there is no backgroundTimer running + this.clearIdleTimer(); + + if (this.bgInstance) { + const { msSinceCreated } = this.bgInstance; + this.bgInstance.shutdown(isAppShutdown); + this.bgInstance = null; + + const { extension } = this; + + // Emit an event for tests. + extension.emit("shutdown-background-script"); + + const now = msSinceProcessStartExcludingSuspend(); + if ( + msSinceCreated && + now && + // TODO(Bug 1790087): record similar telemetry for background service worker. + !(this.isWorker || extension.persistentBackground) + ) { + ExtensionTelemetry.eventPageRunningTime.histogramAdd({ + extension, + value: now - msSinceCreated, + }); + } + } else { + EventManager.clearPrimedListeners(this.extension, false); + } + } + + async onManifestEntry(entryName) { + let { extension } = this; + extension.backgroundState = BACKGROUND_STATE.STOPPED; + + // 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"); + }); + + await this.primeBackground(); + + ExtensionParent.browserStartupPromise.then(() => { + // Return early if the background was created in the first + // primeBackground call. This happens for install, upgrade, downgrade. + if (this.bgInstance) { + return; + } + + // 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); + } + }); + } +}; |