AboutNewTabRedirector.sys.mjs (20239B)
1 /** 2 * This Source Code Form is subject to the terms of the Mozilla Public 3 * License, v. 2.0. If a copy of the MPL was not distributed with this 4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 */ 6 7 /** 8 * This nsIAboutModule is for about:home and about:newtab. The primary 9 * job of the AboutNewTabRedirector is to resolve requests to load about:home 10 * and about:newtab to the appropriate resources for those requests. 11 * 12 * The AboutNewTabRedirector is not involved when the user has overridden 13 * the default about:home or about:newtab pages. 14 * 15 * There are two implementations of this nsIAboutModule - one for the parent 16 * process, and one for content processes. Each one has some secondary 17 * responsibilties that are process-specific. 18 * 19 * The need for two implementations is an unfortunate consequence of how 20 * document loading and process redirection for about: pages currently 21 * works in Gecko. The commonalities between the two implementations has 22 * been put into an abstract base class. 23 */ 24 25 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 26 import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs"; 27 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 28 29 const lazy = {}; 30 31 ChromeUtils.defineESModuleGetters(lazy, { 32 BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs", 33 }); 34 35 XPCOMUtils.defineLazyPreferenceGetter( 36 lazy, 37 "BUILTIN_NEWTAB_ENABLED", 38 "browser.newtabpage.enabled", 39 true 40 ); 41 42 /** 43 * BEWARE: Do not add variables for holding state in the global scope. 44 * Any state variables should be properties of the appropriate class 45 * below. This is to avoid confusion where the state is set in one process, 46 * but not in another. 47 * 48 * Constants are fine in the global scope. 49 */ 50 51 const PREF_ABOUT_HOME_CACHE_TESTING = 52 "browser.startup.homepage.abouthome_cache.testing"; 53 54 const CACHE_WORKER_URL = "resource://newtab/lib/cache.worker.js"; 55 56 const IS_PRIVILEGED_PROCESS = 57 Services.appinfo.remoteType === E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE; 58 59 const PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS = 60 "browser.tabs.remote.separatePrivilegedContentProcess"; 61 const PREF_ACTIVITY_STREAM_DEBUG = "browser.newtabpage.activity-stream.debug"; 62 63 /** 64 * The AboutHomeStartupCacheChild is responsible for connecting the 65 * AboutNewTabRedirectorChild with a cached document and script for about:home 66 * if one happens to exist. The AboutHomeStartupCacheChild is only ever 67 * handed the streams for those caches when the "privileged about content 68 * process" first launches, so subsequent loads of about:home do not read 69 * from this cache. 70 * 71 * See https://firefox-source-docs.mozilla.org/browser/extensions/newtab/docs/v2-system-addon/about_home_startup_cache.html 72 * for further details. 73 */ 74 export const AboutHomeStartupCacheChild = { 75 _initted: false, 76 CACHE_REQUEST_MESSAGE: "AboutHomeStartupCache:CacheRequest", 77 CACHE_RESPONSE_MESSAGE: "AboutHomeStartupCache:CacheResponse", 78 CACHE_USAGE_RESULT_MESSAGE: "AboutHomeStartupCache:UsageResult", 79 STATES: { 80 UNAVAILABLE: 0, 81 UNCONSUMED: 1, 82 PAGE_CONSUMED: 2, 83 PAGE_AND_SCRIPT_CONSUMED: 3, 84 FAILED: 4, 85 DISQUALIFIED: 5, 86 }, 87 REQUEST_TYPE: { 88 PAGE: 0, 89 SCRIPT: 1, 90 }, 91 _state: 0, 92 _consumerBCID: null, 93 94 /** 95 * Called via a process script very early on in the process lifetime. This 96 * prepares the AboutHomeStartupCacheChild to pass an nsIChannel back to 97 * the AboutNewTabRedirectorChild when the initial about:home document is 98 * eventually requested. 99 * 100 * @param {nsIInputStream} pageInputStream 101 * The stream for the cached page markup. 102 * @param {nsIInputStream} scriptInputStream 103 * The stream for the cached script to run on the page. 104 */ 105 init(pageInputStream, scriptInputStream) { 106 if ( 107 !IS_PRIVILEGED_PROCESS && 108 !Services.prefs.getBoolPref(PREF_ABOUT_HOME_CACHE_TESTING, false) 109 ) { 110 throw new Error( 111 "Can only instantiate in the privileged about content processes." 112 ); 113 } 114 115 if ( 116 !Services.prefs.getBoolPref( 117 "browser.startup.homepage.abouthome_cache.enabled" 118 ) 119 ) { 120 return; 121 } 122 123 if (this._initted) { 124 throw new Error("AboutHomeStartupCacheChild already initted."); 125 } 126 127 Services.obs.addObserver(this, "memory-pressure"); 128 Services.cpmm.addMessageListener(this.CACHE_REQUEST_MESSAGE, this); 129 130 this._pageInputStream = pageInputStream; 131 this._scriptInputStream = scriptInputStream; 132 this._initted = true; 133 this.setState(this.STATES.UNCONSUMED); 134 }, 135 136 /** 137 * A function that lets us put the AboutHomeStartupCacheChild back into 138 * its initial state. This is used by tests to let us simulate the startup 139 * behaviour of the module without having to manually launch a new privileged 140 * about content process every time. 141 */ 142 uninit() { 143 if (!Services.prefs.getBoolPref(PREF_ABOUT_HOME_CACHE_TESTING, false)) { 144 throw new Error( 145 "Cannot uninit AboutHomeStartupCacheChild unless testing." 146 ); 147 } 148 149 if (!this._initted) { 150 return; 151 } 152 153 Services.obs.removeObserver(this, "memory-pressure"); 154 Services.cpmm.removeMessageListener(this.CACHE_REQUEST_MESSAGE, this); 155 156 if (this._cacheWorker) { 157 this._cacheWorker.terminate(); 158 this._cacheWorker = null; 159 } 160 161 this._pageInputStream = null; 162 this._scriptInputStream = null; 163 this._initted = false; 164 this._state = this.STATES.UNAVAILABLE; 165 this._consumerBCID = null; 166 }, 167 168 /** 169 * Attempts to return an nsIChannel for a cached about:home document that 170 * we were initialized with. If we failed to be initted with the cache, or the 171 * input streams that we were sent have no data yet available, this function 172 * returns null. The caller should fall back to generating the page 173 * dynamically. 174 * 175 * This function will be called when loading about:home, or 176 * about:home?jscache - the latter returns the cached script. 177 * 178 * It is expected that the same BrowsingContext that loads the cached 179 * page will also load the cached script. 180 * 181 * @param {nsIURI} uri 182 * The URI for the requested page, as passed by AboutNewTabRedirectorChild. 183 * @param {nsILoadInfo} loadInfo 184 * The nsILoadInfo for the requested load, as passed by 185 * AboutNewTabRedirectorChild. 186 * @returns {?nsIChannel} 187 */ 188 maybeGetCachedPageChannel(uri, loadInfo) { 189 if (!this._initted) { 190 return null; 191 } 192 193 if (this._state >= this.STATES.PAGE_AND_SCRIPT_CONSUMED) { 194 return null; 195 } 196 197 let requestType = 198 uri.query === "jscache" 199 ? this.REQUEST_TYPE.SCRIPT 200 : this.REQUEST_TYPE.PAGE; 201 202 // If this is a page request, then we need to be in the UNCONSUMED state, 203 // since we expect the page request to come first. If this is a script 204 // request, we expect to be in PAGE_CONSUMED state, since the page cache 205 // stream should he been consumed already. 206 if ( 207 (requestType === this.REQUEST_TYPE.PAGE && 208 this._state !== this.STATES.UNCONSUMED) || 209 (requestType === this.REQUEST_TYPE_SCRIPT && 210 this._state !== this.STATES.PAGE_CONSUMED) 211 ) { 212 return null; 213 } 214 215 // If by this point, we don't have anything in the streams, 216 // then either the cache was too slow to give us data, or the cache 217 // doesn't exist. The caller should fall back to generating the 218 // page dynamically. 219 // 220 // We only do this on the page request, because by the time 221 // we get to the script request, we should have already drained 222 // the page input stream. 223 if (requestType === this.REQUEST_TYPE.PAGE) { 224 try { 225 if ( 226 !this._scriptInputStream.available() || 227 !this._pageInputStream.available() 228 ) { 229 this.setState(this.STATES.FAILED); 230 this.reportUsageResult(false /* success */); 231 return null; 232 } 233 } catch (e) { 234 this.setState(this.STATES.FAILED); 235 if (e.result === Cr.NS_BASE_STREAM_CLOSED) { 236 this.reportUsageResult(false /* success */); 237 return null; 238 } 239 throw e; 240 } 241 } 242 243 if ( 244 requestType === this.REQUEST_TYPE.SCRIPT && 245 this._consumerBCID !== loadInfo.browsingContextID 246 ) { 247 // Some other document is somehow requesting the script - one 248 // that didn't originally request the page. This is not allowed. 249 this.setState(this.STATES.FAILED); 250 return null; 251 } 252 253 let channel = Cc[ 254 "@mozilla.org/network/input-stream-channel;1" 255 ].createInstance(Ci.nsIInputStreamChannel); 256 channel.QueryInterface(Ci.nsIChannel); 257 channel.setURI(uri); 258 channel.loadInfo = loadInfo; 259 channel.contentStream = 260 requestType === this.REQUEST_TYPE.PAGE 261 ? this._pageInputStream 262 : this._scriptInputStream; 263 264 if (requestType === this.REQUEST_TYPE.SCRIPT) { 265 this.setState(this.STATES.PAGE_AND_SCRIPT_CONSUMED); 266 this.reportUsageResult(true /* success */); 267 } else { 268 this.setState(this.STATES.PAGE_CONSUMED); 269 // Stash the BrowsingContext ID so that when the script stream 270 // attempts to be consumed, we ensure that it's from the same 271 // BrowsingContext that loaded the page. 272 this._consumerBCID = loadInfo.browsingContextID; 273 } 274 275 return channel; 276 }, 277 278 /** 279 * This function takes the state information required to generate 280 * the about:home cache markup and script, and then generates that 281 * markup in script asynchronously. Once that's done, a message 282 * is sent to the parent process with the nsIInputStream's for the 283 * markup and script contents. 284 * 285 * @param {object} state 286 * The Redux state of the about:home document to render. 287 * @returns {Promise<undefined>} 288 * Fulfills after the message with the nsIInputStream's have been sent to 289 * the parent. 290 */ 291 async constructAndSendCache(state) { 292 if (!IS_PRIVILEGED_PROCESS) { 293 throw new Error("Wrong process type."); 294 } 295 296 let worker = this.getOrCreateWorker(); 297 298 let timerId = Glean.newtab.abouthomeCacheConstruction.start(); 299 300 let { page, script } = await worker 301 .post("construct", [state]) 302 .finally(() => { 303 Glean.newtab.abouthomeCacheConstruction.stopAndAccumulate(timerId); 304 }); 305 306 let pageInputStream = Cc[ 307 "@mozilla.org/io/string-input-stream;1" 308 ].createInstance(Ci.nsIStringInputStream); 309 310 pageInputStream.setUTF8Data(page); 311 312 let scriptInputStream = Cc[ 313 "@mozilla.org/io/string-input-stream;1" 314 ].createInstance(Ci.nsIStringInputStream); 315 316 scriptInputStream.setUTF8Data(script); 317 318 Services.cpmm.sendAsyncMessage(this.CACHE_RESPONSE_MESSAGE, { 319 pageInputStream, 320 scriptInputStream, 321 }); 322 }, 323 324 _cacheWorker: null, 325 getOrCreateWorker() { 326 if (this._cacheWorker) { 327 return this._cacheWorker; 328 } 329 330 this._cacheWorker = new lazy.BasePromiseWorker(CACHE_WORKER_URL); 331 return this._cacheWorker; 332 }, 333 334 receiveMessage(message) { 335 if (message.name === this.CACHE_REQUEST_MESSAGE) { 336 let { state } = message.data; 337 this.constructAndSendCache(state); 338 } 339 }, 340 341 reportUsageResult(success) { 342 Services.cpmm.sendAsyncMessage(this.CACHE_USAGE_RESULT_MESSAGE, { 343 success, 344 }); 345 }, 346 347 observe(subject, topic) { 348 if (topic === "memory-pressure" && this._cacheWorker) { 349 this._cacheWorker.terminate(); 350 this._cacheWorker = null; 351 } 352 }, 353 354 /** 355 * Transitions the AboutHomeStartupCacheChild from one state 356 * to the next, where each state is defined in this.STATES. 357 * 358 * States can only be transitioned in increasing order, otherwise 359 * an error is logged. 360 */ 361 setState(state) { 362 if (state > this._state) { 363 this._state = state; 364 } else { 365 console.error( 366 "AboutHomeStartupCacheChild could not transition from state " + 367 `${this._state} to ${state}`, 368 new Error().stack 369 ); 370 } 371 }, 372 373 /** 374 * If the cache hasn't been used, transitions it into the DISQUALIFIED 375 * state so that it cannot be used. This should be called if it's been 376 * determined that about:newtab is going to be loaded, which doesn't 377 * use the cache. 378 */ 379 disqualifyCache() { 380 if (this._state === this.STATES.UNCONSUMED) { 381 this.setState(this.STATES.DISQUALIFIED); 382 this.reportUsageResult(false /* success */); 383 } 384 }, 385 }; 386 387 /** 388 * This is an abstract base class for the nsIAboutModule implementations for 389 * about:home and about:newtab that has some common methods and properties. 390 */ 391 class BaseAboutNewTabRedirector { 392 constructor() { 393 if (!AppConstants.RELEASE_OR_BETA) { 394 XPCOMUtils.defineLazyPreferenceGetter( 395 this, 396 "activityStreamDebug", 397 PREF_ACTIVITY_STREAM_DEBUG, 398 false 399 ); 400 } else { 401 this.activityStreamDebug = false; 402 } 403 404 XPCOMUtils.defineLazyPreferenceGetter( 405 this, 406 "privilegedAboutProcessEnabled", 407 PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS, 408 false 409 ); 410 } 411 412 /** 413 * @returns {string} the default URL 414 * 415 * This URL depends on various activity stream prefs. Overriding 416 * the newtab page has no effect on the result of this function. 417 */ 418 get defaultURL() { 419 // Generate the desired activity stream resource depending on state, e.g., 420 // "resource://newtab/prerendered/activity-stream.html" 421 // "resource://newtab/prerendered/activity-stream-debug.html" 422 // "resource://newtab/prerendered/activity-stream-noscripts.html" 423 return [ 424 "resource://newtab/prerendered/", 425 "activity-stream", 426 // Debug version loads dev scripts but noscripts separately loads scripts 427 this.activityStreamDebug && !this.privilegedAboutProcessEnabled 428 ? "-debug" 429 : "", 430 this.privilegedAboutProcessEnabled ? "-noscripts" : "", 431 ".html", 432 ].join(""); 433 } 434 435 newChannel() { 436 throw Components.Exception( 437 "getChannel not implemented for this process.", 438 Cr.NS_ERROR_NOT_IMPLEMENTED 439 ); 440 } 441 442 getURIFlags() { 443 return ( 444 Ci.nsIAboutModule.ALLOW_SCRIPT | 445 Ci.nsIAboutModule.ENABLE_INDEXED_DB | 446 Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD | 447 Ci.nsIAboutModule.URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | 448 Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT 449 ); 450 } 451 452 getChromeURI() { 453 return Services.io.newURI("chrome://browser/content/blanktab.html"); 454 } 455 456 QueryInterface = ChromeUtils.generateQI(["nsIAboutModule"]); 457 } 458 459 /** 460 * The parent-process implementation of the nsIAboutModule, which is the first 461 * stop for when requests are made to visit about:home or about:newtab (so 462 * before the AboutNewTabRedirectorChild has a chance to handle the request). 463 */ 464 export class AboutNewTabRedirectorParent extends BaseAboutNewTabRedirector { 465 #addonInitialized = false; 466 #suspendedChannels = []; 467 #addonInitializedPromise = null; 468 #addonInitializedResolver = null; 469 470 constructor() { 471 super(); 472 473 let { promise, resolve } = Promise.withResolvers(); 474 this.#addonInitializedPromise = promise; 475 this.#addonInitializedResolver = resolve; 476 477 ChromeUtils.registerWindowActor("AboutNewTab", { 478 parent: { 479 esModuleURI: "resource:///actors/AboutNewTabParent.sys.mjs", 480 }, 481 child: { 482 esModuleURI: "resource:///actors/AboutNewTabChild.sys.mjs", 483 events: { 484 DOMDocElementInserted: {}, 485 DOMContentLoaded: { capture: true }, 486 load: { capture: true }, 487 unload: { capture: true }, 488 pageshow: {}, 489 visibilitychange: {}, 490 }, 491 }, 492 // The wildcard on about:newtab is for the # parameter 493 // that is used for the newtab devtools. The wildcard for about:home 494 // is similar, and also allows for falling back to loading the 495 // about:home document dynamically if an attempt is made to load 496 // about:home?jscache from the AboutHomeStartupCache as a top-level 497 // load. 498 matches: ["about:home*", "about:newtab*"], 499 remoteTypes: ["privilegedabout"], 500 }); 501 this.wrappedJSObject = this; 502 } 503 504 /** 505 * Waits for the AddonManager to be fully initialized, and for the built-in 506 * addon to be ready. Once that's done, it tterates any suspended channels and 507 * resumes them, now that the built-in addon has been set up. 508 */ 509 notifyBuiltInAddonInitialized() { 510 this.#addonInitialized = true; 511 512 for (let suspendedChannel of this.#suspendedChannels) { 513 suspendedChannel.resume(); 514 } 515 this.#suspendedChannels = []; 516 this.#addonInitializedResolver(); 517 } 518 519 /** 520 * Returns a Promise that reoslves when the newtab built-in addon has notified 521 * that it has finished initializing. If this is somehow checked when 522 * BROWSER_NEWTAB_AS_ADDON is not true, then this always resolves. 523 * 524 * @type {Promise<undefined>} 525 */ 526 get promiseBuiltInAddonInitialized() { 527 if (!AppConstants.BROWSER_NEWTAB_AS_ADDON) { 528 return Promise.resolve(); 529 } 530 531 return this.#addonInitializedPromise; 532 } 533 534 newChannel(uri, loadInfo) { 535 let chromeURI = this.getChromeURI(uri); 536 537 if ( 538 uri.spec.startsWith("about:home") || 539 (uri.spec.startsWith("about:newtab") && lazy.BUILTIN_NEWTAB_ENABLED) 540 ) { 541 chromeURI = Services.io.newURI(this.defaultURL); 542 } 543 544 let resultChannel = Services.io.newChannelFromURIWithLoadInfo( 545 chromeURI, 546 loadInfo 547 ); 548 resultChannel.originalURI = uri; 549 550 if (AppConstants.BROWSER_NEWTAB_AS_ADDON && !this.#addonInitialized) { 551 return this.#getSuspendedChannel(resultChannel); 552 } 553 554 return resultChannel; 555 } 556 557 /** 558 * Wraps an nsIChannel with an nsISuspendableChannelWrapper, suspends that 559 * wrapper, and then stores the wrapper in #suspendedChannels so that it can 560 * be resumed with a call to #notifyBuildInAddonInitialized. 561 * 562 * @param {nsIChannel} innerChannel 563 * The channel to wrap and suspend. 564 * @returns {nsISuspendableChannelWrapper} 565 */ 566 #getSuspendedChannel(innerChannel) { 567 let suspendedChannel = 568 Services.io.newSuspendableChannelWrapper(innerChannel); 569 suspendedChannel.suspend(); 570 571 this.#suspendedChannels.push(suspendedChannel); 572 return suspendedChannel; 573 } 574 } 575 576 /** 577 * The child-process implementation of nsIAboutModule, which also does the work 578 * of redirecting about:home loads to the about:home startup cache if its 579 * available. 580 */ 581 export class AboutNewTabRedirectorChild extends BaseAboutNewTabRedirector { 582 newChannel(uri, loadInfo) { 583 if (!IS_PRIVILEGED_PROCESS) { 584 throw Components.Exception( 585 "newChannel can only be called from the privilegedabout content process.", 586 Cr.NS_ERROR_UNEXPECTED 587 ); 588 } 589 590 let pageURI; 591 592 if (uri.spec.startsWith("about:home")) { 593 let cacheChannel = AboutHomeStartupCacheChild.maybeGetCachedPageChannel( 594 uri, 595 loadInfo 596 ); 597 if (cacheChannel) { 598 return cacheChannel; 599 } 600 pageURI = Services.io.newURI(this.defaultURL); 601 } else { 602 // The only other possibility is about:newtab. 603 // 604 // If about:newtab is being requested, then any subsequent request for 605 // about:home should _never_ request the cache (which might be woefully 606 // out of date compared to about:newtab), so we disqualify the cache if 607 // it still happens to be around. 608 AboutHomeStartupCacheChild.disqualifyCache(); 609 610 if (lazy.BUILTIN_NEWTAB_ENABLED) { 611 pageURI = Services.io.newURI(this.defaultURL); 612 } else { 613 pageURI = this.getChromeURI(uri); 614 } 615 } 616 617 let resultChannel = Services.io.newChannelFromURIWithLoadInfo( 618 pageURI, 619 loadInfo 620 ); 621 resultChannel.originalURI = uri; 622 return resultChannel; 623 } 624 } 625 626 /** 627 * The AboutNewTabRedirectorStub is a function called in both the main and 628 * content processes when trying to get at the nsIAboutModule for about:newtab 629 * and about:home. This function does the job of choosing the appropriate 630 * implementation of nsIAboutModule for the process type. 631 */ 632 export function AboutNewTabRedirectorStub() { 633 if (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT) { 634 return new AboutNewTabRedirectorParent(); 635 } 636 return new AboutNewTabRedirectorChild(); 637 }