SessionStateAggregator.js (19521B)
1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ 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 "use strict"; 7 8 const { GeckoViewChildModule } = ChromeUtils.importESModule( 9 "resource://gre/modules/GeckoViewChildModule.sys.mjs" 10 ); 11 const { XPCOMUtils } = ChromeUtils.importESModule( 12 "resource://gre/modules/XPCOMUtils.sys.mjs" 13 ); 14 15 ChromeUtils.defineESModuleGetters(this, { 16 SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", 17 clearTimeout: "resource://gre/modules/Timer.sys.mjs", 18 setTimeoutWithTarget: "resource://gre/modules/Timer.sys.mjs", 19 }); 20 21 const NO_INDEX = Number.MAX_SAFE_INTEGER; 22 const LAST_INDEX = Number.MAX_SAFE_INTEGER - 1; 23 const DEFAULT_INTERVAL_MS = 1500; 24 25 // This pref controls whether or not we send updates to the parent on a timeout 26 // or not, and should only be used for tests or debugging. 27 const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates"; 28 29 const PREF_INTERVAL = "browser.sessionstore.interval"; 30 31 class Handler { 32 constructor(store) { 33 this.store = store; 34 } 35 36 get mm() { 37 return this.store.mm; 38 } 39 40 get eventDispatcher() { 41 return this.store.eventDispatcher; 42 } 43 44 get messageQueue() { 45 return this.store.messageQueue; 46 } 47 48 get stateChangeNotifier() { 49 return this.store.stateChangeNotifier; 50 } 51 } 52 53 /** 54 * Listens for state change notifcations from webProgress and notifies each 55 * registered observer for either the start of a page load, or its completion. 56 */ 57 class StateChangeNotifier extends Handler { 58 constructor(store) { 59 super(store); 60 61 this._observers = new Set(); 62 const ifreq = this.mm.docShell.QueryInterface(Ci.nsIInterfaceRequestor); 63 const webProgress = ifreq.getInterface(Ci.nsIWebProgress); 64 webProgress.addProgressListener( 65 this, 66 Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT 67 ); 68 } 69 70 /** 71 * Adds a given observer |obs| to the set of observers that will be notified 72 * when when a new document starts or finishes loading. 73 * 74 * @param obs (object) 75 */ 76 addObserver(obs) { 77 this._observers.add(obs); 78 } 79 80 /** 81 * Notifies all observers that implement the given |method|. 82 * 83 * @param method (string) 84 */ 85 notifyObservers(method) { 86 for (const obs of this._observers) { 87 if (typeof obs[method] == "function") { 88 obs[method](); 89 } 90 } 91 } 92 93 /** 94 * @see nsIWebProgressListener.onStateChange 95 */ 96 onStateChange(webProgress, request, stateFlags) { 97 // Ignore state changes for subframes because we're only interested in the 98 // top-document starting or stopping its load. 99 if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) { 100 return; 101 } 102 103 // onStateChange will be fired when loading the initial about:blank URI for 104 // a browser, which we don't actually care about. This is particularly for 105 // the case of unrestored background tabs, where the content has not yet 106 // been restored: we don't want to accidentally send any updates to the 107 // parent when the about:blank placeholder page has loaded. 108 if (!this.mm.docShell.hasLoadedNonBlankURI) { 109 return; 110 } 111 112 if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { 113 this.notifyObservers("onPageLoadStarted"); 114 } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { 115 this.notifyObservers("onPageLoadCompleted"); 116 } 117 } 118 } 119 StateChangeNotifier.prototype.QueryInterface = ChromeUtils.generateQI([ 120 "nsIWebProgressListener", 121 "nsISupportsWeakReference", 122 ]); 123 124 /** 125 * Listens for changes to the session history. Whenever the user navigates 126 * we will collect URLs and everything belonging to session history. 127 * 128 * Causes a SessionStore:update message to be sent that contains the current 129 * session history. 130 * 131 * Example: 132 * {entries: [{url: "about:mozilla", ...}, ...], index: 1} 133 */ 134 class SessionHistoryListener extends Handler { 135 constructor(store) { 136 super(store); 137 138 this._fromIdx = NO_INDEX; 139 140 // The state change observer is needed to handle initial subframe loads. 141 // It will redundantly invalidate with the SHistoryListener in some cases 142 // but these invalidations are very cheap. 143 this.stateChangeNotifier.addObserver(this); 144 145 // By adding the SHistoryListener immediately, we will unfortunately be 146 // notified of every history entry as the tab is restored. We don't bother 147 // waiting to add the listener later because these notifications are cheap. 148 // We will likely only collect once since we are batching collection on 149 // a delay. 150 this.mm.docShell 151 .QueryInterface(Ci.nsIWebNavigation) 152 .sessionHistory.legacySHistory.addSHistoryListener(this); 153 154 // Listen for page title changes. 155 this.mm.addEventListener("DOMTitleChanged", this); 156 } 157 158 uninit() { 159 const sessionHistory = this.mm.docShell.QueryInterface( 160 Ci.nsIWebNavigation 161 ).sessionHistory; 162 if (sessionHistory) { 163 sessionHistory.legacySHistory.removeSHistoryListener(this); 164 } 165 } 166 167 collect() { 168 // We want to send down a historychange even for full collects in case our 169 // session history is a partial session history, in which case we don't have 170 // enough information for a full update. collectFrom(-1) tells the collect 171 // function to collect all data avaliable in this process. 172 if (this.mm.docShell) { 173 this.collectFrom(-1); 174 } 175 } 176 177 // History can grow relatively big with the nested elements, so if we don't have to, we 178 // don't want to send the entire history all the time. For a simple optimization 179 // we keep track of the smallest index from after any change has occured and we just send 180 // the elements from that index. If something more complicated happens we just clear it 181 // and send the entire history. We always send the additional info like the current selected 182 // index (so for going back and forth between history entries we set the index to LAST_INDEX 183 // if nothing else changed send an empty array and the additonal info like the selected index) 184 collectFrom(idx) { 185 if (this._fromIdx <= idx) { 186 // If we already know that we need to update history fromn index N we can ignore any changes 187 // tha happened with an element with index larger than N. 188 // Note: initially we use NO_INDEX which is MAX_SAFE_INTEGER which means we don't ignore anything 189 // here, and in case of navigation in the history back and forth we use LAST_INDEX which ignores 190 // only the subsequent navigations, but not any new elements added. 191 return; 192 } 193 194 this._fromIdx = idx; 195 this.messageQueue.push("historychange", () => { 196 if (this._fromIdx === NO_INDEX) { 197 return null; 198 } 199 200 const history = SessionHistory.collect(this.mm.docShell, this._fromIdx); 201 this._fromIdx = NO_INDEX; 202 return history; 203 }); 204 } 205 206 handleEvent() { 207 this.collect(); 208 } 209 210 onPageLoadCompleted() { 211 this.collect(); 212 } 213 214 onPageLoadStarted() { 215 this.collect(); 216 } 217 218 OnHistoryNewEntry() { 219 // We ought to collect the previously current entry as well, see bug 1350567. 220 // TODO: Reenable partial history collection for performance 221 // this.collectFrom(oldIndex); 222 this.collect(); 223 } 224 225 OnHistoryGotoIndex() { 226 // We ought to collect the previously current entry as well, see bug 1350567. 227 // TODO: Reenable partial history collection for performance 228 // this.collectFrom(LAST_INDEX); 229 this.collect(); 230 } 231 232 OnHistoryPurge() { 233 this.collect(); 234 } 235 236 OnHistoryReload() { 237 this.collect(); 238 return true; 239 } 240 241 OnHistoryReplaceEntry() { 242 this.collect(); 243 } 244 } 245 SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([ 246 "nsISHistoryListener", 247 "nsISupportsWeakReference", 248 ]); 249 250 /** 251 * Listens for scroll position changes. Whenever the user scrolls the top-most 252 * frame we update the scroll position and will restore it when requested. 253 * 254 * Causes a SessionStore:update message to be sent that contains the current 255 * scroll positions as a tree of strings. If no frame of the whole frame tree 256 * is scrolled this will return null so that we don't tack a property onto 257 * the tabData object in the parent process. 258 * 259 * Example: 260 * {scroll: "100,100", zoom: {resolution: "1.5", displaySize: 261 * {height: "1600", width: "1000"}}, children: 262 * [null, null, {scroll: "200,200"}]} 263 */ 264 class ScrollPositionListener extends Handler { 265 constructor(store) { 266 super(store); 267 268 SessionStoreUtils.addDynamicFrameFilteredListener( 269 this.mm, 270 "mozvisualscroll", 271 this, 272 /* capture */ false, 273 /* system group */ true 274 ); 275 276 SessionStoreUtils.addDynamicFrameFilteredListener( 277 this.mm, 278 "mozvisualresize", 279 this, 280 /* capture */ false, 281 /* system group */ true 282 ); 283 284 this.stateChangeNotifier.addObserver(this); 285 } 286 287 handleEvent() { 288 this.messageQueue.push("scroll", () => this.collect()); 289 } 290 291 onPageLoadCompleted() { 292 this.messageQueue.push("scroll", () => this.collect()); 293 } 294 295 onPageLoadStarted() { 296 this.messageQueue.push("scroll", () => null); 297 } 298 299 collect() { 300 // TODO: Keep an eye on bug 1525259; we may not have to manually store zoom 301 // Save the current document resolution. 302 let zoom = 1; 303 const scrolldata = 304 SessionStoreUtils.collectScrollPosition(this.mm.content) || {}; 305 const domWindowUtils = this.mm.content.windowUtils; 306 zoom = domWindowUtils.getResolution(); 307 scrolldata.zoom = {}; 308 scrolldata.zoom.resolution = zoom; 309 310 // Save some data that'll help in adjusting the zoom level 311 // when restoring in a different screen orientation. 312 const displaySize = {}; 313 const width = {}, 314 height = {}; 315 domWindowUtils.getDocumentViewerSize(width, height); 316 317 displaySize.width = width.value; 318 displaySize.height = height.value; 319 320 scrolldata.zoom.displaySize = displaySize; 321 322 return scrolldata; 323 } 324 } 325 326 /** 327 * Listens for changes to input elements. Whenever the value of an input 328 * element changes we will re-collect data for the current frame tree and send 329 * a message to the parent process. 330 * 331 * Causes a SessionStore:update message to be sent that contains the form data 332 * for all reachable frames. 333 * 334 * Example: 335 * { 336 * formdata: {url: "http://mozilla.org/", id: {input_id: "input value"}}, 337 * children: [ 338 * null, 339 * {url: "http://sub.mozilla.org/", id: {input_id: "input value 2"}} 340 * ] 341 * } 342 */ 343 class FormDataListener extends Handler { 344 constructor(store) { 345 super(store); 346 347 SessionStoreUtils.addDynamicFrameFilteredListener( 348 this.mm, 349 "input", 350 this, 351 true 352 ); 353 this.stateChangeNotifier.addObserver(this); 354 } 355 356 handleEvent() { 357 this.messageQueue.push("formdata", () => this.collect()); 358 } 359 360 onPageLoadStarted() { 361 this.messageQueue.push("formdata", () => null); 362 } 363 364 collect() { 365 return SessionStoreUtils.collectFormData(this.mm.content); 366 } 367 } 368 369 /** 370 * A message queue that takes collected data and will take care of sending it 371 * to the chrome process. It allows flushing using synchronous messages and 372 * takes care of any race conditions that might occur because of that. Changes 373 * will be batched if they're pushed in quick succession to avoid a message 374 * flood. 375 */ 376 class MessageQueue extends Handler { 377 constructor(store) { 378 super(store); 379 380 /** 381 * A map (string -> lazy fn) holding lazy closures of all queued data 382 * collection routines. These functions will return data collected from the 383 * docShell. 384 */ 385 this._data = new Map(); 386 387 /** 388 * The delay (in ms) used to delay sending changes after data has been 389 * invalidated. 390 */ 391 this.BATCH_DELAY_MS = 1000; 392 393 /** 394 * The minimum idle period (in ms) we need for sending data to chrome process. 395 */ 396 this.NEEDED_IDLE_PERIOD_MS = 5; 397 398 /** 399 * Timeout for waiting an idle period to send data. We will set this from 400 * the pref "browser.sessionstore.interval". 401 */ 402 this._timeoutWaitIdlePeriodMs = null; 403 404 /** 405 * The current timeout ID, null if there is no queue data. We use timeouts 406 * to damp a flood of data changes and send lots of changes as one batch. 407 */ 408 this._timeout = null; 409 410 /** 411 * Whether or not sending batched messages on a timer is disabled. This should 412 * only be used for debugging or testing. If you need to access this value, 413 * you should probably use the timeoutDisabled getter. 414 */ 415 this._timeoutDisabled = false; 416 417 /** 418 * True if there is already a send pending idle dispatch, set to prevent 419 * scheduling more than one. If false there may or may not be one scheduled. 420 */ 421 this._idleScheduled = false; 422 423 this.timeoutDisabled = Services.prefs.getBoolPref( 424 TIMEOUT_DISABLED_PREF, 425 false 426 ); 427 this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref( 428 PREF_INTERVAL, 429 DEFAULT_INTERVAL_MS 430 ); 431 432 Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this); 433 Services.prefs.addObserver(PREF_INTERVAL, this); 434 } 435 436 /** 437 * True if batched messages are not being fired on a timer. This should only 438 * ever be true when debugging or during tests. 439 */ 440 get timeoutDisabled() { 441 return this._timeoutDisabled; 442 } 443 444 /** 445 * Disables sending batched messages on a timer. Also cancels any pending 446 * timers. 447 */ 448 set timeoutDisabled(val) { 449 this._timeoutDisabled = val; 450 451 if (val && this._timeout) { 452 clearTimeout(this._timeout); 453 this._timeout = null; 454 } 455 } 456 457 uninit() { 458 this.cleanupTimers(); 459 } 460 461 /** 462 * Cleanup pending idle callback and timer. 463 */ 464 cleanupTimers() { 465 this._idleScheduled = false; 466 if (this._timeout) { 467 clearTimeout(this._timeout); 468 this._timeout = null; 469 } 470 } 471 472 observe(subject, topic, data) { 473 if (topic == "nsPref:changed") { 474 switch (data) { 475 case TIMEOUT_DISABLED_PREF: 476 this.timeoutDisabled = Services.prefs.getBoolPref( 477 TIMEOUT_DISABLED_PREF, 478 false 479 ); 480 break; 481 case PREF_INTERVAL: 482 this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref( 483 PREF_INTERVAL, 484 DEFAULT_INTERVAL_MS 485 ); 486 break; 487 default: 488 debug`Received unknown message: ${data}`; 489 break; 490 } 491 } 492 } 493 494 /** 495 * Pushes a given |value| onto the queue. The given |key| represents the type 496 * of data that is stored and can override data that has been queued before 497 * but has not been sent to the parent process, yet. 498 * 499 * @param key (string) 500 * A unique identifier specific to the type of data this is passed. 501 * @param fn (function) 502 * A function that returns the value that will be sent to the parent 503 * process. 504 */ 505 push(key, fn) { 506 this._data.set(key, fn); 507 508 if (!this._timeout && !this._timeoutDisabled) { 509 // Wait a little before sending the message to batch multiple changes. 510 this._timeout = setTimeoutWithTarget( 511 () => this.sendWhenIdle(), 512 this.BATCH_DELAY_MS, 513 this.mm.tabEventTarget 514 ); 515 } 516 } 517 518 /** 519 * Sends queued data when the remaining idle time is enough or waiting too 520 * long; otherwise, request an idle time again. If the |deadline| is not 521 * given, this function is going to schedule the first request. 522 * 523 * @param deadline (object) 524 * An IdleDeadline object passed by idleDispatch(). 525 */ 526 sendWhenIdle(deadline) { 527 if (!this.mm.content) { 528 // The frameloader is being torn down. Nothing more to do. 529 return; 530 } 531 532 if (deadline) { 533 if ( 534 deadline.didTimeout || 535 deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS 536 ) { 537 this.send(); 538 return; 539 } 540 } else if (this._idleScheduled) { 541 // Bail out if there's a pending run. 542 return; 543 } 544 ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), { 545 timeout: this._timeoutWaitIdlePeriodMs, 546 }); 547 this._idleScheduled = true; 548 } 549 550 /** 551 * Sends queued data to the chrome process. 552 * 553 * @param options (object) 554 * {isFinal: true} to signal this is the final message sent on unload 555 */ 556 send(options = {}) { 557 // Looks like we have been called off a timeout after the tab has been 558 // closed. The docShell is gone now and we can just return here as there 559 // is nothing to do. 560 if (!this.mm.docShell) { 561 return; 562 } 563 564 this.cleanupTimers(); 565 566 const data = {}; 567 for (const [key, func] of this._data) { 568 const value = func(); 569 570 if (value || (key != "storagechange" && key != "historychange")) { 571 data[key] = value; 572 } 573 } 574 575 this._data.clear(); 576 577 try { 578 // Send all data to the parent process. 579 this.eventDispatcher.sendRequest({ 580 type: "GeckoView:StateUpdated", 581 data, 582 isFinal: options.isFinal || false, 583 epoch: this.store.epoch, 584 }); 585 } catch (ex) { 586 if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) { 587 warn`Failed to save session state`; 588 } 589 } 590 } 591 } 592 593 class SessionStateAggregator extends GeckoViewChildModule { 594 constructor(aModuleName, aMessageManager) { 595 super(aModuleName, aMessageManager); 596 597 this.mm = aMessageManager; 598 this.messageQueue = new MessageQueue(this); 599 this.stateChangeNotifier = new StateChangeNotifier(this); 600 601 this.handlers = [ 602 new SessionHistoryListener(this), 603 this.stateChangeNotifier, 604 this.messageQueue, 605 ]; 606 607 if (!Services.appinfo.sessionStorePlatformCollection) { 608 this.handlers.push( 609 new FormDataListener(this), 610 new ScrollPositionListener(this) 611 ); 612 } 613 614 this.messageManager.addMessageListener("GeckoView:FlushSessionState", this); 615 } 616 617 receiveMessage(aMsg) { 618 debug`receiveMessage: ${aMsg.name}`; 619 620 switch (aMsg.name) { 621 case "GeckoView:FlushSessionState": 622 this.flush(); 623 break; 624 } 625 } 626 627 flush() { 628 // Flush the message queue, send the latest updates. 629 this.messageQueue.send(); 630 } 631 632 onUnload() { 633 // Upon frameLoader destruction, send a final update message to 634 // the parent and flush all data currently held in the child. 635 this.messageQueue.send({ isFinal: true }); 636 637 for (const handler of this.handlers) { 638 if (handler.uninit) { 639 handler.uninit(); 640 } 641 } 642 643 // We don't need to take care of any StateChangeNotifier observers as they 644 // will die with the content script. 645 } 646 } 647 648 // TODO: Bug 1648158 Move SessionAggregator to the parent process 649 class DummySessionStateAggregator extends GeckoViewChildModule { 650 constructor(aModuleName, aMessageManager) { 651 super(aModuleName, aMessageManager); 652 this.messageManager.addMessageListener("GeckoView:FlushSessionState", this); 653 } 654 655 receiveMessage(aMsg) { 656 debug`receiveMessage: ${aMsg.name}`; 657 658 switch (aMsg.name) { 659 case "GeckoView:FlushSessionState": 660 // Do nothing 661 break; 662 } 663 } 664 } 665 666 const { debug, warn } = SessionStateAggregator.initLogging( 667 "SessionStateAggregator" 668 ); 669 670 const module = Services.appinfo.sessionHistoryInParent 671 ? // If history is handled in the parent we don't need a session aggregator 672 // TODO: Bug 1648158 remove this and do everything in the parent 673 DummySessionStateAggregator.create(this) 674 : SessionStateAggregator.create(this);