/* 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"; function debug(msg) { Services.console.logStringMessage("SessionStoreContent: " + msg); } let Cu = Components.utils; let Cc = Components.classes; let Ci = Components.interfaces; let Cr = Components.results; Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); Cu.import("resource://gre/modules/Timer.jsm", this); XPCOMUtils.defineLazyModuleGetter(this, "DocShellCapabilities", "resource:///modules/sessionstore/DocShellCapabilities.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PageStyle", "resource:///modules/sessionstore/PageStyle.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ScrollPosition", "resource:///modules/sessionstore/ScrollPosition.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "SessionHistory", "resource:///modules/sessionstore/SessionHistory.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "SessionStorage", "resource:///modules/sessionstore/SessionStorage.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "TextAndScrollData", "resource:///modules/sessionstore/TextAndScrollData.jsm"); Cu.import("resource:///modules/sessionstore/FrameTree.jsm", this); let gFrameTree = new FrameTree(this); Cu.import("resource:///modules/sessionstore/ContentRestore.jsm", this); XPCOMUtils.defineLazyGetter(this, 'gContentRestore', () => { return new ContentRestore(this) }); /** * Returns a lazy function that will evaluate the given * function |fn| only once and cache its return value. */ function createLazy(fn) { let cached = false; let cachedValue = null; return function lazy() { if (!cached) { cachedValue = fn(); cached = true; } return cachedValue; }; } /** * Determines whether the given storage event was triggered by changes * to the sessionStorage object and not the local or globalStorage. */ function isSessionStorageEvent(event) { try { return event.storageArea == content.sessionStorage; } catch (ex if ex instanceof Ci.nsIException && ex.result == Cr.NS_ERROR_NOT_AVAILABLE) { // This page does not have a DOMSessionStorage // (this is typically the case for about: pages) return false; } } /** * Listens for and handles content events that we need for the * session store service to be notified of state changes in content. */ let EventListener = { DOM_EVENTS: [ "load", "pageshow", "change", "input" ], init: function () { this.DOM_EVENTS.forEach(e => addEventListener(e, this, true)); }, handleEvent: function (event) { switch (event.type) { case "load": // Ignore load events from subframes. if (event.target == content.document) { // If we're in the process of restoring, this load may signal // the end of the restoration. let epoch = gContentRestore.getRestoreEpoch(); if (epoch) { // Restore the form data and scroll position. gContentRestore.restoreDocument(); // Ask SessionStore.jsm to trigger SSTabRestored. sendAsyncMessage("SessionStore:restoreDocumentComplete", {epoch: epoch}); } // Send a load message for all loads so we can invalidate the TabStateCache. sendAsyncMessage("SessionStore:load"); } break; case "pageshow": if (event.persisted && event.target == content.document) sendAsyncMessage("SessionStore:pageshow"); break; case "input": case "change": sendAsyncMessage("SessionStore:input"); break; default: debug("received unknown event '" + event.type + "'"); break; } } }; /** * Listens for and handles messages sent by the session store service. */ let MessageListener = { MESSAGES: [ "SessionStore:collectSessionHistory", "SessionStore:restoreHistory", "SessionStore:restoreTabContent", "SessionStore:resetRestore", ], init: function () { this.MESSAGES.forEach(m => addMessageListener(m, this)); }, receiveMessage: function ({name, data}) { let id = data ? data.id : 0; switch (name) { case "SessionStore:collectSessionHistory": let history = SessionHistory.collect(docShell); if ("index" in history) { let tabIndex = history.index - 1; // Don't include private data. It's only needed when duplicating // tabs, which collects data synchronously. TextAndScrollData.updateFrame(history.entries[tabIndex], content, docShell.isAppTab); } sendAsyncMessage(name, {id: id, data: history}); break; case "SessionStore:restoreHistory": let reloadCallback = () => { // Inform SessionStore.jsm about the reload. It will send // restoreTabContent in response. sendAsyncMessage("SessionStore:reloadPendingTab", {epoch: data.epoch}); }; gContentRestore.restoreHistory(data.epoch, data.tabData, reloadCallback); // When restoreHistory finishes, we send a synchronous message to // SessionStore.jsm so that it can run SSTabRestoring. Users of // SSTabRestoring seem to get confused if chrome and content are out of // sync about the state of the restore (particularly regarding // docShell.currentURI). Using a synchronous message is the easiest way // to temporarily synchronize them. sendSyncMessage("SessionStore:restoreHistoryComplete", {epoch: data.epoch}); break; case "SessionStore:restoreTabContent": let epoch = gContentRestore.getRestoreEpoch(); let finishCallback = () => { // Tell SessionStore.jsm that it may want to restore some more tabs, // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time. sendAsyncMessage("SessionStore:restoreTabContentComplete", {epoch: epoch}); }; // We need to pass the value of didStartLoad back to SessionStore.jsm. let didStartLoad = gContentRestore.restoreTabContent(finishCallback); sendAsyncMessage("SessionStore:restoreTabContentStarted", {epoch: epoch}); if (!didStartLoad) { // Pretend that the load succeeded so that event handlers fire correctly. sendAsyncMessage("SessionStore:restoreTabContentComplete", {epoch: epoch}); sendAsyncMessage("SessionStore:restoreDocumentComplete", {epoch: epoch}); } break; case "SessionStore:resetRestore": gContentRestore.resetRestore(); break; default: debug("received unknown message '" + name + "'"); break; } } }; /** * If session data must be collected synchronously, we do it via * method calls to this object (rather than via messages to * MessageListener). When using multiple processes, these methods run * in the content process, but the parent synchronously waits on them * using cross-process object wrappers. Without multiple processes, we * still use this code for synchronous collection. */ let SyncHandler = { init: function () { // Send this object as a CPOW to chrome. In single-process mode, // the synchronous send ensures that the handler object is // available in SessionStore.jsm immediately upon loading // content-sessionStore.js. sendSyncMessage("SessionStore:setupSyncHandler", {}, {handler: this}); }, collectSessionHistory: function (includePrivateData) { let history = SessionHistory.collect(docShell); if ("index" in history) { let tabIndex = history.index - 1; TextAndScrollData.updateFrame(history.entries[tabIndex], content, docShell.isAppTab, {includePrivateData: includePrivateData}); } return history; }, /** * This function is used to make the tab process flush all data that * hasn't been sent to the parent process, yet. * * @param id (int) * A unique id that represents the last message received by the chrome * process before flushing. We will use this to determine data that * would be lost when data has been sent asynchronously shortly * before flushing synchronously. */ flush: function (id) { MessageQueue.flush(id); }, /** * DO NOT USE - DEBUGGING / TESTING ONLY * * This function is used to simulate certain situations where race conditions * can occur by sending data shortly before flushing synchronously. */ flushAsync: function () { MessageQueue.flushAsync(); } }; let ProgressListener = { init: function() { let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebProgress); webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); }, onLocationChange: function(aWebProgress, aRequest, aLocation, aFlags) { // We are changing page, so time to invalidate the state of the tab sendAsyncMessage("SessionStore:loadStart"); }, onStateChange: function(aWebProgress, aRequest, aStateFlags, aStatus) {}, onProgressChange: function() {}, onStatusChange: function() {}, onSecurityChange: function() {}, QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener, Ci.nsISupportsWeakReference]) }; /** * Listens for scroll position changes. Whenever the user scrolls the top-most * frame we update the scroll position and will restore it when requested. * * Causes a SessionStore:update message to be sent that contains the current * scroll positions as a tree of strings. If no frame of the whole frame tree * is scrolled this will return null so that we don't tack a property onto * the tabData object in the parent process. * * Example: * {scroll: "100,100", children: [null, null, {scroll: "200,200"}]} */ let ScrollPositionListener = { init: function () { addEventListener("scroll", this); gFrameTree.addObserver(this); }, handleEvent: function (event) { let frame = event.target && event.target.defaultView; // Don't collect scroll data for frames created at or after the load event // as SessionStore can't restore scroll data for those. if (frame && gFrameTree.contains(frame)) { MessageQueue.push("scroll", () => this.collect()); } }, onFrameTreeCollected: function () { MessageQueue.push("scroll", () => this.collect()); }, onFrameTreeReset: function () { MessageQueue.push("scroll", () => null); }, collect: function () { return gFrameTree.map(ScrollPosition.collect); } }; /** * Listens for changes to the page style. Whenever a different page style is * selected or author styles are enabled/disabled we send a message with the * currently applied style to the chrome process. * * Causes a SessionStore:update message to be sent that contains the currently * selected pageStyle for all reachable frames. * * Example: * {pageStyle: "Dusk", children: [null, {pageStyle: "Mozilla"}]} */ let PageStyleListener = { init: function () { Services.obs.addObserver(this, "author-style-disabled-changed", true); Services.obs.addObserver(this, "style-sheet-applicable-state-changed", true); gFrameTree.addObserver(this); }, observe: function (subject, topic) { let frame = subject.defaultView; if (frame && gFrameTree.contains(frame)) { MessageQueue.push("pageStyle", () => this.collect()); } }, collect: function () { return PageStyle.collect(docShell, gFrameTree); }, onFrameTreeCollected: function () { MessageQueue.push("pageStyle", () => this.collect()); }, onFrameTreeReset: function () { MessageQueue.push("pageStyle", () => null); }, QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]) }; /** * Listens for changes to docShell capabilities. Whenever a new load is started * we need to re-check the list of capabilities and send message when it has * changed. * * Causes a SessionStore:update message to be sent that contains the currently * disabled docShell capabilities (all nsIDocShell.allow* properties set to * false) as a string - i.e. capability names separate by commas. */ let DocShellCapabilitiesListener = { /** * This field is used to compare the last docShell capabilities to the ones * that have just been collected. If nothing changed we won't send a message. */ _latestCapabilities: "", init: function () { gFrameTree.addObserver(this); }, /** * onFrameTreeReset() is called as soon as we start loading a page. */ onFrameTreeReset: function() { // The order of docShell capabilities cannot change while we're running // so calling join() without sorting before is totally sufficient. let caps = DocShellCapabilities.collect(docShell).join(","); // Send new data only when the capability list changes. if (caps != this._latestCapabilities) { this._latestCapabilities = caps; MessageQueue.push("disallow", () => caps || null); } } }; /** * Listens for changes to the DOMSessionStorage. Whenever new keys are added, * existing ones removed or changed, or the storage is cleared we will send a * message to the parent process containing up-to-date sessionStorage data. * * Causes a SessionStore:update message to be sent that contains the current * DOMSessionStorage contents. The data is a nested object using host names * as keys and per-host DOMSessionStorage data as values. */ let SessionStorageListener = { init: function () { addEventListener("MozStorageChanged", this); Services.obs.addObserver(this, "browser:purge-domain-data", true); gFrameTree.addObserver(this); }, handleEvent: function (event) { // Ignore events triggered by localStorage or globalStorage changes. if (gFrameTree.contains(event.target) && isSessionStorageEvent(event)) { this.collect(); } }, observe: function () { // Collect data on the next tick so that any other observer // that needs to purge data can do its work first. setTimeout(() => this.collect(), 0); }, collect: function () { if (docShell) { MessageQueue.push("storage", () => SessionStorage.collect(docShell, gFrameTree)); } }, onFrameTreeCollected: function () { this.collect(); }, onFrameTreeReset: function () { this.collect(); }, QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]) }; /** * Listen for changes to the privacy status of the tab. * By definition, tabs start in non-private mode. * * Causes a SessionStore:update message to be sent for * field "isPrivate". This message contains * |true| if the tab is now private * |null| if the tab is now public - the field is therefore * not saved. */ let PrivacyListener = { init: function() { docShell.addWeakPrivacyTransitionObserver(this); // Check that value at startup as it might have // been set before the frame script was loaded. if (docShell.QueryInterface(Ci.nsILoadContext).usePrivateBrowsing) { MessageQueue.push("isPrivate", () => true); } }, // Ci.nsIPrivacyTransitionObserver privateModeChanged: function(enabled) { MessageQueue.push("isPrivate", () => enabled || null); }, QueryInterface: XPCOMUtils.generateQI([Ci.nsIPrivacyTransitionObserver, Ci.nsISupportsWeakReference]) }; /** * A message queue that takes collected data and will take care of sending it * to the chrome process. It allows flushing using synchronous messages and * takes care of any race conditions that might occur because of that. Changes * will be batched if they're pushed in quick succession to avoid a message * flood. */ let MessageQueue = { /** * A unique, monotonically increasing ID used for outgoing messages. This is * important to make it possible to reuse tabs and allow sync flushes before * data could be destroyed. */ _id: 1, /** * A map (string -> lazy fn) holding lazy closures of all queued data * collection routines. These functions will return data collected from the * docShell. */ _data: new Map(), /** * A map holding the |this._id| value for every type of data back when it * was pushed onto the queue. We will use those IDs to find the data to send * and flush. */ _lastUpdated: new Map(), /** * The delay (in ms) used to delay sending changes after data has been * invalidated. */ BATCH_DELAY_MS: 1000, /** * The current timeout ID, null if there is no queue data. We use timeouts * to damp a flood of data changes and send lots of changes as one batch. */ _timeout: null, /** * Pushes a given |value| onto the queue. The given |key| represents the type * of data that is stored and can override data that has been queued before * but has not been sent to the parent process, yet. * * @param key (string) * A unique identifier specific to the type of data this is passed. * @param fn (function) * A function that returns the value that will be sent to the parent * process. */ push: function (key, fn) { this._data.set(key, createLazy(fn)); this._lastUpdated.set(key, this._id); if (!this._timeout) { // Wait a little before sending the message to batch multiple changes. this._timeout = setTimeout(() => this.send(), this.BATCH_DELAY_MS); } }, /** * Sends queued data to the chrome process. * * @param options (object) * {id: 123} to override the update ID used to accumulate data to send. * {sync: true} to send data to the parent process synchronously. */ send: function (options = {}) { // Looks like we have been called off a timeout after the tab has been // closed. The docShell is gone now and we can just return here as there // is nothing to do. if (!docShell) { return; } if (this._timeout) { clearTimeout(this._timeout); this._timeout = null; } let sync = options && options.sync; let startID = (options && options.id) || this._id; // We use sendRpcMessage in the sync case because we may have been called // through a CPOW. RPC messages are the only synchronous messages that the // child is allowed to send to the parent while it is handling a CPOW // request. let sendMessage = sync ? sendRpcMessage : sendAsyncMessage; let durationMs = Date.now(); let data = {}; for (let [key, id] of this._lastUpdated) { // There is no data for the given key anymore because // the parent process already marked it as received. if (!this._data.has(key)) { continue; } if (startID > id) { // If the |id| passed by the parent process is higher than the one // stored in |_lastUpdated| for the given key we know that the parent // received all necessary data and we can remove it from the map. this._data.delete(key); continue; } data[key] = this._data.get(key)(); } durationMs = Date.now() - durationMs; let telemetry = { FX_SESSION_RESTORE_CONTENT_COLLECT_DATA_LONGEST_OP_MS: durationMs } // Send all data to the parent process. sendMessage("SessionStore:update", { id: this._id, data: data, telemetry: telemetry }); // Increase our unique message ID. this._id++; }, /** * This function is used to make the message queue flush all queue data that * hasn't been sent to the parent process, yet. * * @param id (int) * A unique id that represents the latest message received by the * chrome process. We can use this to determine which messages have not * yet been received because they are still stuck in the event queue. */ flush: function (id) { // It's important to always send data, even if there is nothing to flush. // The update message will be received by the parent process that can then // update its last received update ID to ignore stale messages. this.send({id: id + 1, sync: true}); this._data.clear(); this._lastUpdated.clear(); }, /** * DO NOT USE - DEBUGGING / TESTING ONLY * * This function is used to simulate certain situations where race conditions * can occur by sending data shortly before flushing synchronously. */ flushAsync: function () { if (!Services.prefs.getBoolPref("browser.sessionstore.debug")) { throw new Error("flushAsync() must be used for testing, only."); } this.send(); } }; EventListener.init(); MessageListener.init(); SyncHandler.init(); ProgressListener.init(); PageStyleListener.init(); SessionStorageListener.init(); ScrollPositionListener.init(); DocShellCapabilitiesListener.init(); PrivacyListener.init();