/** * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * This nsIAboutModule is for about:home and about:newtab. The primary * job of the AboutNewTabRedirector is to resolve requests to load about:home * and about:newtab to the appropriate resources for those requests. * * The AboutNewTabRedirector is not involved when the user has overridden * the default about:home or about:newtab pages. * * There are two implementations of this nsIAboutModule - one for the parent * process, and one for content processes. Each one has some secondary * responsibilties that are process-specific. * * The need for two implementations is an unfortunate consequence of how * document loading and process redirection for about: pages currently * works in Gecko. The commonalities between the two implementations has * been put into an abstract base class. */ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs", }); XPCOMUtils.defineLazyPreferenceGetter( lazy, "BUILTIN_NEWTAB_ENABLED", "browser.newtabpage.enabled", true ); /** * BEWARE: Do not add variables for holding state in the global scope. * Any state variables should be properties of the appropriate class * below. This is to avoid confusion where the state is set in one process, * but not in another. * * Constants are fine in the global scope. */ const PREF_ABOUT_HOME_CACHE_TESTING = "browser.startup.homepage.abouthome_cache.testing"; const CACHE_WORKER_URL = "resource://newtab/lib/cache.worker.js"; const IS_PRIVILEGED_PROCESS = Services.appinfo.remoteType === E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE; const PREF_ACTIVITY_STREAM_DEBUG = "browser.newtabpage.activity-stream.debug"; const PREF_NEWTAB_SELF_LOADING = "browser.newtabpage.activity-stream.selfLoading.enabled"; /** * The AboutHomeStartupCacheChild is responsible for connecting the * AboutNewTabRedirectorChild with a cached document and script for about:home * if one happens to exist. The AboutHomeStartupCacheChild is only ever * handed the streams for those caches when the "privileged about content * process" first launches, so subsequent loads of about:home do not read * from this cache. * * See https://firefox-source-docs.mozilla.org/browser/extensions/newtab/docs/v2-system-addon/about_home_startup_cache.html * for further details. */ export const AboutHomeStartupCacheChild = { _initted: false, CACHE_REQUEST_MESSAGE: "AboutHomeStartupCache:CacheRequest", CACHE_RESPONSE_MESSAGE: "AboutHomeStartupCache:CacheResponse", CACHE_USAGE_RESULT_MESSAGE: "AboutHomeStartupCache:UsageResult", STATES: { UNAVAILABLE: 0, UNCONSUMED: 1, PAGE_CONSUMED: 2, PAGE_AND_SCRIPT_CONSUMED: 3, FAILED: 4, DISQUALIFIED: 5, }, REQUEST_TYPE: { PAGE: 0, SCRIPT: 1, }, _state: 0, _consumerBCID: null, /** * Called via a process script very early on in the process lifetime. This * prepares the AboutHomeStartupCacheChild to pass an nsIChannel back to * the AboutNewTabRedirectorChild when the initial about:home document is * eventually requested. * * @param {nsIInputStream} pageInputStream * The stream for the cached page markup. * @param {nsIInputStream} scriptInputStream * The stream for the cached script to run on the page. */ init(pageInputStream, scriptInputStream) { if ( !IS_PRIVILEGED_PROCESS && !Services.prefs.getBoolPref(PREF_ABOUT_HOME_CACHE_TESTING, false) ) { throw new Error( "Can only instantiate in the privileged about content processes." ); } if ( !Services.prefs.getBoolPref( "browser.startup.homepage.abouthome_cache.enabled" ) ) { return; } if (this._initted) { throw new Error("AboutHomeStartupCacheChild already initted."); } Services.obs.addObserver(this, "memory-pressure"); Services.cpmm.addMessageListener(this.CACHE_REQUEST_MESSAGE, this); this._pageInputStream = pageInputStream; this._scriptInputStream = scriptInputStream; this._initted = true; this.setState(this.STATES.UNCONSUMED); }, /** * A function that lets us put the AboutHomeStartupCacheChild back into * its initial state. This is used by tests to let us simulate the startup * behaviour of the module without having to manually launch a new privileged * about content process every time. */ uninit() { if (!Services.prefs.getBoolPref(PREF_ABOUT_HOME_CACHE_TESTING, false)) { throw new Error( "Cannot uninit AboutHomeStartupCacheChild unless testing." ); } if (!this._initted) { return; } Services.obs.removeObserver(this, "memory-pressure"); Services.cpmm.removeMessageListener(this.CACHE_REQUEST_MESSAGE, this); if (this._cacheWorker) { this._cacheWorker.terminate(); this._cacheWorker = null; } this._pageInputStream = null; this._scriptInputStream = null; this._initted = false; this._state = this.STATES.UNAVAILABLE; this._consumerBCID = null; }, /** * Attempts to return an nsIChannel for a cached about:home document that * we were initialized with. If we failed to be initted with the cache, or the * input streams that we were sent have no data yet available, this function * returns null. The caller should fall back to generating the page * dynamically. * * This function will be called when loading about:home, or * about:home?jscache - the latter returns the cached script. * * It is expected that the same BrowsingContext that loads the cached * page will also load the cached script. * * @param {nsIURI} uri * The URI for the requested page, as passed by AboutNewTabRedirectorChild. * @param {nsILoadInfo} loadInfo * The nsILoadInfo for the requested load, as passed by * AboutNewTabRedirectorChild. * @returns {?nsIChannel} */ maybeGetCachedPageChannel(uri, loadInfo) { if (!this._initted) { return null; } if (this._state >= this.STATES.PAGE_AND_SCRIPT_CONSUMED) { return null; } let requestType = uri.query === "jscache" ? this.REQUEST_TYPE.SCRIPT : this.REQUEST_TYPE.PAGE; // If this is a page request, then we need to be in the UNCONSUMED state, // since we expect the page request to come first. If this is a script // request, we expect to be in PAGE_CONSUMED state, since the page cache // stream should he been consumed already. if ( (requestType === this.REQUEST_TYPE.PAGE && this._state !== this.STATES.UNCONSUMED) || (requestType === this.REQUEST_TYPE_SCRIPT && this._state !== this.STATES.PAGE_CONSUMED) ) { return null; } // If by this point, we don't have anything in the streams, // then either the cache was too slow to give us data, or the cache // doesn't exist. The caller should fall back to generating the // page dynamically. // // We only do this on the page request, because by the time // we get to the script request, we should have already drained // the page input stream. if (requestType === this.REQUEST_TYPE.PAGE) { try { if ( !this._scriptInputStream.available() || !this._pageInputStream.available() ) { this.setState(this.STATES.FAILED); this.reportUsageResult(false /* success */); return null; } } catch (e) { this.setState(this.STATES.FAILED); if (e.result === Cr.NS_BASE_STREAM_CLOSED) { this.reportUsageResult(false /* success */); return null; } throw e; } } if ( requestType === this.REQUEST_TYPE.SCRIPT && this._consumerBCID !== loadInfo.browsingContextID ) { // Some other document is somehow requesting the script - one // that didn't originally request the page. This is not allowed. this.setState(this.STATES.FAILED); return null; } let channel = Cc[ "@mozilla.org/network/input-stream-channel;1" ].createInstance(Ci.nsIInputStreamChannel); channel.QueryInterface(Ci.nsIChannel); channel.setURI(uri); channel.loadInfo = loadInfo; channel.contentStream = requestType === this.REQUEST_TYPE.PAGE ? this._pageInputStream : this._scriptInputStream; if (requestType === this.REQUEST_TYPE.SCRIPT) { this.setState(this.STATES.PAGE_AND_SCRIPT_CONSUMED); this.reportUsageResult(true /* success */); } else { this.setState(this.STATES.PAGE_CONSUMED); // Stash the BrowsingContext ID so that when the script stream // attempts to be consumed, we ensure that it's from the same // BrowsingContext that loaded the page. this._consumerBCID = loadInfo.browsingContextID; } return channel; }, /** * This function takes the state information required to generate * the about:home cache markup and script, and then generates that * markup in script asynchronously. Once that's done, a message * is sent to the parent process with the nsIInputStream's for the * markup and script contents. * * @param {object} state * The Redux state of the about:home document to render. * @returns {Promise} * Fulfills after the message with the nsIInputStream's have been sent to * the parent. */ async constructAndSendCache(state) { if (!IS_PRIVILEGED_PROCESS) { throw new Error("Wrong process type."); } let worker = this.getOrCreateWorker(); let timerId = Glean.newtab.abouthomeCacheConstruction.start(); let { page, script } = await worker .post("construct", [state]) .finally(() => { Glean.newtab.abouthomeCacheConstruction.stopAndAccumulate(timerId); }); let pageInputStream = Cc[ "@mozilla.org/io/string-input-stream;1" ].createInstance(Ci.nsIStringInputStream); pageInputStream.setUTF8Data(page); let scriptInputStream = Cc[ "@mozilla.org/io/string-input-stream;1" ].createInstance(Ci.nsIStringInputStream); scriptInputStream.setUTF8Data(script); Services.cpmm.sendAsyncMessage(this.CACHE_RESPONSE_MESSAGE, { pageInputStream, scriptInputStream, }); }, _cacheWorker: null, getOrCreateWorker() { if (this._cacheWorker) { return this._cacheWorker; } this._cacheWorker = new lazy.BasePromiseWorker(CACHE_WORKER_URL); return this._cacheWorker; }, receiveMessage(message) { if (message.name === this.CACHE_REQUEST_MESSAGE) { let { state } = message.data; this.constructAndSendCache(state); } }, reportUsageResult(success) { Services.cpmm.sendAsyncMessage(this.CACHE_USAGE_RESULT_MESSAGE, { success, }); }, observe(subject, topic) { if (topic === "memory-pressure" && this._cacheWorker) { this._cacheWorker.terminate(); this._cacheWorker = null; } }, /** * Transitions the AboutHomeStartupCacheChild from one state * to the next, where each state is defined in this.STATES. * * States can only be transitioned in increasing order, otherwise * an error is logged. */ setState(state) { if (state > this._state) { this._state = state; } else { console.error( "AboutHomeStartupCacheChild could not transition from state " + `${this._state} to ${state}`, new Error().stack ); } }, /** * If the cache hasn't been used, transitions it into the DISQUALIFIED * state so that it cannot be used. This should be called if it's been * determined that about:newtab is going to be loaded, which doesn't * use the cache. */ disqualifyCache() { if (this._state === this.STATES.UNCONSUMED) { this.setState(this.STATES.DISQUALIFIED); this.reportUsageResult(false /* success */); } }, }; /** * This is an abstract base class for the nsIAboutModule implementations for * about:home and about:newtab that has some common methods and properties. */ class BaseAboutNewTabRedirector { constructor() { if (!AppConstants.RELEASE_OR_BETA) { XPCOMUtils.defineLazyPreferenceGetter( this, "activityStreamDebug", PREF_ACTIVITY_STREAM_DEBUG, false ); } else { this.activityStreamDebug = false; } XPCOMUtils.defineLazyPreferenceGetter( this, "selfLoadingEnabled", PREF_NEWTAB_SELF_LOADING, false ); } /** * @returns {string} the default URL * * This URL depends on various activity stream prefs. Overriding * the newtab page has no effect on the result of this function. */ get defaultURL() { // Generate the desired activity stream resource depending on state, e.g., // "resource://newtab/prerendered/activity-stream.html" // "resource://newtab/prerendered/activity-stream-debug.html" // "resource://newtab/prerendered/activity-stream-noscripts.html" return [ "resource://newtab/prerendered/", "activity-stream", this.activityStreamDebug && this.selfLoadingEnabled ? "-debug" : "", this.selfLoadingEnabled ? "" : "-noscripts", ".html", ].join(""); } newChannel() { throw Components.Exception( "getChannel not implemented for this process.", Cr.NS_ERROR_NOT_IMPLEMENTED ); } getURIFlags() { return ( Ci.nsIAboutModule.ALLOW_SCRIPT | Ci.nsIAboutModule.ENABLE_INDEXED_DB | Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD | Ci.nsIAboutModule.URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT ); } getChromeURI() { return Services.io.newURI("chrome://browser/content/blanktab.html"); } QueryInterface = ChromeUtils.generateQI(["nsIAboutModule"]); } /** * The parent-process implementation of the nsIAboutModule, which is the first * stop for when requests are made to visit about:home or about:newtab (so * before the AboutNewTabRedirectorChild has a chance to handle the request). */ export class AboutNewTabRedirectorParent extends BaseAboutNewTabRedirector { #addonInitialized = false; #suspendedChannels = []; #addonInitializedPromise = null; #addonInitializedResolver = null; constructor() { super(); let { promise, resolve } = Promise.withResolvers(); this.#addonInitializedPromise = promise; this.#addonInitializedResolver = resolve; ChromeUtils.registerWindowActor("AboutNewTab", { parent: { esModuleURI: "resource:///actors/AboutNewTabParent.sys.mjs", }, child: { esModuleURI: "resource:///actors/AboutNewTabChild.sys.mjs", events: { DOMDocElementInserted: {}, DOMContentLoaded: { capture: true }, load: { capture: true }, unload: { capture: true }, pageshow: {}, visibilitychange: {}, }, }, // The wildcard on about:newtab is for the # parameter // that is used for the newtab devtools. The wildcard for about:home // is similar, and also allows for falling back to loading the // about:home document dynamically if an attempt is made to load // about:home?jscache from the AboutHomeStartupCache as a top-level // load. matches: ["about:home*", "about:newtab*"], remoteTypes: ["privilegedabout"], }); this.wrappedJSObject = this; } /** * Waits for the AddonManager to be fully initialized, and for the built-in * addon to be ready. Once that's done, it tterates any suspended channels and * resumes them, now that the built-in addon has been set up. */ notifyBuiltInAddonInitialized() { this.#addonInitialized = true; for (let suspendedChannel of this.#suspendedChannels) { suspendedChannel.resume(); } this.#suspendedChannels = []; this.#addonInitializedResolver(); } /** * Returns a Promise that resolves when the newtab built-in addon has notified * that it has finished initializing. * * @type {Promise} */ get promiseBuiltInAddonInitialized() { return this.#addonInitializedPromise; } newChannel(uri, loadInfo) { let chromeURI = this.getChromeURI(uri); if ( uri.spec.startsWith("about:home") || (uri.spec.startsWith("about:newtab") && lazy.BUILTIN_NEWTAB_ENABLED) ) { chromeURI = Services.io.newURI(this.defaultURL); } let resultChannel = Services.io.newChannelFromURIWithLoadInfo( chromeURI, loadInfo ); resultChannel.originalURI = uri; if (!this.#addonInitialized) { return this.#getSuspendedChannel(resultChannel); } return resultChannel; } /** * Wraps an nsIChannel with an nsISuspendableChannelWrapper, suspends that * wrapper, and then stores the wrapper in #suspendedChannels so that it can * be resumed with a call to #notifyBuildInAddonInitialized. * * @param {nsIChannel} innerChannel * The channel to wrap and suspend. * @returns {nsISuspendableChannelWrapper} */ #getSuspendedChannel(innerChannel) { let suspendedChannel = Services.io.newSuspendableChannelWrapper(innerChannel); suspendedChannel.suspend(); this.#suspendedChannels.push(suspendedChannel); return suspendedChannel; } } /** * The child-process implementation of nsIAboutModule, which also does the work * of redirecting about:home loads to the about:home startup cache if its * available. */ export class AboutNewTabRedirectorChild extends BaseAboutNewTabRedirector { newChannel(uri, loadInfo) { if (!IS_PRIVILEGED_PROCESS) { throw Components.Exception( "newChannel can only be called from the privilegedabout content process.", Cr.NS_ERROR_UNEXPECTED ); } let pageURI; if (uri.spec.startsWith("about:home")) { let cacheChannel = AboutHomeStartupCacheChild.maybeGetCachedPageChannel( uri, loadInfo ); if (cacheChannel) { return cacheChannel; } pageURI = Services.io.newURI(this.defaultURL); } else { // The only other possibility is about:newtab. // // If about:newtab is being requested, then any subsequent request for // about:home should _never_ request the cache (which might be woefully // out of date compared to about:newtab), so we disqualify the cache if // it still happens to be around. AboutHomeStartupCacheChild.disqualifyCache(); if (lazy.BUILTIN_NEWTAB_ENABLED) { pageURI = Services.io.newURI(this.defaultURL); } else { pageURI = this.getChromeURI(uri); } } let resultChannel = Services.io.newChannelFromURIWithLoadInfo( pageURI, loadInfo ); resultChannel.originalURI = uri; return resultChannel; } } /** * The AboutNewTabRedirectorStub is a function called in both the main and * content processes when trying to get at the nsIAboutModule for about:newtab * and about:home. This function does the job of choosing the appropriate * implementation of nsIAboutModule for the process type. */ export function AboutNewTabRedirectorStub() { if (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT) { return new AboutNewTabRedirectorParent(); } return new AboutNewTabRedirectorChild(); }