diff options
Diffstat (limited to 'browser/components/sessionstore/content')
3 files changed, 1345 insertions, 0 deletions
diff --git a/browser/components/sessionstore/content/aboutSessionRestore.js b/browser/components/sessionstore/content/aboutSessionRestore.js new file mode 100644 index 000000000..cc8d2da0b --- /dev/null +++ b/browser/components/sessionstore/content/aboutSessionRestore.js @@ -0,0 +1,362 @@ +/* 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 {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); + +var gStateObject; +var gTreeData; + +// Page initialization + +window.onload = function() { + // pages used by this script may have a link that needs to be updated to + // the in-product link. + let anchor = document.getElementById("linkMoreTroubleshooting"); + if (anchor) { + let baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL"); + anchor.setAttribute("href", baseURL + "troubleshooting"); + } + + // wire up click handlers for the radio buttons if they exist. + for (let radioId of ["radioRestoreAll", "radioRestoreChoose"]) { + let button = document.getElementById(radioId); + if (button) { + button.addEventListener("click", updateTabListVisibility); + } + } + + // the crashed session state is kept inside a textbox so that SessionStore picks it up + // (for when the tab is closed or the session crashes right again) + var sessionData = document.getElementById("sessionData"); + if (!sessionData.value) { + document.getElementById("errorTryAgain").disabled = true; + return; + } + + gStateObject = JSON.parse(sessionData.value); + + // make sure the data is tracked to be restored in case of a subsequent crash + var event = document.createEvent("UIEvents"); + event.initUIEvent("input", true, true, window, 0); + sessionData.dispatchEvent(event); + + initTreeView(); + + document.getElementById("errorTryAgain").focus(); +}; + +function isTreeViewVisible() { + let tabList = document.querySelector(".tree-container"); + return tabList.hasAttribute("available"); +} + +function initTreeView() { + // If we aren't visible we initialize as we are made visible (and it's OK + // to initialize multiple times) + if (!isTreeViewVisible()) { + return; + } + var tabList = document.getElementById("tabList"); + var winLabel = tabList.getAttribute("_window_label"); + + gTreeData = []; + gStateObject.windows.forEach(function(aWinData, aIx) { + var winState = { + label: winLabel.replace("%S", (aIx + 1)), + open: true, + checked: true, + ix: aIx + }; + winState.tabs = aWinData.tabs.map(function(aTabData) { + var entry = aTabData.entries[aTabData.index - 1] || { url: "about:blank" }; + var iconURL = aTabData.image || null; + // don't initiate a connection just to fetch a favicon (see bug 462863) + if (/^https?:/.test(iconURL)) + iconURL = "moz-anno:favicon:" + iconURL; + return { + label: entry.title || entry.url, + checked: true, + src: iconURL, + parent: winState + }; + }); + gTreeData.push(winState); + for (let tab of winState.tabs) + gTreeData.push(tab); + }, this); + + tabList.view = treeView; + tabList.view.selection.select(0); +} + +// User actions +function updateTabListVisibility() { + let tabList = document.querySelector(".tree-container"); + let container = document.querySelector(".container"); + if (document.getElementById("radioRestoreChoose").checked) { + tabList.setAttribute("available", "true"); + container.classList.add("restore-chosen"); + } else { + tabList.removeAttribute("available"); + container.classList.remove("restore-chosen"); + } + initTreeView(); +} + +function restoreSession() { + Services.obs.notifyObservers(null, "sessionstore-initiating-manual-restore", ""); + document.getElementById("errorTryAgain").disabled = true; + + if (isTreeViewVisible()) { + if (!gTreeData.some(aItem => aItem.checked)) { + // This should only be possible when we have no "cancel" button, and thus + // the "Restore session" button always remains enabled. In that case and + // when nothing is selected, we just want a new session. + startNewSession(); + return; + } + + // remove all unselected tabs from the state before restoring it + var ix = gStateObject.windows.length - 1; + for (var t = gTreeData.length - 1; t >= 0; t--) { + if (treeView.isContainer(t)) { + if (gTreeData[t].checked === 0) + // this window will be restored partially + gStateObject.windows[ix].tabs = + gStateObject.windows[ix].tabs.filter((aTabData, aIx) => + gTreeData[t].tabs[aIx].checked); + else if (!gTreeData[t].checked) + // this window won't be restored at all + gStateObject.windows.splice(ix, 1); + ix--; + } + } + } + var stateString = JSON.stringify(gStateObject); + + var ss = Cc["@mozilla.org/browser/sessionstore;1"].getService(Ci.nsISessionStore); + var top = getBrowserWindow(); + + // if there's only this page open, reuse the window for restoring the session + if (top.gBrowser.tabs.length == 1) { + ss.setWindowState(top, stateString, true); + return; + } + + // restore the session into a new window and close the current tab + var newWindow = top.openDialog(top.location, "_blank", "chrome,dialog=no,all"); + + var obs = Cc["@mozilla.org/observer-service;1"].getService(Ci.nsIObserverService); + obs.addObserver(function observe(win, topic) { + if (win != newWindow) { + return; + } + + obs.removeObserver(observe, topic); + ss.setWindowState(newWindow, stateString, true); + + var tabbrowser = top.gBrowser; + var tabIndex = tabbrowser.getBrowserIndexForDocument(document); + tabbrowser.removeTab(tabbrowser.tabs[tabIndex]); + }, "browser-delayed-startup-finished", false); +} + +function startNewSession() { + var prefBranch = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch); + if (prefBranch.getIntPref("browser.startup.page") == 0) + getBrowserWindow().gBrowser.loadURI("about:blank"); + else + getBrowserWindow().BrowserHome(); +} + +function onListClick(aEvent) { + // don't react to right-clicks + if (aEvent.button == 2) + return; + + var cell = treeView.treeBox.getCellAt(aEvent.clientX, aEvent.clientY); + if (cell.col) { + // Restore this specific tab in the same window for middle/double/accel clicking + // on a tab's title. + let accelKey = AppConstants.platform == "macosx" ? + aEvent.metaKey : + aEvent.ctrlKey; + if ((aEvent.button == 1 || aEvent.button == 0 && aEvent.detail == 2 || accelKey) && + cell.col.id == "title" && + !treeView.isContainer(cell.row)) { + restoreSingleTab(cell.row, aEvent.shiftKey); + aEvent.stopPropagation(); + } + else if (cell.col.id == "restore") + toggleRowChecked(cell.row); + } +} + +function onListKeyDown(aEvent) { + switch (aEvent.keyCode) + { + case KeyEvent.DOM_VK_SPACE: + toggleRowChecked(document.getElementById("tabList").currentIndex); + // Prevent page from scrolling on the space key. + aEvent.preventDefault(); + break; + case KeyEvent.DOM_VK_RETURN: + var ix = document.getElementById("tabList").currentIndex; + if (aEvent.ctrlKey && !treeView.isContainer(ix)) + restoreSingleTab(ix, aEvent.shiftKey); + break; + } +} + +// Helper functions + +function getBrowserWindow() { + return window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem).rootTreeItem + .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow); +} + +function toggleRowChecked(aIx) { + function isChecked(aItem) { + return aItem.checked; + } + + var item = gTreeData[aIx]; + item.checked = !item.checked; + treeView.treeBox.invalidateRow(aIx); + + if (treeView.isContainer(aIx)) { + // (un)check all tabs of this window as well + for (let tab of item.tabs) { + tab.checked = item.checked; + treeView.treeBox.invalidateRow(gTreeData.indexOf(tab)); + } + } + else { + // update the window's checkmark as well (0 means "partially checked") + item.parent.checked = item.parent.tabs.every(isChecked) ? true : + item.parent.tabs.some(isChecked) ? 0 : false; + treeView.treeBox.invalidateRow(gTreeData.indexOf(item.parent)); + } + + // we only disable the button when there's no cancel button. + if (document.getElementById("errorCancel")) { + document.getElementById("errorTryAgain").disabled = !gTreeData.some(isChecked); + } +} + +function restoreSingleTab(aIx, aShifted) { + var tabbrowser = getBrowserWindow().gBrowser; + var newTab = tabbrowser.addTab(); + var item = gTreeData[aIx]; + + var ss = Cc["@mozilla.org/browser/sessionstore;1"].getService(Ci.nsISessionStore); + var tabState = gStateObject.windows[item.parent.ix] + .tabs[aIx - gTreeData.indexOf(item.parent) - 1]; + // ensure tab would be visible on the tabstrip. + tabState.hidden = false; + ss.setTabState(newTab, JSON.stringify(tabState)); + + // respect the preference as to whether to select the tab (the Shift key inverses) + var prefBranch = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch); + if (prefBranch.getBoolPref("browser.tabs.loadInBackground") != !aShifted) + tabbrowser.selectedTab = newTab; +} + +// Tree controller + +var treeView = { + treeBox: null, + selection: null, + + get rowCount() { return gTreeData.length; }, + setTree: function(treeBox) { this.treeBox = treeBox; }, + getCellText: function(idx, column) { return gTreeData[idx].label; }, + isContainer: function(idx) { return "open" in gTreeData[idx]; }, + getCellValue: function(idx, column){ return gTreeData[idx].checked; }, + isContainerOpen: function(idx) { return gTreeData[idx].open; }, + isContainerEmpty: function(idx) { return false; }, + isSeparator: function(idx) { return false; }, + isSorted: function() { return false; }, + isEditable: function(idx, column) { return false; }, + canDrop: function(idx, orientation, dt) { return false; }, + getLevel: function(idx) { return this.isContainer(idx) ? 0 : 1; }, + + getParentIndex: function(idx) { + if (!this.isContainer(idx)) + for (var t = idx - 1; t >= 0 ; t--) + if (this.isContainer(t)) + return t; + return -1; + }, + + hasNextSibling: function(idx, after) { + var thisLevel = this.getLevel(idx); + for (var t = after + 1; t < gTreeData.length; t++) + if (this.getLevel(t) <= thisLevel) + return this.getLevel(t) == thisLevel; + return false; + }, + + toggleOpenState: function(idx) { + if (!this.isContainer(idx)) + return; + var item = gTreeData[idx]; + if (item.open) { + // remove this window's tab rows from the view + var thisLevel = this.getLevel(idx); + for (var t = idx + 1; t < gTreeData.length && this.getLevel(t) > thisLevel; t++); + var deletecount = t - idx - 1; + gTreeData.splice(idx + 1, deletecount); + this.treeBox.rowCountChanged(idx + 1, -deletecount); + } + else { + // add this window's tab rows to the view + var toinsert = gTreeData[idx].tabs; + for (var i = 0; i < toinsert.length; i++) + gTreeData.splice(idx + i + 1, 0, toinsert[i]); + this.treeBox.rowCountChanged(idx + 1, toinsert.length); + } + item.open = !item.open; + this.treeBox.invalidateRow(idx); + }, + + getCellProperties: function(idx, column) { + if (column.id == "restore" && this.isContainer(idx) && gTreeData[idx].checked === 0) + return "partial"; + if (column.id == "title") + return this.getImageSrc(idx, column) ? "icon" : "noicon"; + + return ""; + }, + + getRowProperties: function(idx) { + var winState = gTreeData[idx].parent || gTreeData[idx]; + if (winState.ix % 2 != 0) + return "alternate"; + + return ""; + }, + + getImageSrc: function(idx, column) { + if (column.id == "title") + return gTreeData[idx].src || null; + return null; + }, + + getProgressMode : function(idx, column) { }, + cycleHeader: function(column) { }, + cycleCell: function(idx, column) { }, + selectionChanged: function() { }, + performAction: function(action) { }, + performActionOnCell: function(action, index, column) { }, + getColumnProperties: function(column) { return ""; } +}; diff --git a/browser/components/sessionstore/content/aboutSessionRestore.xhtml b/browser/components/sessionstore/content/aboutSessionRestore.xhtml new file mode 100644 index 000000000..bcd9084e7 --- /dev/null +++ b/browser/components/sessionstore/content/aboutSessionRestore.xhtml @@ -0,0 +1,86 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +# 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/. +--> +<!DOCTYPE html [ + <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd"> + %htmlDTD; + <!ENTITY % netErrorDTD SYSTEM "chrome://global/locale/netError.dtd"> + %netErrorDTD; + <!ENTITY % globalDTD SYSTEM "chrome://global/locale/global.dtd"> + %globalDTD; + <!ENTITY % restorepageDTD SYSTEM "chrome://browser/locale/aboutSessionRestore.dtd"> + %restorepageDTD; +]> + +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <head> + <title>&restorepage.tabtitle;</title> + <link rel="stylesheet" href="chrome://global/skin/in-content/info-pages.css" type="text/css" media="all"/> + <link rel="stylesheet" href="chrome://browser/skin/aboutSessionRestore.css" type="text/css" media="all"/> + <link rel="icon" type="image/png" href="chrome://global/skin/icons/warning-16.png"/> + + <script type="application/javascript;version=1.8" src="chrome://browser/content/aboutSessionRestore.js"/> + </head> + + <body dir="&locale.dir;"> + + <div class="container restore-chosen"> + + <div class="title"> + <h1 class="title-text">&restorepage.errorTitle;</h1> + </div> + <div class="description"> + <p>&restorepage.problemDesc;</p> + + <div id="errorLongDesc"> + <p>&restorepage.tryThis;</p> + <ul> + <li>&restorepage.restoreSome;</li> + <li>&restorepage.startNew;</li> + </ul> + </div> + </div> + <div class="tree-container" available="true"> + <xul:tree id="tabList" seltype="single" hidecolumnpicker="true" + onclick="onListClick(event);" onkeydown="onListKeyDown(event);" + _window_label="&restorepage.windowLabel;"> + <xul:treecols> + <xul:treecol cycler="true" id="restore" type="checkbox" label="&restorepage.restoreHeader;"/> + <xul:splitter class="tree-splitter"/> + <xul:treecol primary="true" id="title" label="&restorepage.listHeader;" flex="1"/> + </xul:treecols> + <xul:treechildren flex="1"/> + </xul:tree> + </div> + <div class="button-container"> +#ifdef XP_UNIX + <xul:button id="errorCancel" + label="&restorepage.closeButton;" + accesskey="&restorepage.close.access;" + oncommand="startNewSession();"/> + <xul:button class="primary" + id="errorTryAgain" + label="&restorepage.tryagainButton;" + accesskey="&restorepage.restore.access;" + oncommand="restoreSession();"/> +#else + <xul:button class="primary" + id="errorTryAgain" + label="&restorepage.tryagainButton;" + accesskey="&restorepage.restore.access;" + oncommand="restoreSession();"/> + <xul:button id="errorCancel" + label="&restorepage.closeButton;" + accesskey="&restorepage.close.access;" + oncommand="startNewSession();"/> +#endif + </div> + <!-- holds the session data for when the tab is closed --> + <input type="text" id="sessionData" style="display: none;"/> + </div> + + </body> +</html> diff --git a/browser/components/sessionstore/content/content-sessionStore.js b/browser/components/sessionstore/content/content-sessionStore.js new file mode 100644 index 000000000..858e35750 --- /dev/null +++ b/browser/components/sessionstore/content/content-sessionStore.js @@ -0,0 +1,897 @@ +/* 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); +} + +var Cu = Components.utils; +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); +Cu.import("resource://gre/modules/Timer.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "FormData", + "resource://gre/modules/FormData.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DocShellCapabilities", + "resource:///modules/sessionstore/DocShellCapabilities.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PageStyle", + "resource:///modules/sessionstore/PageStyle.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ScrollPosition", + "resource://gre/modules/ScrollPosition.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionHistory", + "resource:///modules/sessionstore/SessionHistory.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionStorage", + "resource:///modules/sessionstore/SessionStorage.jsm"); + +Cu.import("resource:///modules/sessionstore/FrameTree.jsm", this); +var gFrameTree = new FrameTree(this); + +Cu.import("resource:///modules/sessionstore/ContentRestore.jsm", this); +XPCOMUtils.defineLazyGetter(this, 'gContentRestore', + () => { return new ContentRestore(this) }); + +// The current epoch. +var gCurrentEpoch = 0; + +// A bound to the size of data to store for DOM Storage. +const DOM_STORAGE_MAX_CHARS = 10000000; // 10M characters + +// This pref controls whether or not we send updates to the parent on a timeout +// or not, and should only be used for tests or debugging. +const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates"; + +const kNoIndex = Number.MAX_SAFE_INTEGER; +const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + +/** + * 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; + }; +} + +/** + * Listens for and handles content events that we need for the + * session store service to be notified of state changes in content. + */ +var EventListener = { + + init: function () { + addEventListener("load", this, true); + }, + + handleEvent: function (event) { + // Ignore load events from subframes. + if (event.target != content.document) { + return; + } + + if (content.document.documentURI.startsWith("about:reader")) { + if (event.type == "load" && + !content.document.body.classList.contains("loaded")) { + // Don't restore the scroll position of an about:reader page at this + // point; listen for the custom event dispatched from AboutReader.jsm. + content.addEventListener("AboutReaderContentReady", this); + return; + } + + content.removeEventListener("AboutReaderContentReady", this); + } + + // Restore the form data and scroll position. If we're not currently + // restoring a tab state then this call will simply be a noop. + gContentRestore.restoreDocument(); + } +}; + +/** + * Listens for and handles messages sent by the session store service. + */ +var MessageListener = { + + MESSAGES: [ + "SessionStore:restoreHistory", + "SessionStore:restoreTabContent", + "SessionStore:resetRestore", + "SessionStore:flush", + ], + + init: function () { + this.MESSAGES.forEach(m => addMessageListener(m, this)); + }, + + receiveMessage: function ({name, data}) { + // The docShell might be gone. Don't process messages, + // that will just lead to errors anyway. + if (!docShell) { + return; + } + + // A fresh tab always starts with epoch=0. The parent has the ability to + // override that to signal a new era in this tab's life. This enables it + // to ignore async messages that were already sent but not yet received + // and would otherwise confuse the internal tab state. + if (data.epoch && data.epoch != gCurrentEpoch) { + gCurrentEpoch = data.epoch; + } + + switch (name) { + case "SessionStore:restoreHistory": + this.restoreHistory(data); + break; + case "SessionStore:restoreTabContent": + this.restoreTabContent(data); + break; + case "SessionStore:resetRestore": + gContentRestore.resetRestore(); + break; + case "SessionStore:flush": + this.flush(data); + break; + default: + debug("received unknown message '" + name + "'"); + break; + } + }, + + restoreHistory({epoch, tabData, loadArguments, isRemotenessUpdate}) { + gContentRestore.restoreHistory(tabData, loadArguments, { + // Note: The callbacks passed here will only be used when a load starts + // that was not initiated by sessionstore itself. This can happen when + // some code calls browser.loadURI() or browser.reload() on a pending + // browser/tab. + + onLoadStarted() { + // Notify the parent that the tab is no longer pending. + sendSyncMessage("SessionStore:restoreTabContentStarted", {epoch}); + }, + + onLoadFinished() { + // 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}); + } + }); + + // 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, isRemotenessUpdate}); + }, + + restoreTabContent({loadArguments, isRemotenessUpdate}) { + let epoch = gCurrentEpoch; + + // We need to pass the value of didStartLoad back to SessionStore.jsm. + let didStartLoad = gContentRestore.restoreTabContent(loadArguments, isRemotenessUpdate, () => { + // 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, isRemotenessUpdate}); + }); + + sendAsyncMessage("SessionStore:restoreTabContentStarted", {epoch, isRemotenessUpdate}); + + if (!didStartLoad) { + // Pretend that the load succeeded so that event handlers fire correctly. + sendAsyncMessage("SessionStore:restoreTabContentComplete", {epoch, isRemotenessUpdate}); + } + }, + + flush({id}) { + // Flush the message queue, send the latest updates. + MessageQueue.send({flushID: id}); + } +}; + +/** + * Listens for changes to the session history. Whenever the user navigates + * we will collect URLs and everything belonging to session history. + * + * Causes a SessionStore:update message to be sent that contains the current + * session history. + * + * Example: + * {entries: [{url: "about:mozilla", ...}, ...], index: 1} + */ +var SessionHistoryListener = { + init: function () { + // The frame tree observer is needed to handle initial subframe loads. + // It will redundantly invalidate with the SHistoryListener in some cases + // but these invalidations are very cheap. + gFrameTree.addObserver(this); + + // By adding the SHistoryListener immediately, we will unfortunately be + // notified of every history entry as the tab is restored. We don't bother + // waiting to add the listener later because these notifications are cheap. + // We will likely only collect once since we are batching collection on + // a delay. + docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory. + addSHistoryListener(this); + + // Collect data if we start with a non-empty shistory. + if (!SessionHistory.isEmpty(docShell)) { + this.collect(); + // When a tab is detached from the window, for the new window there is a + // new SessionHistoryListener created. Normally it is empty at this point + // but in a test env. the initial about:blank might have a children in which + // case we fire off a history message here with about:blank in it. If we + // don't do it ASAP then there is going to be a browser swap and the parent + // will be all confused by that message. + MessageQueue.send(); + } + + // Listen for page title changes. + addEventListener("DOMTitleChanged", this); + }, + + uninit: function () { + let sessionHistory = docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory; + if (sessionHistory) { + sessionHistory.removeSHistoryListener(this); + } + }, + + collect: function () { + this._fromIdx = kNoIndex; + if (docShell) { + MessageQueue.push("history", () => SessionHistory.collect(docShell)); + } + }, + + _fromIdx: kNoIndex, + + // History can grow relatively big with the nested elements, so if we don't have to, we + // don't want to send the entire history all the time. For a simple optimization + // we keep track of the smallest index from after any change has occured and we just send + // the elements from that index. If something more complicated happens we just clear it + // and send the entire history. We always send the additional info like the current selected + // index (so for going back and forth between history entries we set the index to kLastIndex + // if nothing else changed send an empty array and the additonal info like the selected index) + collectFrom: function (idx) { + if (this._fromIdx <= idx) { + // If we already know that we need to update history fromn index N we can ignore any changes + // tha happened with an element with index larger than N. + // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which means we don't ignore anything + // here, and in case of navigation in the history back and forth we use kLastIndex which ignores + // only the subsequent navigations, but not any new elements added. + return; + } + + this._fromIdx = idx; + MessageQueue.push("historychange", () => { + if (this._fromIdx === kNoIndex) { + return null; + } + + let history = SessionHistory.collect(docShell); + if (kLastIndex == idx) { + history.entries = []; + } else { + history.entries.splice(0, this._fromIdx + 1); + } + + history.fromIdx = this._fromIdx; + + this._fromIdx = kNoIndex; + return history; + }); + }, + + handleEvent(event) { + this.collect(); + }, + + onFrameTreeCollected: function () { + this.collect(); + }, + + onFrameTreeReset: function () { + this.collect(); + }, + + OnHistoryNewEntry: function (newURI, oldIndex) { + this.collectFrom(oldIndex); + }, + + OnHistoryGoBack: function (backURI) { + this.collectFrom(kLastIndex); + return true; + }, + + OnHistoryGoForward: function (forwardURI) { + this.collectFrom(kLastIndex); + return true; + }, + + OnHistoryGotoIndex: function (index, gotoURI) { + this.collectFrom(kLastIndex); + return true; + }, + + OnHistoryPurge: function (numEntries) { + this.collect(); + return true; + }, + + OnHistoryReload: function (reloadURI, reloadFlags) { + this.collect(); + return true; + }, + + OnHistoryReplaceEntry: function (index) { + this.collect(); + }, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsISHistoryListener, + 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"}]} + */ +var ScrollPositionListener = { + init: function () { + addEventListener("scroll", this); + gFrameTree.addObserver(this); + }, + + handleEvent: function (event) { + let frame = 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 (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 input elements. Whenever the value of an input + * element changes we will re-collect data for the current frame tree and send + * a message to the parent process. + * + * Causes a SessionStore:update message to be sent that contains the form data + * for all reachable frames. + * + * Example: + * { + * formdata: {url: "http://mozilla.org/", id: {input_id: "input value"}}, + * children: [ + * null, + * {url: "http://sub.mozilla.org/", id: {input_id: "input value 2"}} + * ] + * } + */ +var FormDataListener = { + init: function () { + addEventListener("input", this, true); + addEventListener("change", this, true); + gFrameTree.addObserver(this); + }, + + handleEvent: function (event) { + let frame = event.target.ownerGlobal; + + // Don't collect form data for frames created at or after the load event + // as SessionStore can't restore form data for those. + if (gFrameTree.contains(frame)) { + MessageQueue.push("formdata", () => this.collect()); + } + }, + + onFrameTreeReset: function () { + MessageQueue.push("formdata", () => null); + }, + + collect: function () { + return gFrameTree.map(FormData.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"}]} + */ +var PageStyleListener = { + init: function () { + Services.obs.addObserver(this, "author-style-disabled-changed", false); + Services.obs.addObserver(this, "style-sheet-applicable-state-changed", false); + gFrameTree.addObserver(this); + }, + + uninit: function () { + Services.obs.removeObserver(this, "author-style-disabled-changed"); + Services.obs.removeObserver(this, "style-sheet-applicable-state-changed"); + }, + + 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); + } +}; + +/** + * 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. + */ +var 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. + */ +var SessionStorageListener = { + init: function () { + addEventListener("MozSessionStorageChanged", this, true); + Services.obs.addObserver(this, "browser:purge-domain-data", false); + gFrameTree.addObserver(this); + }, + + uninit: function () { + Services.obs.removeObserver(this, "browser:purge-domain-data"); + }, + + handleEvent: function (event) { + if (gFrameTree.contains(event.target)) { + this.collectFromEvent(event); + } + }, + + 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); + }, + + // Before DOM Storage can be written to disk, it needs to be serialized + // for sending across frames/processes, then again to be sent across + // threads, then again to be put in a buffer for the disk. Each of these + // serializations is an opportunity to OOM and (depending on the site of + // the OOM), either crash, lose all data for the frame or lose all data + // for the application. + // + // In order to avoid this, compute an estimate of the size of the + // object, and block SessionStorage items that are too large. As + // we also don't want to cause an OOM here, we use a quick and memory- + // efficient approximation: we compute the total sum of string lengths + // involved in this object. + estimateStorageSize: function(collected) { + if (!collected) { + return 0; + } + + let size = 0; + for (let host of Object.keys(collected)) { + size += host.length; + let perHost = collected[host]; + for (let key of Object.keys(perHost)) { + size += key.length; + let perKey = perHost[key]; + size += perKey.length; + } + } + + return size; + }, + + // We don't want to send all the session storage data for all the frames + // for every change. So if only a few value changed we send them over as + // a "storagechange" event. If however for some reason before we send these + // changes we have to send over the entire sessions storage data, we just + // reset these changes. + _changes: undefined, + + resetChanges: function () { + this._changes = undefined; + }, + + collectFromEvent: function (event) { + // TODO: we should take browser.sessionstore.dom_storage_limit into an account here. + if (docShell) { + let {url, key, newValue} = event; + let uri = Services.io.newURI(url, null, null); + let domain = uri.prePath; + if (!this._changes) { + this._changes = {}; + } + if (!this._changes[domain]) { + this._changes[domain] = {}; + } + this._changes[domain][key] = newValue; + + MessageQueue.push("storagechange", () => { + let tmp = this._changes; + // If there were multiple changes we send them merged. + // First one will collect all the changes the rest of + // these messages will be ignored. + this.resetChanges(); + return tmp; + }); + } + }, + + collect: function () { + if (docShell) { + // We need the entire session storage, let's reset the pending individual change + // messages. + this.resetChanges(); + MessageQueue.push("storage", () => { + let collected = SessionStorage.collect(docShell, gFrameTree); + + if (collected == null) { + return collected; + } + + let size = this.estimateStorageSize(collected); + + MessageQueue.push("telemetry", () => ({ FX_SESSION_RESTORE_DOM_STORAGE_SIZE_ESTIMATE_CHARS: size })); + if (size > Preferences.get("browser.sessionstore.dom_storage_limit", DOM_STORAGE_MAX_CHARS)) { + // Rather than keeping the old storage, which wouldn't match the rest + // of the state of the page, empty the storage. DOM storage will be + // recollected the next time and stored if it is now small enough. + return {}; + } + + return collected; + }); + } + }, + + onFrameTreeCollected: function () { + this.collect(); + }, + + onFrameTreeReset: function () { + this.collect(); + } +}; + +/** + * 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. + */ +var 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. + */ +var MessageQueue = { + /** + * 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(), + + /** + * 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, + + /** + * Whether or not sending batched messages on a timer is disabled. This should + * only be used for debugging or testing. If you need to access this value, + * you should probably use the timeoutDisabled getter. + */ + _timeoutDisabled: false, + + /** + * True if batched messages are not being fired on a timer. This should only + * ever be true when debugging or during tests. + */ + get timeoutDisabled() { + return this._timeoutDisabled; + }, + + /** + * Disables sending batched messages on a timer. Also cancels any pending + * timers. + */ + set timeoutDisabled(val) { + this._timeoutDisabled = val; + + if (val && this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + + return val; + }, + + init() { + this.timeoutDisabled = + Services.prefs.getBoolPref(TIMEOUT_DISABLED_PREF); + + Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this, false); + }, + + uninit() { + Services.prefs.removeObserver(TIMEOUT_DISABLED_PREF, this); + }, + + observe(subject, topic, data) { + if (topic == "nsPref:changed" && data == TIMEOUT_DISABLED_PREF) { + this.timeoutDisabled = + Services.prefs.getBoolPref(TIMEOUT_DISABLED_PREF); + } + }, + + /** + * 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)); + + if (!this._timeout && !this._timeoutDisabled) { + // 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) + * {flushID: 123} to specify that this is a flush + * {isFinal: true} to signal this is the final message sent on unload + */ + 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 flushID = (options && options.flushID) || 0; + + let durationMs = Date.now(); + + let data = {}; + let telemetry = {}; + for (let [key, func] of this._data) { + let value = func(); + if (key == "telemetry") { + for (let histogramId of Object.keys(value)) { + telemetry[histogramId] = value[histogramId]; + } + } else if (value || (key != "storagechange" && key != "historychange")) { + data[key] = value; + } + } + + this._data.clear(); + + durationMs = Date.now() - durationMs; + telemetry.FX_SESSION_RESTORE_CONTENT_COLLECT_DATA_LONGEST_OP_MS = durationMs; + + try { + // Send all data to the parent process. + sendAsyncMessage("SessionStore:update", { + data, telemetry, flushID, + isFinal: options.isFinal || false, + epoch: gCurrentEpoch + }); + } catch (ex if ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) { + let telemetry = { + FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM: 1 + }; + sendAsyncMessage("SessionStore:error", { + telemetry + }); + } + }, +}; + +EventListener.init(); +MessageListener.init(); +FormDataListener.init(); +PageStyleListener.init(); +SessionHistoryListener.init(); +SessionStorageListener.init(); +ScrollPositionListener.init(); +DocShellCapabilitiesListener.init(); +PrivacyListener.init(); +MessageQueue.init(); + +function handleRevivedTab() { + if (!content) { + removeEventListener("pagehide", handleRevivedTab); + return; + } + + if (content.document.documentURI.startsWith("about:tabcrashed")) { + if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) { + // Sanity check - we'd better be loading this in a non-remote browser. + throw new Error("We seem to be navigating away from about:tabcrashed in " + + "a non-remote browser. This should really never happen."); + } + + removeEventListener("pagehide", handleRevivedTab); + + // Notify the parent. + sendAsyncMessage("SessionStore:crashedTabRevived"); + } +} + +// If we're browsing from the tab crashed UI to a blacklisted URI that keeps +// this browser non-remote, we'll handle that in a pagehide event. +addEventListener("pagehide", handleRevivedTab); + +addEventListener("unload", () => { + // Upon frameLoader destruction, send a final update message to + // the parent and flush all data currently held in the child. + MessageQueue.send({isFinal: true}); + + // If we're browsing from the tab crashed UI to a URI that causes the tab + // to go remote again, we catch this in the unload event handler, because + // swapping out the non-remote browser for a remote one in + // tabbrowser.xml's updateBrowserRemoteness doesn't cause the pagehide + // event to be fired. + handleRevivedTab(); + + // Remove all registered nsIObservers. + PageStyleListener.uninit(); + SessionStorageListener.uninit(); + SessionHistoryListener.uninit(); + MessageQueue.uninit(); + + // Remove progress listeners. + gContentRestore.resetRestore(); + + // We don't need to take care of any gFrameTree observers as the gFrameTree + // will die with the content script. The same goes for the privacy transition + // observer that will die with the docShell when the tab is closed. +}); |