diff options
author | wolfbeast <mcwerewolf@gmail.com> | 2018-06-04 13:17:38 +0200 |
---|---|---|
committer | wolfbeast <mcwerewolf@gmail.com> | 2018-06-04 13:17:38 +0200 |
commit | a1be17c1cea81ebb1e8b131a662c698d78f3f7f2 (patch) | |
tree | a92f7de513be600cc07bac458183e9af40e00c06 /application/basilisk/components/sessionstore | |
parent | bf11fdd304898ac675e39b01b280d39550e419d0 (diff) | |
download | UXP-a1be17c1cea81ebb1e8b131a662c698d78f3f7f2.tar UXP-a1be17c1cea81ebb1e8b131a662c698d78f3f7f2.tar.gz UXP-a1be17c1cea81ebb1e8b131a662c698d78f3f7f2.tar.lz UXP-a1be17c1cea81ebb1e8b131a662c698d78f3f7f2.tar.xz UXP-a1be17c1cea81ebb1e8b131a662c698d78f3f7f2.zip |
Issue #303 Part 1: Move basilisk files from /browser to /application/basilisk
Diffstat (limited to 'application/basilisk/components/sessionstore')
33 files changed, 11386 insertions, 0 deletions
diff --git a/application/basilisk/components/sessionstore/ContentRestore.jsm b/application/basilisk/components/sessionstore/ContentRestore.jsm new file mode 100644 index 000000000..d4972bcaf --- /dev/null +++ b/application/basilisk/components/sessionstore/ContentRestore.jsm @@ -0,0 +1,434 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["ContentRestore"]; + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "DocShellCapabilities", + "resource:///modules/sessionstore/DocShellCapabilities.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FormData", + "resource://gre/modules/FormData.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"); +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://gre/modules/sessionstore/Utils.jsm"); + +/** + * This module implements the content side of session restoration. The chrome + * side is handled by SessionStore.jsm. The functions in this module are called + * by content-sessionStore.js based on messages received from SessionStore.jsm + * (or, in one case, based on a "load" event). Each tab has its own + * ContentRestore instance, constructed by content-sessionStore.js. + * + * In a typical restore, content-sessionStore.js will call the following based + * on messages and events it receives: + * + * restoreHistory(tabData, loadArguments, callbacks) + * Restores the tab's history and session cookies. + * restoreTabContent(loadArguments, finishCallback) + * Starts loading the data for the current page to restore. + * restoreDocument() + * Restore form and scroll data. + * + * When the page has been loaded from the network, we call finishCallback. It + * should send a message to SessionStore.jsm, which may cause other tabs to be + * restored. + * + * When the page has finished loading, a "load" event will trigger in + * content-sessionStore.js, which will call restoreDocument. At that point, + * form data is restored and the restore is complete. + * + * At any time, SessionStore.jsm can cancel the ongoing restore by sending a + * reset message, which causes resetRestore to be called. At that point it's + * legal to begin another restore. + */ +function ContentRestore(chromeGlobal) { + let internal = new ContentRestoreInternal(chromeGlobal); + let external = {}; + + let EXPORTED_METHODS = ["restoreHistory", + "restoreTabContent", + "restoreDocument", + "resetRestore" + ]; + + for (let method of EXPORTED_METHODS) { + external[method] = internal[method].bind(internal); + } + + return Object.freeze(external); +} + +function ContentRestoreInternal(chromeGlobal) { + this.chromeGlobal = chromeGlobal; + + // The following fields are only valid during certain phases of the restore + // process. + + // The tabData for the restore. Set in restoreHistory and removed in + // restoreTabContent. + this._tabData = null; + + // Contains {entry, pageStyle, scrollPositions, formdata}, where entry is a + // single entry from the tabData.entries array. Set in + // restoreTabContent and removed in restoreDocument. + this._restoringDocument = null; + + // This listener is used to detect reloads on restoring tabs. Set in + // restoreHistory and removed in restoreTabContent. + this._historyListener = null; + + // This listener detects when a pending tab starts loading (when not + // initiated by sessionstore) and when a restoring tab has finished loading + // data from the network. Set in restoreHistory() and restoreTabContent(), + // removed in resetRestore(). + this._progressListener = null; +} + +/** + * The API for the ContentRestore module. Methods listed in EXPORTED_METHODS are + * public. + */ +ContentRestoreInternal.prototype = { + + get docShell() { + return this.chromeGlobal.docShell; + }, + + /** + * Starts the process of restoring a tab. The tabData to be restored is passed + * in here and used throughout the restoration. The epoch (which must be + * non-zero) is passed through to all the callbacks. If a load in the tab + * is started while it is pending, the appropriate callbacks are called. + */ + restoreHistory(tabData, loadArguments, callbacks) { + this._tabData = tabData; + + // In case about:blank isn't done yet. + let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); + webNavigation.stop(Ci.nsIWebNavigation.STOP_ALL); + + // Make sure currentURI is set so that switch-to-tab works before the tab is + // restored. We'll reset this to about:blank when we try to restore the tab + // to ensure that docshell doeesn't get confused. Don't bother doing this if + // we're restoring immediately due to a process switch. It just causes the + // URL bar to be temporarily blank. + let activeIndex = tabData.index - 1; + let activePageData = tabData.entries[activeIndex] || {}; + let uri = activePageData.url || null; + if (uri && !loadArguments) { + webNavigation.setCurrentURI(Utils.makeURI(uri)); + } + + SessionHistory.restore(this.docShell, tabData); + + // Add a listener to watch for reloads. + let listener = new HistoryListener(this.docShell, () => { + // On reload, restore tab contents. + this.restoreTabContent(null, false, callbacks.onLoadFinished); + }); + + webNavigation.sessionHistory.addSHistoryListener(listener); + this._historyListener = listener; + + // Make sure to reset the capabilities and attributes in case this tab gets + // reused. + let disallow = new Set(tabData.disallow && tabData.disallow.split(",")); + DocShellCapabilities.restore(this.docShell, disallow); + + if (tabData.storage && this.docShell instanceof Ci.nsIDocShell) { + SessionStorage.restore(this.docShell, tabData.storage); + delete tabData.storage; + } + + // Add a progress listener to correctly handle browser.loadURI() + // calls from foreign code. + this._progressListener = new ProgressListener(this.docShell, { + onStartRequest: () => { + // Some code called browser.loadURI() on a pending tab. It's safe to + // assume we don't care about restoring scroll or form data. + this._tabData = null; + + // Listen for the tab to finish loading. + this.restoreTabContentStarted(callbacks.onLoadFinished); + + // Notify the parent. + callbacks.onLoadStarted(); + } + }); + }, + + /** + * Start loading the current page. When the data has finished loading from the + * network, finishCallback is called. Returns true if the load was successful. + */ + restoreTabContent: function (loadArguments, isRemotenessUpdate, finishCallback) { + let tabData = this._tabData; + this._tabData = null; + + let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + + // Listen for the tab to finish loading. + this.restoreTabContentStarted(finishCallback); + + // Reset the current URI to about:blank. We changed it above for + // switch-to-tab, but now it must go back to the correct value before the + // load happens. Don't bother doing this if we're restoring immediately + // due to a process switch. + if (!isRemotenessUpdate) { + webNavigation.setCurrentURI(Utils.makeURI("about:blank")); + } + + try { + if (loadArguments) { + // A load has been redirected to a new process so get history into the + // same state it was before the load started then trigger the load. + let referrer = loadArguments.referrer ? + Utils.makeURI(loadArguments.referrer) : null; + let referrerPolicy = ('referrerPolicy' in loadArguments + ? loadArguments.referrerPolicy + : Ci.nsIHttpChannel.REFERRER_POLICY_DEFAULT); + let postData = loadArguments.postData ? + Utils.makeInputStream(loadArguments.postData) : null; + let triggeringPrincipal = loadArguments.triggeringPrincipal + ? Utils.deserializePrincipal(loadArguments.triggeringPrincipal) + : null; + + if (loadArguments.userContextId) { + webNavigation.setOriginAttributesBeforeLoading({ userContextId: loadArguments.userContextId }); + } + + webNavigation.loadURIWithOptions(loadArguments.uri, loadArguments.flags, + referrer, referrerPolicy, postData, + null, null, triggeringPrincipal); + } else if (tabData.userTypedValue && tabData.userTypedClear) { + // If the user typed a URL into the URL bar and hit enter right before + // we crashed, we want to start loading that page again. A non-zero + // userTypedClear value means that the load had started. + // Load userTypedValue and fix up the URL if it's partial/broken. + webNavigation.loadURI(tabData.userTypedValue, + Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP, + null, null, null); + } else if (tabData.entries.length) { + // Stash away the data we need for restoreDocument. + let activeIndex = tabData.index - 1; + this._restoringDocument = {entry: tabData.entries[activeIndex] || {}, + formdata: tabData.formdata || {}, + pageStyle: tabData.pageStyle || {}, + scrollPositions: tabData.scroll || {}}; + + // In order to work around certain issues in session history, we need to + // force session history to update its internal index and call reload + // instead of gotoIndex. See bug 597315. + history.reloadCurrentEntry(); + } else { + // If there's nothing to restore, we should still blank the page. + webNavigation.loadURI("about:blank", + Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY, + null, null, null); + } + + return true; + } catch (ex if ex instanceof Ci.nsIException) { + // Ignore page load errors, but return false to signal that the load never + // happened. + return false; + } + }, + + /** + * To be called after restoreHistory(). Removes all listeners needed for + * pending tabs and makes sure to notify when the tab finished loading. + */ + restoreTabContentStarted(finishCallback) { + // The reload listener is no longer needed. + this._historyListener.uninstall(); + this._historyListener = null; + + // Remove the old progress listener. + this._progressListener.uninstall(); + + // We're about to start a load. This listener will be called when the load + // has finished getting everything from the network. + this._progressListener = new ProgressListener(this.docShell, { + onStopRequest: () => { + // Call resetRestore() to reset the state back to normal. The data + // needed for restoreDocument() (which hasn't happened yet) will + // remain in _restoringDocument. + this.resetRestore(); + + finishCallback(); + } + }); + }, + + /** + * Finish restoring the tab by filling in form data and setting the scroll + * position. The restore is complete when this function exits. It should be + * called when the "load" event fires for the restoring tab. + */ + restoreDocument: function () { + if (!this._restoringDocument) { + return; + } + let {entry, pageStyle, formdata, scrollPositions} = this._restoringDocument; + this._restoringDocument = null; + + let window = this.docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + + PageStyle.restoreTree(this.docShell, pageStyle); + FormData.restoreTree(window, formdata); + ScrollPosition.restoreTree(window, scrollPositions); + }, + + /** + * Cancel an ongoing restore. This function can be called any time between + * restoreHistory and restoreDocument. + * + * This function is called externally (if a restore is canceled) and + * internally (when the loads for a restore have finished). In the latter + * case, it's called before restoreDocument, so it cannot clear + * _restoringDocument. + */ + resetRestore: function () { + this._tabData = null; + + if (this._historyListener) { + this._historyListener.uninstall(); + } + this._historyListener = null; + + if (this._progressListener) { + this._progressListener.uninstall(); + } + this._progressListener = null; + } +}; + +/* + * This listener detects when a page being restored is reloaded. It triggers a + * callback and cancels the reload. The callback will send a message to + * SessionStore.jsm so that it can restore the content immediately. + */ +function HistoryListener(docShell, callback) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + webNavigation.sessionHistory.addSHistoryListener(this); + + this.webNavigation = webNavigation; + this.callback = callback; +} +HistoryListener.prototype = { + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsISHistoryListener, + Ci.nsISupportsWeakReference + ]), + + uninstall: function () { + let shistory = this.webNavigation.sessionHistory; + if (shistory) { + shistory.removeSHistoryListener(this); + } + }, + + OnHistoryGoBack: function(backURI) { return true; }, + OnHistoryGoForward: function(forwardURI) { return true; }, + OnHistoryGotoIndex: function(index, gotoURI) { return true; }, + OnHistoryPurge: function(numEntries) { return true; }, + OnHistoryReplaceEntry: function(index) {}, + + // This will be called for a pending tab when loadURI(uri) is called where + // the given |uri| only differs in the fragment. + OnHistoryNewEntry(newURI) { + let currentURI = this.webNavigation.currentURI; + + // Ignore new SHistory entries with the same URI as those do not indicate + // a navigation inside a document by changing the #hash part of the URL. + // We usually hit this when purging session history for browsers. + if (currentURI && (currentURI.spec == newURI.spec)) { + return; + } + + // Reset the tab's URL to what it's actually showing. Without this loadURI() + // would use the current document and change the displayed URL only. + this.webNavigation.setCurrentURI(Utils.makeURI("about:blank")); + + // Kick off a new load so that we navigate away from about:blank to the + // new URL that was passed to loadURI(). The new load will cause a + // STATE_START notification to be sent and the ProgressListener will then + // notify the parent and do the rest. + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP; + this.webNavigation.loadURI(newURI.spec, flags, null, null, null); + }, + + OnHistoryReload(reloadURI, reloadFlags) { + this.callback(); + + // Cancel the load. + return false; + }, +} + +/** + * This class informs SessionStore.jsm whenever the network requests for a + * restoring page have completely finished. We only restore three tabs + * simultaneously, so this is the signal for SessionStore.jsm to kick off + * another restore (if there are more to do). + * + * The progress listener is also used to be notified when a load not initiated + * by sessionstore starts. Pending tabs will then need to be marked as no + * longer pending. + */ +function ProgressListener(docShell, callbacks) { + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW); + + this.webProgress = webProgress; + this.callbacks = callbacks; +} + +ProgressListener.prototype = { + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference + ]), + + uninstall: function() { + this.webProgress.removeProgressListener(this); + }, + + onStateChange: function(webProgress, request, stateFlags, status) { + let {STATE_IS_WINDOW, STATE_STOP, STATE_START} = Ci.nsIWebProgressListener; + if (!webProgress.isTopLevel || !(stateFlags & STATE_IS_WINDOW)) { + return; + } + + if (stateFlags & STATE_START && this.callbacks.onStartRequest) { + this.callbacks.onStartRequest(); + } + + if (stateFlags & STATE_STOP && this.callbacks.onStopRequest) { + this.callbacks.onStopRequest(); + } + }, + + onLocationChange: function() {}, + onProgressChange: function() {}, + onStatusChange: function() {}, + onSecurityChange: function() {}, +}; diff --git a/application/basilisk/components/sessionstore/DocShellCapabilities.jsm b/application/basilisk/components/sessionstore/DocShellCapabilities.jsm new file mode 100644 index 000000000..098aae86f --- /dev/null +++ b/application/basilisk/components/sessionstore/DocShellCapabilities.jsm @@ -0,0 +1,50 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["DocShellCapabilities"]; + +/** + * The external API exported by this module. + */ +this.DocShellCapabilities = Object.freeze({ + collect: function (docShell) { + return DocShellCapabilitiesInternal.collect(docShell); + }, + + restore: function (docShell, disallow) { + return DocShellCapabilitiesInternal.restore(docShell, disallow); + }, +}); + +/** + * Internal functionality to save and restore the docShell.allow* properties. + */ +var DocShellCapabilitiesInternal = { + // List of docShell capabilities to (re)store. These are automatically + // retrieved from a given docShell if not already collected before. + // This is made so they're automatically in sync with all nsIDocShell.allow* + // properties. + caps: null, + + allCapabilities: function (docShell) { + if (!this.caps) { + let keys = Object.keys(docShell); + this.caps = keys.filter(k => k.startsWith("allow")).map(k => k.slice(5)); + } + return this.caps; + }, + + collect: function (docShell) { + let caps = this.allCapabilities(docShell); + return caps.filter(cap => !docShell["allow" + cap]); + }, + + restore: function (docShell, disallow) { + let caps = this.allCapabilities(docShell); + for (let cap of caps) + docShell["allow" + cap] = !disallow.has(cap); + }, +}; diff --git a/application/basilisk/components/sessionstore/FrameTree.jsm b/application/basilisk/components/sessionstore/FrameTree.jsm new file mode 100644 index 000000000..e8ed12a8f --- /dev/null +++ b/application/basilisk/components/sessionstore/FrameTree.jsm @@ -0,0 +1,254 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["FrameTree"]; + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +const EXPORTED_METHODS = ["addObserver", "contains", "map", "forEach"]; + +/** + * A FrameTree represents all frames that were reachable when the document + * was loaded. We use this information to ignore frames when collecting + * sessionstore data as we can't currently restore anything for frames that + * have been created dynamically after or at the load event. + * + * @constructor + */ +function FrameTree(chromeGlobal) { + let internal = new FrameTreeInternal(chromeGlobal); + let external = {}; + + for (let method of EXPORTED_METHODS) { + external[method] = internal[method].bind(internal); + } + + return Object.freeze(external); +} + +/** + * The internal frame tree API that the public one points to. + * + * @constructor + */ +function FrameTreeInternal(chromeGlobal) { + // A WeakMap that uses frames (DOMWindows) as keys and their initial indices + // in their parents' child lists as values. Suppose we have a root frame with + // three subframes i.e. a page with three iframes. The WeakMap would have + // four entries and look as follows: + // + // root -> 0 + // subframe1 -> 0 + // subframe2 -> 1 + // subframe3 -> 2 + // + // Should one of the subframes disappear we will stop collecting data for it + // as |this._frames.has(frame) == false|. All other subframes will maintain + // their initial indices to ensure we can restore frame data appropriately. + this._frames = new WeakMap(); + + // The Set of observers that will be notified when the frame changes. + this._observers = new Set(); + + // The chrome global we use to retrieve the current DOMWindow. + this._chromeGlobal = chromeGlobal; + + // Register a web progress listener to be notified about new page loads. + let docShell = chromeGlobal.docShell; + let ifreq = docShell.QueryInterface(Ci.nsIInterfaceRequestor); + let webProgress = ifreq.getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT); +} + +FrameTreeInternal.prototype = { + + // Returns the docShell's current global. + get content() { + return this._chromeGlobal.content; + }, + + /** + * Adds a given observer |obs| to the set of observers that will be notified + * when the frame tree is reset (when a new document starts loading) or + * recollected (when a document finishes loading). + * + * @param obs (object) + */ + addObserver: function (obs) { + this._observers.add(obs); + }, + + /** + * Notifies all observers that implement the given |method|. + * + * @param method (string) + */ + notifyObservers: function (method) { + for (let obs of this._observers) { + if (obs.hasOwnProperty(method)) { + obs[method](); + } + } + }, + + /** + * Checks whether a given |frame| is contained in the collected frame tree. + * If it is not, this indicates that we should not collect data for it. + * + * @param frame (nsIDOMWindow) + * @return bool + */ + contains: function (frame) { + return this._frames.has(frame); + }, + + /** + * Recursively applies the given function |cb| to the stored frame tree. Use + * this method to collect sessionstore data for all reachable frames stored + * in the frame tree. + * + * If a given function |cb| returns a value, it must be an object. It may + * however return "null" to indicate that there is no data to be stored for + * the given frame. + * + * The object returned by |cb| cannot have any property named "children" as + * that is used to store information about subframes in the tree returned + * by |map()| and might be overridden. + * + * @param cb (function) + * @return object + */ + map: function (cb) { + let frames = this._frames; + + function walk(frame) { + let obj = cb(frame) || {}; + + if (frames.has(frame)) { + let children = []; + + Array.forEach(frame.frames, subframe => { + // Don't collect any data if the frame is not contained in the + // initial frame tree. It's a dynamic frame added later. + if (!frames.has(subframe)) { + return; + } + + // Retrieve the frame's original position in its parent's child list. + let index = frames.get(subframe); + + // Recursively collect data for the current subframe. + let result = walk(subframe, cb); + if (result && Object.keys(result).length) { + children[index] = result; + } + }); + + if (children.length) { + obj.children = children; + } + } + + return Object.keys(obj).length ? obj : null; + } + + return walk(this.content); + }, + + /** + * Applies the given function |cb| to all frames stored in the tree. Use this + * method if |map()| doesn't suit your needs and you want more control over + * how data is collected. + * + * @param cb (function) + * This callback receives the current frame as the only argument. + */ + forEach: function (cb) { + let frames = this._frames; + + function walk(frame) { + cb(frame); + + if (!frames.has(frame)) { + return; + } + + Array.forEach(frame.frames, subframe => { + if (frames.has(subframe)) { + cb(subframe); + } + }); + } + + walk(this.content); + }, + + /** + * Stores a given |frame| and its children in the frame tree. + * + * @param frame (nsIDOMWindow) + * @param index (int) + * The index in the given frame's parent's child list. + */ + collect: function (frame, index = 0) { + // Mark the given frame as contained in the frame tree. + this._frames.set(frame, index); + + // Mark the given frame's subframes as contained in the tree. + Array.forEach(frame.frames, this.collect, this); + }, + + /** + * @see nsIWebProgressListener.onStateChange + * + * We want to be notified about: + * - new documents that start loading to clear the current frame tree; + * - completed document loads to recollect reachable frames. + */ + onStateChange: function (webProgress, request, stateFlags, status) { + // Ignore state changes for subframes because we're only interested in the + // top-document starting or stopping its load. We thus only care about any + // changes to the root of the frame tree, not to any of its nodes/leafs. + if (!webProgress.isTopLevel || webProgress.DOMWindow != this.content) { + return; + } + + // onStateChange will be fired when loading the initial about:blank URI for + // a browser, which we don't actually care about. This is particularly for + // the case of unrestored background tabs, where the content has not yet + // been restored: we don't want to accidentally send any updates to the + // parent when the about:blank placeholder page has loaded. + if (!this._chromeGlobal.docShell.hasLoadedNonBlankURI) { + return; + } + + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + // Clear the list of frames until we can recollect it. + this._frames = new WeakMap(); + + // Notify observers that the frame tree has been reset. + this.notifyObservers("onFrameTreeReset"); + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + // The document and its resources have finished loading. + this.collect(webProgress.DOMWindow); + + // Notify observers that the frame tree has been reset. + this.notifyObservers("onFrameTreeCollected"); + } + }, + + // Unused nsIWebProgressListener methods. + onLocationChange: function () {}, + onProgressChange: function () {}, + onSecurityChange: function () {}, + onStatusChange: function () {}, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference]) +}; diff --git a/application/basilisk/components/sessionstore/GlobalState.jsm b/application/basilisk/components/sessionstore/GlobalState.jsm new file mode 100644 index 000000000..ac2d7c81b --- /dev/null +++ b/application/basilisk/components/sessionstore/GlobalState.jsm @@ -0,0 +1,84 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["GlobalState"]; + +const EXPORTED_METHODS = ["getState", "clear", "get", "set", "delete", "setFromState"]; +/** + * Module that contains global session data. + */ +function GlobalState() { + let internal = new GlobalStateInternal(); + let external = {}; + for (let method of EXPORTED_METHODS) { + external[method] = internal[method].bind(internal); + } + return Object.freeze(external); +} + +function GlobalStateInternal() { + // Storage for global state. + this.state = {}; +} + +GlobalStateInternal.prototype = { + /** + * Get all value from the global state. + */ + getState: function() { + return this.state; + }, + + /** + * Clear all currently stored global state. + */ + clear: function() { + this.state = {}; + }, + + /** + * Retrieve a value from the global state. + * + * @param aKey + * A key the value is stored under. + * @return The value stored at aKey, or an empty string if no value is set. + */ + get: function(aKey) { + return this.state[aKey] || ""; + }, + + /** + * Set a global value. + * + * @param aKey + * A key to store the value under. + */ + set: function(aKey, aStringValue) { + this.state[aKey] = aStringValue; + }, + + /** + * Delete a global value. + * + * @param aKey + * A key to delete the value for. + */ + delete: function(aKey) { + delete this.state[aKey]; + }, + + /** + * Set the current global state from a state object. Any previous global + * state will be removed, even if the new state does not contain a matching + * key. + * + * @param aState + * A state object to extract global state from to be set. + */ + setFromState: function (aState) { + this.state = (aState && aState.global) || {}; + } +}; diff --git a/application/basilisk/components/sessionstore/PageStyle.jsm b/application/basilisk/components/sessionstore/PageStyle.jsm new file mode 100644 index 000000000..0424ef6b1 --- /dev/null +++ b/application/basilisk/components/sessionstore/PageStyle.jsm @@ -0,0 +1,100 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["PageStyle"]; + +const Ci = Components.interfaces; + +/** + * The external API exported by this module. + */ +this.PageStyle = Object.freeze({ + collect: function (docShell, frameTree) { + return PageStyleInternal.collect(docShell, frameTree); + }, + + restoreTree: function (docShell, data) { + PageStyleInternal.restoreTree(docShell, data); + } +}); + +// Signifies that author style level is disabled for the page. +const NO_STYLE = "_nostyle"; + +var PageStyleInternal = { + /** + * Collects the selected style sheet sets for all reachable frames. + */ + collect: function (docShell, frameTree) { + let result = frameTree.map(({document: doc}) => { + let style; + + if (doc) { + // http://dev.w3.org/csswg/cssom/#persisting-the-selected-css-style-sheet-set + style = doc.selectedStyleSheetSet || doc.lastStyleSheetSet; + } + + return style ? {pageStyle: style} : null; + }); + + let markupDocumentViewer = + docShell.contentViewer; + + if (markupDocumentViewer.authorStyleDisabled) { + result = result || {}; + result.disabled = true; + } + + return result && Object.keys(result).length ? result : null; + }, + + /** + * Restores pageStyle data for the current frame hierarchy starting at the + * |docShell's| current DOMWindow using the given pageStyle |data|. + * + * Warning: If the current frame hierarchy doesn't match that of the given + * |data| object we will silently discard data for unreachable frames. We may + * as well assign page styles to the wrong frames if some were reordered or + * removed. + * + * @param docShell (nsIDocShell) + * @param data (object) + * { + * disabled: true, // when true, author styles will be disabled + * pageStyle: "Dusk", + * children: [ + * null, + * {pageStyle: "Mozilla", children: [ ... ]} + * ] + * } + */ + restoreTree: function (docShell, data) { + let disabled = data.disabled || false; + let markupDocumentViewer = + docShell.contentViewer; + markupDocumentViewer.authorStyleDisabled = disabled; + + function restoreFrame(root, data) { + if (data.hasOwnProperty("pageStyle")) { + root.document.selectedStyleSheetSet = data.pageStyle; + } + + if (!data.hasOwnProperty("children")) { + return; + } + + let frames = root.frames; + data.children.forEach((child, index) => { + if (child && index < frames.length) { + restoreFrame(frames[index], child); + } + }); + } + + let ifreq = docShell.QueryInterface(Ci.nsIInterfaceRequestor); + restoreFrame(ifreq.getInterface(Ci.nsIDOMWindow), data); + } +}; diff --git a/application/basilisk/components/sessionstore/PrivacyFilter.jsm b/application/basilisk/components/sessionstore/PrivacyFilter.jsm new file mode 100644 index 000000000..88713b402 --- /dev/null +++ b/application/basilisk/components/sessionstore/PrivacyFilter.jsm @@ -0,0 +1,135 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["PrivacyFilter"]; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyLevel", + "resource:///modules/sessionstore/PrivacyLevel.jsm"); + +/** + * A module that provides methods to filter various kinds of data collected + * from a tab by the current privacy level as set by the user. + */ +this.PrivacyFilter = Object.freeze({ + /** + * Filters the given (serialized) session storage |data| according to the + * current privacy level and returns a new object containing only data that + * we're allowed to store. + * + * @param data The session storage data as collected from a tab. + * @return object + */ + filterSessionStorageData: function (data) { + let retval = {}; + + for (let host of Object.keys(data)) { + if (PrivacyLevel.check(host)) { + retval[host] = data[host]; + } + } + + return Object.keys(retval).length ? retval : null; + }, + + /** + * Filters the given (serialized) form |data| according to the current + * privacy level and returns a new object containing only data that we're + * allowed to store. + * + * @param data The form data as collected from a tab. + * @return object + */ + filterFormData: function (data) { + // If the given form data object has an associated URL that we are not + // allowed to store data for, bail out. We explicitly discard data for any + // children as well even if storing data for those frames would be allowed. + if (data.url && !PrivacyLevel.check(data.url)) { + return; + } + + let retval = {}; + + for (let key of Object.keys(data)) { + if (key === "children") { + let recurse = child => this.filterFormData(child); + let children = data.children.map(recurse).filter(child => child); + + if (children.length) { + retval.children = children; + } + // Only copy keys other than "children" if we have a valid URL in + // data.url and we thus passed the privacy level check. + } else if (data.url) { + retval[key] = data[key]; + } + } + + return Object.keys(retval).length ? retval : null; + }, + + /** + * Removes any private windows and tabs from a given browser state object. + * + * @param browserState (object) + * The browser state for which we remove any private windows and tabs. + * The given object will be modified. + */ + filterPrivateWindowsAndTabs: function (browserState) { + // Remove private opened windows. + for (let i = browserState.windows.length - 1; i >= 0; i--) { + let win = browserState.windows[i]; + + if (win.isPrivate) { + browserState.windows.splice(i, 1); + + if (browserState.selectedWindow >= i) { + browserState.selectedWindow--; + } + } else { + // Remove private tabs from all open non-private windows. + this.filterPrivateTabs(win); + } + } + + // Remove private closed windows. + browserState._closedWindows = + browserState._closedWindows.filter(win => !win.isPrivate); + + // Remove private tabs from all remaining closed windows. + browserState._closedWindows.forEach(win => this.filterPrivateTabs(win)); + }, + + /** + * Removes open private tabs from a given window state object. + * + * @param winState (object) + * The window state for which we remove any private tabs. + * The given object will be modified. + */ + filterPrivateTabs: function (winState) { + // Remove open private tabs. + for (let i = winState.tabs.length - 1; i >= 0 ; i--) { + let tab = winState.tabs[i]; + + if (tab.isPrivate) { + winState.tabs.splice(i, 1); + + if (winState.selected >= i) { + winState.selected--; + } + } + } + + // Note that closed private tabs are only stored for private windows. + // There is no need to call this function for private windows as the + // whole window state should just be discarded so we explicitly don't + // try to remove closed private tabs as an optimization. + } +}); diff --git a/application/basilisk/components/sessionstore/PrivacyLevel.jsm b/application/basilisk/components/sessionstore/PrivacyLevel.jsm new file mode 100644 index 000000000..135f1f959 --- /dev/null +++ b/application/basilisk/components/sessionstore/PrivacyLevel.jsm @@ -0,0 +1,64 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["PrivacyLevel"]; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/Services.jsm"); + +const PREF = "browser.sessionstore.privacy_level"; + +// The following constants represent the different possible privacy levels that +// can be set by the user and that we need to consider when collecting text +// data, and cookies. +// +// Collect data from all sites (http and https). +const PRIVACY_NONE = 0; +// Collect data from unencrypted sites (http), only. +const PRIVACY_ENCRYPTED = 1; +// Collect no data. +const PRIVACY_FULL = 2; + +/** + * The external API as exposed by this module. + */ +var PrivacyLevel = Object.freeze({ + /** + * Returns whether the current privacy level allows saving data for the given + * |url|. + * + * @param url The URL we want to save data for. + * @return bool + */ + check: function (url) { + return PrivacyLevel.canSave({ isHttps: url.startsWith("https:") }); + }, + + /** + * Checks whether we're allowed to save data for a specific site. + * + * @param {isHttps: boolean} + * An object that must have one property: 'isHttps'. + * 'isHttps' tells whether the site us secure communication (HTTPS). + * @return {bool} Whether we can save data for the specified site. + */ + canSave: function ({isHttps}) { + let level = Services.prefs.getIntPref(PREF); + + // Never save any data when full privacy is requested. + if (level == PRIVACY_FULL) { + return false; + } + + // Don't save data for encrypted sites when requested. + if (isHttps && level == PRIVACY_ENCRYPTED) { + return false; + } + + return true; + } +}); diff --git a/application/basilisk/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.jsm b/application/basilisk/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.jsm new file mode 100644 index 000000000..ac5731160 --- /dev/null +++ b/application/basilisk/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.jsm @@ -0,0 +1,214 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +this.EXPORTED_SYMBOLS = ["RecentlyClosedTabsAndWindowsMenuUtils"]; + +const kNSXUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +var Ci = Components.interfaces; +var Cc = Components.classes; +var Cr = Components.results; +var Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/PlacesUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", + "resource://gre/modules/PluralForm.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionStore", + "resource:///modules/sessionstore/SessionStore.jsm"); + +var navigatorBundle = Services.strings.createBundle("chrome://browser/locale/browser.properties"); + +this.RecentlyClosedTabsAndWindowsMenuUtils = { + + /** + * Builds up a document fragment of UI items for the recently closed tabs. + * @param aWindow + * The window that the tabs were closed in. + * @param aTagName + * The tag name that will be used when creating the UI items. + * @param aPrefixRestoreAll (defaults to false) + * Whether the 'restore all tabs' item is suffixed or prefixed to the list. + * If suffixed (the default) a separator will be inserted before it. + * @param aRestoreAllLabel (defaults to "menuRestoreAllTabs.label") + * Which localizable string to use for the 'restore all tabs' item. + * @returns A document fragment with UI items for each recently closed tab. + */ + getTabsFragment: function(aWindow, aTagName, aPrefixRestoreAll=false, + aRestoreAllLabel="menuRestoreAllTabs.label") { + let doc = aWindow.document; + let fragment = doc.createDocumentFragment(); + if (SessionStore.getClosedTabCount(aWindow) != 0) { + let closedTabs = SessionStore.getClosedTabData(aWindow, false); + for (let i = 0; i < closedTabs.length; i++) { + createEntry(aTagName, false, i, closedTabs[i], doc, + closedTabs[i].title, fragment); + } + + createRestoreAllEntry(doc, fragment, aPrefixRestoreAll, false, + aRestoreAllLabel, closedTabs.length, aTagName) + } + return fragment; + }, + + /** + * Builds up a document fragment of UI items for the recently closed windows. + * @param aWindow + * A window that can be used to create the elements and document fragment. + * @param aTagName + * The tag name that will be used when creating the UI items. + * @param aPrefixRestoreAll (defaults to false) + * Whether the 'restore all windows' item is suffixed or prefixed to the list. + * If suffixed (the default) a separator will be inserted before it. + * @param aRestoreAllLabel (defaults to "menuRestoreAllWindows.label") + * Which localizable string to use for the 'restore all windows' item. + * @returns A document fragment with UI items for each recently closed window. + */ + getWindowsFragment: function(aWindow, aTagName, aPrefixRestoreAll=false, + aRestoreAllLabel="menuRestoreAllWindows.label") { + let closedWindowData = SessionStore.getClosedWindowData(false); + let doc = aWindow.document; + let fragment = doc.createDocumentFragment(); + if (closedWindowData.length != 0) { + let menuLabelString = navigatorBundle.GetStringFromName("menuUndoCloseWindowLabel"); + let menuLabelStringSingleTab = + navigatorBundle.GetStringFromName("menuUndoCloseWindowSingleTabLabel"); + + for (let i = 0; i < closedWindowData.length; i++) { + let undoItem = closedWindowData[i]; + let otherTabsCount = undoItem.tabs.length - 1; + let label = (otherTabsCount == 0) ? menuLabelStringSingleTab + : PluralForm.get(otherTabsCount, menuLabelString); + let menuLabel = label.replace("#1", undoItem.title) + .replace("#2", otherTabsCount); + let selectedTab = undoItem.tabs[undoItem.selected - 1]; + + createEntry(aTagName, true, i, selectedTab, doc, menuLabel, + fragment); + } + + createRestoreAllEntry(doc, fragment, aPrefixRestoreAll, true, + aRestoreAllLabel, closedWindowData.length, + aTagName); + } + return fragment; + }, + + + /** + * Re-open a closed tab and put it to the end of the tab strip. + * Used for a middle click. + * @param aEvent + * The event when the user clicks the menu item + */ + _undoCloseMiddleClick: function(aEvent) { + if (aEvent.button != 1) + return; + + aEvent.view.undoCloseTab(aEvent.originalTarget.getAttribute("value")); + aEvent.view.gBrowser.moveTabToEnd(); + }, +}; + +function setImage(aItem, aElement) { + let iconURL = aItem.image; + // don't initiate a connection just to fetch a favicon (see bug 467828) + if (/^https?:/.test(iconURL)) + iconURL = "moz-anno:favicon:" + iconURL; + + aElement.setAttribute("image", iconURL); +} + +/** + * Create a UI entry for a recently closed tab or window. + * @param aTagName + * the tag name that will be used when creating the UI entry + * @param aIsWindowsFragment + * whether or not this entry will represent a closed window + * @param aIndex + * the index of the closed tab + * @param aClosedTab + * the closed tab + * @param aDocument + * a document that can be used to create the entry + * @param aMenuLabel + * the label the created entry will have + * @param aFragment + * the fragment the created entry will be in + */ +function createEntry(aTagName, aIsWindowsFragment, aIndex, aClosedTab, + aDocument, aMenuLabel, aFragment) { + let element = aDocument.createElementNS(kNSXUL, aTagName); + + element.setAttribute("label", aMenuLabel); + if (aClosedTab.image) { + setImage(aClosedTab, element); + } + if (!aIsWindowsFragment) { + element.setAttribute("value", aIndex); + } + + if (aTagName == "menuitem") { + element.setAttribute("class", "menuitem-iconic bookmark-item menuitem-with-favicon"); + } + + element.setAttribute("oncommand", "undoClose" + (aIsWindowsFragment ? "Window" : "Tab") + + "(" + aIndex + ");"); + + // Set the targetURI attribute so it will be shown in tooltip. + // SessionStore uses one-based indexes, so we need to normalize them. + let tabData; + tabData = aIsWindowsFragment ? aClosedTab + : aClosedTab.state; + let activeIndex = (tabData.index || tabData.entries.length) - 1; + if (activeIndex >= 0 && tabData.entries[activeIndex]) { + element.setAttribute("targetURI", tabData.entries[activeIndex].url); + } + + if (!aIsWindowsFragment) { + element.addEventListener("click", RecentlyClosedTabsAndWindowsMenuUtils._undoCloseMiddleClick, false); + } + if (aIndex == 0) { + element.setAttribute("key", "key_undoClose" + (aIsWindowsFragment? "Window" : "Tab")); + } + + aFragment.appendChild(element); +} + +/** + * Create an entry to restore all closed windows or tabs. + * @param aDocument + * a document that can be used to create the entry + * @param aFragment + * the fragment the created entry will be in + * @param aPrefixRestoreAll + * whether the 'restore all windows' item is suffixed or prefixed to the list + * If suffixed a separator will be inserted before it. + * @param aIsWindowsFragment + * whether or not this entry will represent a closed window + * @param aRestoreAllLabel + * which localizable string to use for the entry + * @param aEntryCount + * the number of elements to be restored by this entry + * @param aTagName + * the tag name that will be used when creating the UI entry + */ +function createRestoreAllEntry(aDocument, aFragment, aPrefixRestoreAll, + aIsWindowsFragment, aRestoreAllLabel, + aEntryCount, aTagName) { + let restoreAllElements = aDocument.createElementNS(kNSXUL, aTagName); + restoreAllElements.classList.add("restoreallitem"); + restoreAllElements.setAttribute("label", navigatorBundle.GetStringFromName(aRestoreAllLabel)); + restoreAllElements.setAttribute("oncommand", + "for (var i = 0; i < " + aEntryCount + "; i++) undoClose" + + (aIsWindowsFragment? "Window" : "Tab") + "();"); + if (aPrefixRestoreAll) { + aFragment.insertBefore(restoreAllElements, aFragment.firstChild); + } else { + aFragment.appendChild(aDocument.createElementNS(kNSXUL, "menuseparator")); + aFragment.appendChild(restoreAllElements); + } +}
\ No newline at end of file diff --git a/application/basilisk/components/sessionstore/RunState.jsm b/application/basilisk/components/sessionstore/RunState.jsm new file mode 100644 index 000000000..3cdf47718 --- /dev/null +++ b/application/basilisk/components/sessionstore/RunState.jsm @@ -0,0 +1,96 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["RunState"]; + +const STATE_STOPPED = 0; +const STATE_RUNNING = 1; +const STATE_QUITTING = 2; +const STATE_CLOSING = 3; +const STATE_CLOSED = 4; + +// We're initially stopped. +var state = STATE_STOPPED; + +/** + * This module keeps track of SessionStore's current run state. We will + * always start out at STATE_STOPPED. After the session was read from disk and + * the initial browser window has loaded we switch to STATE_RUNNING. On the + * first notice that a browser shutdown was granted we switch to STATE_QUITTING. + */ +this.RunState = Object.freeze({ + // If we're stopped then SessionStore hasn't been initialized yet. As soon + // as the session is read from disk and the initial browser window has loaded + // the run state will change to STATE_RUNNING. + get isStopped() { + return state == STATE_STOPPED; + }, + + // STATE_RUNNING is our default mode of operation that we'll spend most of + // the time in. After the session was read from disk and the first browser + // window has loaded we remain running until the browser quits. + get isRunning() { + return state == STATE_RUNNING; + }, + + // We will enter STATE_QUITTING as soon as we receive notice that a browser + // shutdown was granted. SessionStore will use this information to prevent + // us from collecting partial information while the browser is shutting down + // as well as to allow a last single write to disk and block all writes after + // that. + get isQuitting() { + return state >= STATE_QUITTING; + }, + + // We will enter STATE_CLOSING as soon as SessionStore is uninitialized. + // The SessionFile module will know that a last write will happen in this + // state and it can do some necessary cleanup. + get isClosing() { + return state == STATE_CLOSING; + }, + + // We will enter STATE_CLOSED as soon as SessionFile has written to disk for + // the last time before shutdown and will not accept any further writes. + get isClosed() { + return state == STATE_CLOSED; + }, + + // Switch the run state to STATE_RUNNING. This must be called after the + // session was read from, the initial browser window has loaded and we're + // now ready to restore session data. + setRunning() { + if (this.isStopped) { + state = STATE_RUNNING; + } + }, + + // Switch the run state to STATE_CLOSING. This must be called *before* the + // last SessionFile.write() call so that SessionFile knows we're closing and + // can do some last cleanups and write a proper sessionstore.js file. + setClosing() { + if (this.isQuitting) { + state = STATE_CLOSING; + } + }, + + // Switch the run state to STATE_CLOSED. This must be called by SessionFile + // after the last write to disk was accepted and no further writes will be + // allowed. Any writes after this stage will cause exceptions. + setClosed() { + if (this.isClosing) { + state = STATE_CLOSED; + } + }, + + // Switch the run state to STATE_QUITTING. This should be called once we're + // certain that the browser is going away and before we start collecting the + // final window states to save in the session file. + setQuitting() { + if (this.isRunning) { + state = STATE_QUITTING; + } + }, +}); diff --git a/application/basilisk/components/sessionstore/SessionCookies.jsm b/application/basilisk/components/sessionstore/SessionCookies.jsm new file mode 100644 index 000000000..b99ab927b --- /dev/null +++ b/application/basilisk/components/sessionstore/SessionCookies.jsm @@ -0,0 +1,476 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["SessionCookies"]; + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://gre/modules/sessionstore/Utils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyLevel", + "resource:///modules/sessionstore/PrivacyLevel.jsm"); + +// MAX_EXPIRY should be 2^63-1, but JavaScript can't handle that precision. +const MAX_EXPIRY = Math.pow(2, 62); + +/** + * The external API implemented by the SessionCookies module. + */ +this.SessionCookies = Object.freeze({ + update: function (windows) { + SessionCookiesInternal.update(windows); + }, + + getHostsForWindow: function (window, checkPrivacy = false) { + return SessionCookiesInternal.getHostsForWindow(window, checkPrivacy); + }, + + restore(cookies) { + SessionCookiesInternal.restore(cookies); + } +}); + +/** + * The internal API. + */ +var SessionCookiesInternal = { + /** + * Stores whether we're initialized, yet. + */ + _initialized: false, + + /** + * Retrieve the list of all hosts contained in the given windows' session + * history entries (per window) and collect the associated cookies for those + * hosts, if any. The given state object is being modified. + * + * @param windows + * Array of window state objects. + * [{ tabs: [...], cookies: [...] }, ...] + */ + update: function (windows) { + this._ensureInitialized(); + + for (let window of windows) { + let cookies = []; + + // Collect all hosts for the current window. + let hosts = this.getHostsForWindow(window, true); + + for (let host of Object.keys(hosts)) { + let isPinned = hosts[host]; + + for (let cookie of CookieStore.getCookiesForHost(host)) { + // _getCookiesForHost() will only return hosts with the right privacy + // rules, so there is no need to do anything special with this call + // to PrivacyLevel.canSave(). + if (PrivacyLevel.canSave({isHttps: cookie.secure, isPinned: isPinned})) { + cookies.push(cookie); + } + } + } + + // Don't include/keep empty cookie sections. + if (cookies.length) { + window.cookies = cookies; + } else if ("cookies" in window) { + delete window.cookies; + } + } + }, + + /** + * Returns a map of all hosts for a given window that we might want to + * collect cookies for. + * + * @param window + * A window state object containing tabs with history entries. + * @param checkPrivacy (bool) + * Whether to check the privacy level for each host. + * @return {object} A map of hosts for a given window state object. The keys + * will be hosts, the values are boolean and determine + * whether we will use the deferred privacy level when + * checking how much data to save on quitting. + */ + getHostsForWindow: function (window, checkPrivacy = false) { + let hosts = {}; + + for (let tab of window.tabs) { + for (let entry of tab.entries) { + this._extractHostsFromEntry(entry, hosts, checkPrivacy, tab.pinned); + } + } + + return hosts; + }, + + /** + * Restores a given list of session cookies. + */ + restore(cookies) { + + for (let cookie of cookies) { + let expiry = "expiry" in cookie ? cookie.expiry : MAX_EXPIRY; + let cookieObj = { + host: cookie.host, + path: cookie.path || "", + name: cookie.name || "" + }; + if (!Services.cookies.cookieExists(cookieObj, cookie.originAttributes || {})) { + Services.cookies.add(cookie.host, cookie.path || "", cookie.name || "", + cookie.value, !!cookie.secure, !!cookie.httponly, + /* isSession = */ true, expiry, cookie.originAttributes || {}); + } + } + }, + + /** + * Handles observers notifications that are sent whenever cookies are added, + * changed, or removed. Ensures that the storage is updated accordingly. + */ + observe: function (subject, topic, data) { + switch (data) { + case "added": + case "changed": + this._updateCookie(subject); + break; + case "deleted": + this._removeCookie(subject); + break; + case "cleared": + CookieStore.clear(); + break; + case "batch-deleted": + this._removeCookies(subject); + break; + case "reload": + CookieStore.clear(); + this._reloadCookies(); + break; + default: + throw new Error("Unhandled cookie-changed notification."); + } + }, + + /** + * If called for the first time in a session, iterates all cookies in the + * cookies service and puts them into the store if they're session cookies. + */ + _ensureInitialized: function () { + if (!this._initialized) { + this._reloadCookies(); + this._initialized = true; + Services.obs.addObserver(this, "cookie-changed", false); + } + }, + + /** + * Fill a given map with hosts found in the given entry's session history and + * any child entries. + * + * @param entry + * the history entry, serialized + * @param hosts + * the hash that will be used to store hosts eg, { hostname: true } + * @param checkPrivacy + * should we check the privacy level for https + * @param isPinned + * is the entry we're evaluating for a pinned tab; used only if + * checkPrivacy + */ + _extractHostsFromEntry: function (entry, hosts, checkPrivacy, isPinned) { + let host = entry._host; + let scheme = entry._scheme; + + // If host & scheme aren't defined, then we are likely here in the startup + // process via _splitCookiesFromWindow. In that case, we'll turn entry.url + // into an nsIURI and get host/scheme from that. This will throw for about: + // urls in which case we don't need to do anything. + if (!host && !scheme) { + try { + let uri = Utils.makeURI(entry.url); + host = uri.host; + scheme = uri.scheme; + this._extractHostsFromHostScheme(host, scheme, hosts, checkPrivacy, isPinned); + } + catch (ex) { } + } + + if (entry.children) { + for (let child of entry.children) { + this._extractHostsFromEntry(child, hosts, checkPrivacy, isPinned); + } + } + }, + + /** + * Add a given host to a given map of hosts if the privacy level allows + * saving cookie data for it. + * + * @param host + * the host of a uri (usually via nsIURI.host) + * @param scheme + * the scheme of a uri (usually via nsIURI.scheme) + * @param hosts + * the hash that will be used to store hosts eg, { hostname: true } + * @param checkPrivacy + * should we check the privacy level for https + * @param isPinned + * is the entry we're evaluating for a pinned tab; used only if + * checkPrivacy + */ + _extractHostsFromHostScheme: + function (host, scheme, hosts, checkPrivacy, isPinned) { + // host and scheme may not be set (for about: urls for example), in which + // case testing scheme will be sufficient. + if (/https?/.test(scheme) && !hosts[host] && + (!checkPrivacy || + PrivacyLevel.canSave({isHttps: scheme == "https", isPinned: isPinned}))) { + // By setting this to true or false, we can determine when looking at + // the host in update() if we should check for privacy. + hosts[host] = isPinned; + } else if (scheme == "file") { + hosts[host] = true; + } + }, + + /** + * Updates or adds a given cookie to the store. + */ + _updateCookie: function (cookie) { + cookie.QueryInterface(Ci.nsICookie2); + + if (cookie.isSession) { + CookieStore.set(cookie); + } else { + CookieStore.delete(cookie); + } + }, + + /** + * Removes a given cookie from the store. + */ + _removeCookie: function (cookie) { + cookie.QueryInterface(Ci.nsICookie2); + + if (cookie.isSession) { + CookieStore.delete(cookie); + } + }, + + /** + * Removes a given list of cookies from the store. + */ + _removeCookies: function (cookies) { + for (let i = 0; i < cookies.length; i++) { + this._removeCookie(cookies.queryElementAt(i, Ci.nsICookie2)); + } + }, + + /** + * Iterates all cookies in the cookies service and puts them into the store + * if they're session cookies. + */ + _reloadCookies: function () { + let iter = Services.cookies.enumerator; + while (iter.hasMoreElements()) { + this._updateCookie(iter.getNext()); + } + } +}; + +/** + * Generates all possible subdomains for a given host and prepends a leading + * dot to all variants. + * + * See http://tools.ietf.org/html/rfc6265#section-5.1.3 + * http://en.wikipedia.org/wiki/HTTP_cookie#Domain_and_Path + * + * All cookies belonging to a web page will be internally represented by a + * nsICookie object. nsICookie.host will be the request host if no domain + * parameter was given when setting the cookie. If a specific domain was given + * then nsICookie.host will contain that specific domain and prepend a leading + * dot to it. + * + * We thus generate all possible subdomains for a given domain and prepend a + * leading dot to them as that is the value that was used as the map key when + * the cookie was set. + */ +function* getPossibleSubdomainVariants(host) { + // Try given domain with a leading dot (.www.example.com). + yield "." + host; + + // Stop if there are only two parts left (e.g. example.com was given). + let parts = host.split("."); + if (parts.length < 3) { + return; + } + + // Remove the first subdomain (www.example.com -> example.com). + let rest = parts.slice(1).join("."); + + // Try possible parent subdomains. + yield* getPossibleSubdomainVariants(rest); +} + +/** + * The internal cookie storage that keeps track of every active session cookie. + * These are stored using maps per host, path, and cookie name. + */ +var CookieStore = { + /** + * The internal structure holding all known cookies. + * + * Host => + * Path => + * Name => {path: "/", name: "sessionid", secure: true} + * + * Maps are used for storage but the data structure is equivalent to this: + * + * this._hosts = { + * "www.mozilla.org": { + * "/": { + * "username": {name: "username", value: "my_name_is", etc...}, + * "sessionid": {name: "sessionid", value: "1fdb3a", etc...} + * } + * }, + * "tbpl.mozilla.org": { + * "/path": { + * "cookiename": {name: "cookiename", value: "value", etc...} + * } + * }, + * ".example.com": { + * "/path": { + * "cookiename": {name: "cookiename", value: "value", etc...} + * } + * } + * }; + */ + _hosts: new Map(), + + /** + * Returns the list of stored session cookies for a given host. + * + * @param host + * A string containing the host name we want to get cookies for. + */ + getCookiesForHost: function (host) { + let cookies = []; + + let appendCookiesForHost = host => { + if (!this._hosts.has(host)) { + return; + } + + for (let pathToNamesMap of this._hosts.get(host).values()) { + for (let nameToCookiesMap of pathToNamesMap.values()) { + cookies.push(...nameToCookiesMap.values()); + } + } + } + + // Try to find cookies for the given host, e.g. <www.example.com>. + // The full hostname will be in the map if the Set-Cookie header did not + // have a domain= attribute, i.e. the cookie will only be stored for the + // request domain. Also, try to find cookies for subdomains, e.g. + // <.example.com>. We will find those variants with a leading dot in the + // map if the Set-Cookie header had a domain= attribute, i.e. the cookie + // will be stored for a parent domain and we send it for any subdomain. + for (let variant of [host, ...getPossibleSubdomainVariants(host)]) { + appendCookiesForHost(variant); + } + + return cookies; + }, + + /** + * Stores a given cookie. + * + * @param cookie + * The nsICookie2 object to add to the storage. + */ + set: function (cookie) { + let jscookie = {host: cookie.host, value: cookie.value}; + + // Only add properties with non-default values to save a few bytes. + if (cookie.path) { + jscookie.path = cookie.path; + } + + if (cookie.name) { + jscookie.name = cookie.name; + } + + if (cookie.isSecure) { + jscookie.secure = true; + } + + if (cookie.isHttpOnly) { + jscookie.httponly = true; + } + + if (cookie.expiry < MAX_EXPIRY) { + jscookie.expiry = cookie.expiry; + } + + if (cookie.originAttributes) { + jscookie.originAttributes = cookie.originAttributes; + } + + this._ensureMap(cookie).set(cookie.name, jscookie); + }, + + /** + * Removes a given cookie. + * + * @param cookie + * The nsICookie2 object to be removed from storage. + */ + delete: function (cookie) { + this._ensureMap(cookie).delete(cookie.name); + }, + + /** + * Removes all cookies. + */ + clear: function () { + this._hosts.clear(); + }, + + /** + * Creates all maps necessary to store a given cookie. + * + * @param cookie + * The nsICookie2 object to create maps for. + * + * @return The newly created Map instance mapping cookie names to + * internal jscookies, in the given path of the given host. + */ + _ensureMap: function (cookie) { + if (!this._hosts.has(cookie.host)) { + this._hosts.set(cookie.host, new Map()); + } + + let originAttributesMap = this._hosts.get(cookie.host); + // If cookie.originAttributes is null, originAttributes will be an empty string. + let originAttributes = ChromeUtils.originAttributesToSuffix(cookie.originAttributes); + if (!originAttributesMap.has(originAttributes)) { + originAttributesMap.set(originAttributes, new Map()); + } + + let pathToNamesMap = originAttributesMap.get(originAttributes); + + if (!pathToNamesMap.has(cookie.path)) { + pathToNamesMap.set(cookie.path, new Map()); + } + + return pathToNamesMap.get(cookie.path); + } +}; diff --git a/application/basilisk/components/sessionstore/SessionFile.jsm b/application/basilisk/components/sessionstore/SessionFile.jsm new file mode 100644 index 000000000..80c4e7790 --- /dev/null +++ b/application/basilisk/components/sessionstore/SessionFile.jsm @@ -0,0 +1,399 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["SessionFile"]; + +/** + * Implementation of all the disk I/O required by the session store. + * This is a private API, meant to be used only by the session store. + * It will change. Do not use it for any other purpose. + * + * Note that this module implicitly depends on one of two things: + * 1. either the asynchronous file I/O system enqueues its requests + * and never attempts to simultaneously execute two I/O requests on + * the files used by this module from two distinct threads; or + * 2. the clients of this API are well-behaved and do not place + * concurrent requests to the files used by this module. + * + * Otherwise, we could encounter bugs, especially under Windows, + * e.g. if a request attempts to write sessionstore.js while + * another attempts to copy that file. + * + * This implementation uses OS.File, which guarantees property 1. + */ + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/AsyncShutdown.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils", + "resource://gre/modules/PromiseUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "RunState", + "resource:///modules/sessionstore/RunState.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch", + "resource://gre/modules/TelemetryStopwatch.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "Telemetry", + "@mozilla.org/base/telemetry;1", "nsITelemetry"); +XPCOMUtils.defineLazyServiceGetter(this, "sessionStartup", + "@mozilla.org/browser/sessionstartup;1", "nsISessionStartup"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionWorker", + "resource:///modules/sessionstore/SessionWorker.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionStore", + "resource:///modules/sessionstore/SessionStore.jsm"); + +const PREF_UPGRADE_BACKUP = "browser.sessionstore.upgradeBackup.latestBuildID"; +const PREF_MAX_UPGRADE_BACKUPS = "browser.sessionstore.upgradeBackup.maxUpgradeBackups"; + +const PREF_MAX_SERIALIZE_BACK = "browser.sessionstore.max_serialize_back"; +const PREF_MAX_SERIALIZE_FWD = "browser.sessionstore.max_serialize_forward"; + +this.SessionFile = { + /** + * Read the contents of the session file, asynchronously. + */ + read: function () { + return SessionFileInternal.read(); + }, + /** + * Write the contents of the session file, asynchronously. + */ + write: function (aData) { + return SessionFileInternal.write(aData); + }, + /** + * Wipe the contents of the session file, asynchronously. + */ + wipe: function () { + return SessionFileInternal.wipe(); + }, + + /** + * Return the paths to the files used to store, backup, etc. + * the state of the file. + */ + get Paths() { + return SessionFileInternal.Paths; + } +}; + +Object.freeze(SessionFile); + +var Path = OS.Path; +var profileDir = OS.Constants.Path.profileDir; + +var SessionFileInternal = { + Paths: Object.freeze({ + // The path to the latest version of sessionstore written during a clean + // shutdown. After startup, it is renamed `cleanBackup`. + clean: Path.join(profileDir, "sessionstore.js"), + + // The path at which we store the previous version of `clean`. Updated + // whenever we successfully load from `clean`. + cleanBackup: Path.join(profileDir, "sessionstore-backups", "previous.js"), + + // The directory containing all sessionstore backups. + backups: Path.join(profileDir, "sessionstore-backups"), + + // The path to the latest version of the sessionstore written + // during runtime. Generally, this file contains more + // privacy-sensitive information than |clean|, and this file is + // therefore removed during clean shutdown. This file is designed to protect + // against crashes / sudden shutdown. + recovery: Path.join(profileDir, "sessionstore-backups", "recovery.js"), + + // The path to the previous version of the sessionstore written + // during runtime (e.g. 15 seconds before recovery). In case of a + // clean shutdown, this file is removed. Generally, this file + // contains more privacy-sensitive information than |clean|, and + // this file is therefore removed during clean shutdown. This + // file is designed to protect against crashes that are nasty + // enough to corrupt |recovery|. + recoveryBackup: Path.join(profileDir, "sessionstore-backups", "recovery.bak"), + + // The path to a backup created during an upgrade of Firefox. + // Having this backup protects the user essentially from bugs in + // Firefox or add-ons, especially for users of Nightly. This file + // does not contain any information more sensitive than |clean|. + upgradeBackupPrefix: Path.join(profileDir, "sessionstore-backups", "upgrade.js-"), + + // The path to the backup of the version of the session store used + // during the latest upgrade of Firefox. During load/recovery, + // this file should be used if both |path|, |backupPath| and + // |latestStartPath| are absent/incorrect. May be "" if no + // upgrade backup has ever been performed. This file does not + // contain any information more sensitive than |clean|. + get upgradeBackup() { + let latestBackupID = SessionFileInternal.latestUpgradeBackupID; + if (!latestBackupID) { + return ""; + } + return this.upgradeBackupPrefix + latestBackupID; + }, + + // The path to a backup created during an upgrade of Firefox. + // Having this backup protects the user essentially from bugs in + // Firefox, especially for users of Nightly. + get nextUpgradeBackup() { + return this.upgradeBackupPrefix + Services.appinfo.platformBuildID; + }, + + /** + * The order in which to search for a valid sessionstore file. + */ + get loadOrder() { + // If `clean` exists and has been written without corruption during + // the latest shutdown, we need to use it. + // + // Otherwise, `recovery` and `recoveryBackup` represent the most + // recent state of the session store. + // + // Finally, if nothing works, fall back to the last known state + // that can be loaded (`cleanBackup`) or, if available, to the + // backup performed during the latest upgrade. + let order = ["clean", + "recovery", + "recoveryBackup", + "cleanBackup"]; + if (SessionFileInternal.latestUpgradeBackupID) { + // We have an upgradeBackup + order.push("upgradeBackup"); + } + return order; + }, + }), + + // Number of attempted calls to `write`. + // Note that we may have _attempts > _successes + _failures, + // if attempts never complete. + // Used for error reporting. + _attempts: 0, + + // Number of successful calls to `write`. + // Used for error reporting. + _successes: 0, + + // Number of failed calls to `write`. + // Used for error reporting. + _failures: 0, + + // Resolved once initialization is complete. + // The promise never rejects. + _deferredInitialized: PromiseUtils.defer(), + + // `true` once we have started initialization, i.e. once something + // has been scheduled that will eventually resolve `_deferredInitialized`. + _initializationStarted: false, + + // The ID of the latest version of Gecko for which we have an upgrade backup + // or |undefined| if no upgrade backup was ever written. + get latestUpgradeBackupID() { + try { + return Services.prefs.getCharPref(PREF_UPGRADE_BACKUP); + } catch (ex) { + return undefined; + } + }, + + // Find the correct session file, read it and setup the worker. + read: Task.async(function* () { + this._initializationStarted = true; + + let result; + let noFilesFound = true; + // Attempt to load by order of priority from the various backups + for (let key of this.Paths.loadOrder) { + let corrupted = false; + let exists = true; + try { + let path = this.Paths[key]; + let startMs = Date.now(); + + let source = yield OS.File.read(path, { encoding: "utf-8" }); + let parsed = JSON.parse(source); + + if (!SessionStore.isFormatVersionCompatible(parsed.version || ["sessionrestore", 0] /*fallback for old versions*/)) { + // Skip sessionstore files that we don't understand. + Cu.reportError("Cannot extract data from Session Restore file " + path + ". Wrong format/version: " + JSON.stringify(parsed.version) + "."); + continue; + } + result = { + origin: key, + source: source, + parsed: parsed + }; + Telemetry.getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE"). + add(false); + Telemetry.getHistogramById("FX_SESSION_RESTORE_READ_FILE_MS"). + add(Date.now() - startMs); + break; + } catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) { + exists = false; + } catch (ex if ex instanceof OS.File.Error) { + // The file might be inaccessible due to wrong permissions + // or similar failures. We'll just count it as "corrupted". + console.error("Could not read session file ", ex, ex.stack); + corrupted = true; + } catch (ex if ex instanceof SyntaxError) { + console.error("Corrupt session file (invalid JSON found) ", ex, ex.stack); + // File is corrupted, try next file + corrupted = true; + } finally { + if (exists) { + noFilesFound = false; + Telemetry.getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE"). + add(corrupted); + } + } + } + + // All files are corrupted if files found but none could deliver a result. + let allCorrupt = !noFilesFound && !result; + Telemetry.getHistogramById("FX_SESSION_RESTORE_ALL_FILES_CORRUPT"). + add(allCorrupt); + + if (!result) { + // If everything fails, start with an empty session. + result = { + origin: "empty", + source: "", + parsed: null + }; + } + + result.noFilesFound = noFilesFound; + + // Initialize the worker (in the background) to let it handle backups and also + // as a workaround for bug 964531. + let promiseInitialized = SessionWorker.post("init", [result.origin, this.Paths, { + maxUpgradeBackups: Preferences.get(PREF_MAX_UPGRADE_BACKUPS, 3), + maxSerializeBack: Preferences.get(PREF_MAX_SERIALIZE_BACK, 10), + maxSerializeForward: Preferences.get(PREF_MAX_SERIALIZE_FWD, -1) + }]); + + promiseInitialized.catch(err => { + // Ensure that we report errors but that they do not stop us. + Promise.reject(err); + }).then(() => this._deferredInitialized.resolve()); + + return result; + }), + + // Post a message to the worker, making sure that it has been initialized + // first. + _postToWorker: Task.async(function*(...args) { + if (!this._initializationStarted) { + // Initializing the worker is somewhat complex, as proper handling of + // backups requires us to first read and check the session. Consequently, + // the only way to initialize the worker is to first call `this.read()`. + + // The call to `this.read()` causes background initialization of the worker. + // Initialization will be complete once `this._deferredInitialized.promise` + // resolves. + this.read(); + } + yield this._deferredInitialized.promise; + return SessionWorker.post(...args) + }), + + write: function (aData) { + if (RunState.isClosed) { + return Promise.reject(new Error("SessionFile is closed")); + } + + let isFinalWrite = false; + if (RunState.isClosing) { + // If shutdown has started, we will want to stop receiving + // write instructions. + isFinalWrite = true; + RunState.setClosed(); + } + + let performShutdownCleanup = isFinalWrite && + !sessionStartup.isAutomaticRestoreEnabled(); + + this._attempts++; + let options = {isFinalWrite, performShutdownCleanup}; + let promise = this._postToWorker("write", [aData, options]); + + // Wait until the write is done. + promise = promise.then(msg => { + // Record how long the write took. + this._recordTelemetry(msg.telemetry); + this._successes++; + if (msg.result.upgradeBackup) { + // We have just completed a backup-on-upgrade, store the information + // in preferences. + Services.prefs.setCharPref(PREF_UPGRADE_BACKUP, + Services.appinfo.platformBuildID); + } + }, err => { + // Catch and report any errors. + console.error("Could not write session state file ", err, err.stack); + this._failures++; + // By not doing anything special here we ensure that |promise| cannot + // be rejected anymore. The shutdown/cleanup code at the end of the + // function will thus always be executed. + }); + + // Ensure that we can write sessionstore.js cleanly before the profile + // becomes unaccessible. + AsyncShutdown.profileBeforeChange.addBlocker( + "SessionFile: Finish writing Session Restore data", + promise, + { + fetchState: () => ({ + options, + attempts: this._attempts, + successes: this._successes, + failures: this._failures, + }) + }); + + // This code will always be executed because |promise| can't fail anymore. + // We ensured that by having a reject handler that reports the failure but + // doesn't forward the rejection. + return promise.then(() => { + // Remove the blocker, no matter if writing failed or not. + AsyncShutdown.profileBeforeChange.removeBlocker(promise); + + if (isFinalWrite) { + Services.obs.notifyObservers(null, "sessionstore-final-state-write-complete", ""); + } + }); + }, + + wipe: function () { + return this._postToWorker("wipe"); + }, + + _recordTelemetry: function(telemetry) { + for (let id of Object.keys(telemetry)){ + let value = telemetry[id]; + let samples = []; + if (Array.isArray(value)) { + samples.push(...value); + } else { + samples.push(value); + } + let histogram = Telemetry.getHistogramById(id); + for (let sample of samples) { + histogram.add(sample); + } + } + } +}; diff --git a/application/basilisk/components/sessionstore/SessionHistory.jsm b/application/basilisk/components/sessionstore/SessionHistory.jsm new file mode 100644 index 000000000..3d28d87db --- /dev/null +++ b/application/basilisk/components/sessionstore/SessionHistory.jsm @@ -0,0 +1,431 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["SessionHistory"]; + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://gre/modules/sessionstore/Utils.jsm"); + +function debug(msg) { + Services.console.logStringMessage("SessionHistory: " + msg); +} + +/** + * The external API exported by this module. + */ +this.SessionHistory = Object.freeze({ + isEmpty: function (docShell) { + return SessionHistoryInternal.isEmpty(docShell); + }, + + collect: function (docShell) { + return SessionHistoryInternal.collect(docShell); + }, + + restore: function (docShell, tabData) { + SessionHistoryInternal.restore(docShell, tabData); + } +}); + +/** + * The internal API for the SessionHistory module. + */ +var SessionHistoryInternal = { + /** + * Returns whether the given docShell's session history is empty. + * + * @param docShell + * The docShell that owns the session history. + */ + isEmpty: function (docShell) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + if (!webNavigation.currentURI) { + return true; + } + let uri = webNavigation.currentURI.spec; + return uri == "about:blank" && history.count == 0; + }, + + /** + * Collects session history data for a given docShell. + * + * @param docShell + * The docShell that owns the session history. + */ + collect: function (docShell) { + let loadContext = docShell.QueryInterface(Ci.nsILoadContext); + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory.QueryInterface(Ci.nsISHistoryInternal); + + let data = {entries: [], userContextId: loadContext.originAttributes.userContextId }; + + if (history && history.count > 0) { + // Loop over the transaction linked list directly so we can get the + // persist property for each transaction. + for (let txn = history.rootTransaction; txn; txn = txn.next) { + let entry = this.serializeEntry(txn.sHEntry); + entry.persist = txn.persist; + data.entries.push(entry); + } + + // Ensure the index isn't out of bounds if an exception was thrown above. + data.index = Math.min(history.index + 1, data.entries.length); + } + + // If either the session history isn't available yet or doesn't have any + // valid entries, make sure we at least include the current page. + if (data.entries.length == 0) { + let uri = webNavigation.currentURI.spec; + let body = webNavigation.document.body; + // We landed here because the history is inaccessible or there are no + // history entries. In that case we should at least record the docShell's + // current URL as a single history entry. If the URL is not about:blank + // or it's a blank tab that was modified (like a custom newtab page), + // record it. For about:blank we explicitly want an empty array without + // an 'index' property to denote that there are no history entries. + if (uri != "about:blank" || (body && body.hasChildNodes())) { + data.entries.push({ + url: uri, + triggeringPrincipal_base64: Utils.SERIALIZED_SYSTEMPRINCIPAL + }); + data.index = 1; + } + } + + return data; + }, + + /** + * Get an object that is a serialized representation of a History entry. + * + * @param shEntry + * nsISHEntry instance + * @return object + */ + serializeEntry: function (shEntry) { + let entry = { url: shEntry.URI.spec }; + + // Save some bytes and don't include the title property + // if that's identical to the current entry's URL. + if (shEntry.title && shEntry.title != entry.url) { + entry.title = shEntry.title; + } + if (shEntry.isSubFrame) { + entry.subframe = true; + } + + entry.charset = shEntry.URI.originCharset; + + let cacheKey = shEntry.cacheKey; + if (cacheKey && cacheKey instanceof Ci.nsISupportsPRUint32 && + cacheKey.data != 0) { + // XXXbz would be better to have cache keys implement + // nsISerializable or something. + entry.cacheKey = cacheKey.data; + } + entry.ID = shEntry.ID; + entry.docshellID = shEntry.docshellID; + + // We will include the property only if it's truthy to save a couple of + // bytes when the resulting object is stringified and saved to disk. + if (shEntry.referrerURI) { + entry.referrer = shEntry.referrerURI.spec; + entry.referrerPolicy = shEntry.referrerPolicy; + } + + if (shEntry.originalURI) { + entry.originalURI = shEntry.originalURI.spec; + } + + if (shEntry.loadReplace) { + entry.loadReplace = shEntry.loadReplace; + } + + if (shEntry.srcdocData) + entry.srcdocData = shEntry.srcdocData; + + if (shEntry.isSrcdocEntry) + entry.isSrcdocEntry = shEntry.isSrcdocEntry; + + if (shEntry.baseURI) + entry.baseURI = shEntry.baseURI.spec; + + if (shEntry.contentType) + entry.contentType = shEntry.contentType; + + if (shEntry.scrollRestorationIsManual) { + entry.scrollRestorationIsManual = true; + } else { + let x = {}, y = {}; + shEntry.getScrollPosition(x, y); + if (x.value != 0 || y.value != 0) + entry.scroll = x.value + "," + y.value; + } + + // Collect triggeringPrincipal data for the current history entry. + // Please note that before Bug 1297338 there was no concept of a + // principalToInherit. To remain backward/forward compatible we + // serialize the principalToInherit as triggeringPrincipal_b64. + // Once principalToInherit is well established (within FF55) + // we can update this code, remove triggeringPrincipal_b64 and + // just keep triggeringPrincipal_base64 as well as + // principalToInherit_base64; see Bug 1301666. + if (shEntry.principalToInherit) { + try { + let principalToInherit = Utils.serializePrincipal(shEntry.principalToInherit); + if (principalToInherit) { + entry.triggeringPrincipal_b64 = principalToInherit; + entry.principalToInherit_base64 = principalToInherit; + } + } catch (e) { + debug(e); + } + } + + if (shEntry.triggeringPrincipal) { + try { + let triggeringPrincipal = Utils.serializePrincipal(shEntry.triggeringPrincipal); + if (triggeringPrincipal) { + entry.triggeringPrincipal_base64 = triggeringPrincipal; + } + } catch (e) { + debug(e); + } + } + + entry.docIdentifier = shEntry.BFCacheEntry.ID; + + if (shEntry.stateData != null) { + entry.structuredCloneState = shEntry.stateData.getDataAsBase64(); + entry.structuredCloneVersion = shEntry.stateData.formatVersion; + } + + if (!(shEntry instanceof Ci.nsISHContainer)) { + return entry; + } + + if (shEntry.childCount > 0 && !shEntry.hasDynamicallyAddedChild()) { + let children = []; + for (let i = 0; i < shEntry.childCount; i++) { + let child = shEntry.GetChildAt(i); + + if (child) { + // Don't try to restore framesets containing wyciwyg URLs. + // (cf. bug 424689 and bug 450595) + if (child.URI.schemeIs("wyciwyg")) { + children.length = 0; + break; + } + + children.push(this.serializeEntry(child)); + } + } + + if (children.length) { + entry.children = children; + } + } + + return entry; + }, + + /** + * Restores session history data for a given docShell. + * + * @param docShell + * The docShell that owns the session history. + * @param tabData + * The tabdata including all history entries. + */ + restore: function (docShell, tabData) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + if (history.count > 0) { + history.PurgeHistory(history.count); + } + history.QueryInterface(Ci.nsISHistoryInternal); + + let idMap = { used: {} }; + let docIdentMap = {}; + for (let i = 0; i < tabData.entries.length; i++) { + let entry = tabData.entries[i]; + //XXXzpao Wallpaper patch for bug 514751 + if (!entry.url) + continue; + let persist = "persist" in entry ? entry.persist : true; + history.addEntry(this.deserializeEntry(entry, idMap, docIdentMap), persist); + } + + // Select the right history entry. + let index = tabData.index - 1; + if (index < history.count && history.index != index) { + history.getEntryAtIndex(index, true); + } + }, + + /** + * Expands serialized history data into a session-history-entry instance. + * + * @param entry + * Object containing serialized history data for a URL + * @param idMap + * Hash for ensuring unique frame IDs + * @param docIdentMap + * Hash to ensure reuse of BFCache entries + * @returns nsISHEntry + */ + deserializeEntry: function (entry, idMap, docIdentMap) { + + var shEntry = Cc["@mozilla.org/browser/session-history-entry;1"]. + createInstance(Ci.nsISHEntry); + + shEntry.setURI(Utils.makeURI(entry.url, entry.charset)); + shEntry.setTitle(entry.title || entry.url); + if (entry.subframe) + shEntry.setIsSubFrame(entry.subframe || false); + shEntry.loadType = Ci.nsIDocShellLoadInfo.loadHistory; + if (entry.contentType) + shEntry.contentType = entry.contentType; + if (entry.referrer) { + shEntry.referrerURI = Utils.makeURI(entry.referrer); + shEntry.referrerPolicy = entry.referrerPolicy; + } + if (entry.originalURI) { + shEntry.originalURI = Utils.makeURI(entry.originalURI); + } + if (entry.loadReplace) { + shEntry.loadReplace = entry.loadReplace; + } + if (entry.isSrcdocEntry) + shEntry.srcdocData = entry.srcdocData; + if (entry.baseURI) + shEntry.baseURI = Utils.makeURI(entry.baseURI); + + if (entry.cacheKey) { + var cacheKey = Cc["@mozilla.org/supports-PRUint32;1"]. + createInstance(Ci.nsISupportsPRUint32); + cacheKey.data = entry.cacheKey; + shEntry.cacheKey = cacheKey; + } + + if (entry.ID) { + // get a new unique ID for this frame (since the one from the last + // start might already be in use) + var id = idMap[entry.ID] || 0; + if (!id) { + for (id = Date.now(); id in idMap.used; id++); + idMap[entry.ID] = id; + idMap.used[id] = true; + } + shEntry.ID = id; + } + + if (entry.docshellID) + shEntry.docshellID = entry.docshellID; + + if (entry.structuredCloneState && entry.structuredCloneVersion) { + shEntry.stateData = + Cc["@mozilla.org/docshell/structured-clone-container;1"]. + createInstance(Ci.nsIStructuredCloneContainer); + + shEntry.stateData.initFromBase64(entry.structuredCloneState, + entry.structuredCloneVersion); + } + + if (entry.scrollRestorationIsManual) { + shEntry.scrollRestorationIsManual = true; + } else if (entry.scroll) { + var scrollPos = (entry.scroll || "0,0").split(","); + scrollPos = [parseInt(scrollPos[0]) || 0, parseInt(scrollPos[1]) || 0]; + shEntry.setScrollPosition(scrollPos[0], scrollPos[1]); + } + + let childDocIdents = {}; + if (entry.docIdentifier) { + // If we have a serialized document identifier, try to find an SHEntry + // which matches that doc identifier and adopt that SHEntry's + // BFCacheEntry. If we don't find a match, insert shEntry as the match + // for the document identifier. + let matchingEntry = docIdentMap[entry.docIdentifier]; + if (!matchingEntry) { + matchingEntry = {shEntry: shEntry, childDocIdents: childDocIdents}; + docIdentMap[entry.docIdentifier] = matchingEntry; + } + else { + shEntry.adoptBFCacheEntry(matchingEntry.shEntry); + childDocIdents = matchingEntry.childDocIdents; + } + } + + // The field entry.owner_b64 got renamed to entry.triggeringPricipal_b64 in + // Bug 1286472. To remain backward compatible we still have to support that + // field for a few cycles before we can remove it within Bug 1289785. + if (entry.owner_b64) { + entry.triggeringPricipal_b64 = entry.owner_b64; + delete entry.owner_b64; + } + + // Before introducing the concept of principalToInherit we only had + // a triggeringPrincipal within every entry which basically is the + // equivalent of the new principalToInherit. To avoid compatibility + // issues, we first check if the entry has entries for + // triggeringPrincipal_base64 and principalToInherit_base64. If not + // we fall back to using the principalToInherit (which is stored + // as triggeringPrincipal_b64) as the triggeringPrincipal and + // the principalToInherit. + // FF55 will remove the triggeringPrincipal_b64, see Bug 1301666. + if (entry.triggeringPrincipal_base64 || entry.principalToInherit_base64) { + if (entry.triggeringPrincipal_base64) { + shEntry.triggeringPrincipal = + Utils.deserializePrincipal(entry.triggeringPrincipal_base64); + } + if (entry.principalToInherit_base64) { + shEntry.principalToInherit = + Utils.deserializePrincipal(entry.principalToInherit_base64); + } + } else if (entry.triggeringPrincipal_b64) { + shEntry.triggeringPrincipal = Utils.deserializePrincipal(entry.triggeringPrincipal_b64); + shEntry.principalToInherit = shEntry.triggeringPrincipal; + } + + if (entry.children && shEntry instanceof Ci.nsISHContainer) { + for (var i = 0; i < entry.children.length; i++) { + //XXXzpao Wallpaper patch for bug 514751 + if (!entry.children[i].url) + continue; + + // We're getting sessionrestore.js files with a cycle in the + // doc-identifier graph, likely due to bug 698656. (That is, we have + // an entry where doc identifier A is an ancestor of doc identifier B, + // and another entry where doc identifier B is an ancestor of A.) + // + // If we were to respect these doc identifiers, we'd create a cycle in + // the SHEntries themselves, which causes the docshell to loop forever + // when it looks for the root SHEntry. + // + // So as a hack to fix this, we restrict the scope of a doc identifier + // to be a node's siblings and cousins, and pass childDocIdents, not + // aDocIdents, to _deserializeHistoryEntry. That is, we say that two + // SHEntries with the same doc identifier have the same document iff + // they have the same parent or their parents have the same document. + + shEntry.AddChild(this.deserializeEntry(entry.children[i], idMap, + childDocIdents), i); + } + } + + return shEntry; + }, + +}; diff --git a/application/basilisk/components/sessionstore/SessionMigration.jsm b/application/basilisk/components/sessionstore/SessionMigration.jsm new file mode 100644 index 000000000..1aa22f1a9 --- /dev/null +++ b/application/basilisk/components/sessionstore/SessionMigration.jsm @@ -0,0 +1,106 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["SessionMigration"]; + +const Cu = Components.utils; +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); +Cu.import("resource://gre/modules/Task.jsm", this); +Cu.import("resource://gre/modules/osfile.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://gre/modules/sessionstore/Utils.jsm"); + +// An encoder to UTF-8. +XPCOMUtils.defineLazyGetter(this, "gEncoder", function () { + return new TextEncoder(); +}); + +// A decoder. +XPCOMUtils.defineLazyGetter(this, "gDecoder", function () { + return new TextDecoder(); +}); + +var SessionMigrationInternal = { + /** + * Convert the original session restore state into a minimal state. It will + * only contain: + * - open windows + * - with tabs + * - with history entries with only title, url, triggeringPrincipal + * - with pinned state + * - with tab group info (hidden + group id) + * - with selected tab info + * - with selected window info + * + * The complete state is then wrapped into the "about:welcomeback" page as + * form field info to be restored when restoring the state. + */ + convertState: function(aStateObj) { + let state = { + selectedWindow: aStateObj.selectedWindow, + _closedWindows: [] + }; + state.windows = aStateObj.windows.map(function(oldWin) { + var win = {extData: {}}; + win.tabs = oldWin.tabs.map(function(oldTab) { + var tab = {}; + // Keep only titles, urls and triggeringPrincipals for history entries + tab.entries = oldTab.entries.map(function(entry) { + return { url: entry.url, + triggeringPrincipal_base64: entry.triggeringPrincipal_base64, + title: entry.title }; + }); + tab.index = oldTab.index; + tab.hidden = oldTab.hidden; + tab.pinned = oldTab.pinned; + return tab; + }); + win.selected = oldWin.selected; + win._closedTabs = []; + return win; + }); + let url = "about:welcomeback"; + let formdata = {id: {sessionData: state}, url}; + let entry = { url, triggeringPrincipal_base64: Utils.SERIALIZED_SYSTEMPRINCIPAL }; + return { windows: [{ tabs: [{ entries: [ entry ], formdata}]}]}; + }, + /** + * Asynchronously read session restore state (JSON) from a path + */ + readState: function(aPath) { + return Task.spawn(function() { + let bytes = yield OS.File.read(aPath); + let text = gDecoder.decode(bytes); + let state = JSON.parse(text); + throw new Task.Result(state); + }); + }, + /** + * Asynchronously write session restore state as JSON to a path + */ + writeState: function(aPath, aState) { + let bytes = gEncoder.encode(JSON.stringify(aState)); + return OS.File.writeAtomic(aPath, bytes, {tmpPath: aPath + ".tmp"}); + } +} + +var SessionMigration = { + /** + * Migrate a limited set of session data from one path to another. + */ + migrate: function(aFromPath, aToPath) { + return Task.spawn(function() { + let inState = yield SessionMigrationInternal.readState(aFromPath); + let outState = SessionMigrationInternal.convertState(inState); + // Unfortunately, we can't use SessionStore's own SessionFile to + // write out the data because it has a dependency on the profile dir + // being known. When the migration runs, there is no guarantee that + // that's true. + yield SessionMigrationInternal.writeState(aToPath, outState); + }); + } +}; diff --git a/application/basilisk/components/sessionstore/SessionSaver.jsm b/application/basilisk/components/sessionstore/SessionSaver.jsm new file mode 100644 index 000000000..d672f8877 --- /dev/null +++ b/application/basilisk/components/sessionstore/SessionSaver.jsm @@ -0,0 +1,264 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["SessionSaver"]; + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/Timer.jsm", this); +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); +Cu.import("resource://gre/modules/TelemetryStopwatch.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyFilter", + "resource:///modules/sessionstore/PrivacyFilter.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "RunState", + "resource:///modules/sessionstore/RunState.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionStore", + "resource:///modules/sessionstore/SessionStore.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionFile", + "resource:///modules/sessionstore/SessionFile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +// Minimal interval between two save operations (in milliseconds). +XPCOMUtils.defineLazyGetter(this, "gInterval", function () { + const PREF = "browser.sessionstore.interval"; + + // Observer that updates the cached value when the preference changes. + Services.prefs.addObserver(PREF, () => { + this.gInterval = Services.prefs.getIntPref(PREF); + + // Cancel any pending runs and call runDelayed() with + // zero to apply the newly configured interval. + SessionSaverInternal.cancel(); + SessionSaverInternal.runDelayed(0); + }, false); + + return Services.prefs.getIntPref(PREF); +}); + +// Notify observers about a given topic with a given subject. +function notify(subject, topic) { + Services.obs.notifyObservers(subject, topic, ""); +} + +// TelemetryStopwatch helper functions. +function stopWatch(method) { + return function (...histograms) { + for (let hist of histograms) { + TelemetryStopwatch[method]("FX_SESSION_RESTORE_" + hist); + } + }; +} + +var stopWatchStart = stopWatch("start"); +var stopWatchCancel = stopWatch("cancel"); +var stopWatchFinish = stopWatch("finish"); + +/** + * The external API implemented by the SessionSaver module. + */ +this.SessionSaver = Object.freeze({ + /** + * Immediately saves the current session to disk. + */ + run: function () { + return SessionSaverInternal.run(); + }, + + /** + * Saves the current session to disk delayed by a given amount of time. Should + * another delayed run be scheduled already, we will ignore the given delay + * and state saving may occur a little earlier. + */ + runDelayed: function () { + SessionSaverInternal.runDelayed(); + }, + + /** + * Sets the last save time to the current time. This will cause us to wait for + * at least the configured interval when runDelayed() is called next. + */ + updateLastSaveTime: function () { + SessionSaverInternal.updateLastSaveTime(); + }, + + /** + * Cancels all pending session saves. + */ + cancel: function () { + SessionSaverInternal.cancel(); + } +}); + +/** + * The internal API. + */ +var SessionSaverInternal = { + /** + * The timeout ID referencing an active timer for a delayed save. When no + * save is pending, this is null. + */ + _timeoutID: null, + + /** + * A timestamp that keeps track of when we saved the session last. We will + * this to determine the correct interval between delayed saves to not deceed + * the configured session write interval. + */ + _lastSaveTime: 0, + + /** + * Immediately saves the current session to disk. + */ + run: function () { + return this._saveState(true /* force-update all windows */); + }, + + /** + * Saves the current session to disk delayed by a given amount of time. Should + * another delayed run be scheduled already, we will ignore the given delay + * and state saving may occur a little earlier. + * + * @param delay (optional) + * The minimum delay in milliseconds to wait for until we collect and + * save the current session. + */ + runDelayed: function (delay = 2000) { + // Bail out if there's a pending run. + if (this._timeoutID) { + return; + } + + // Interval until the next disk operation is allowed. + delay = Math.max(this._lastSaveTime + gInterval - Date.now(), delay, 0); + + // Schedule a state save. + this._timeoutID = setTimeout(() => this._saveStateAsync(), delay); + }, + + /** + * Sets the last save time to the current time. This will cause us to wait for + * at least the configured interval when runDelayed() is called next. + */ + updateLastSaveTime: function () { + this._lastSaveTime = Date.now(); + }, + + /** + * Cancels all pending session saves. + */ + cancel: function () { + clearTimeout(this._timeoutID); + this._timeoutID = null; + }, + + /** + * Saves the current session state. Collects data and writes to disk. + * + * @param forceUpdateAllWindows (optional) + * Forces us to recollect data for all windows and will bypass and + * update the corresponding caches. + */ + _saveState: function (forceUpdateAllWindows = false) { + // Cancel any pending timeouts. + this.cancel(); + + if (PrivateBrowsingUtils.permanentPrivateBrowsing) { + // Don't save (or even collect) anything in permanent private + // browsing mode + + this.updateLastSaveTime(); + return Promise.resolve(); + } + + stopWatchStart("COLLECT_DATA_MS", "COLLECT_DATA_LONGEST_OP_MS"); + let state = SessionStore.getCurrentState(forceUpdateAllWindows); + PrivacyFilter.filterPrivateWindowsAndTabs(state); + + // Make sure that we keep the previous session if we started with a single + // private window and no non-private windows have been opened, yet. + if (state.deferredInitialState) { + state.windows = state.deferredInitialState.windows || []; + delete state.deferredInitialState; + } + + if (AppConstants.platform != "macosx") { + // We want to restore closed windows that are marked with _shouldRestore. + // We're doing this here because we want to control this only when saving + // the file. + while (state._closedWindows.length) { + let i = state._closedWindows.length - 1; + + if (!state._closedWindows[i]._shouldRestore) { + // We only need to go until _shouldRestore + // is falsy since we're going in reverse. + break; + } + + delete state._closedWindows[i]._shouldRestore; + state.windows.unshift(state._closedWindows.pop()); + } + } + + // Clear all cookies on clean shutdown according to user preferences + if (RunState.isClosing) { + let expireCookies = Services.prefs.getIntPref("network.cookie.lifetimePolicy") == + Services.cookies.QueryInterface(Ci.nsICookieService).ACCEPT_SESSION; + let sanitizeCookies = Services.prefs.getBoolPref("privacy.sanitize.sanitizeOnShutdown") && + Services.prefs.getBoolPref("privacy.clearOnShutdown.cookies"); + let restart = Services.prefs.getBoolPref("browser.sessionstore.resume_session_once"); + // Don't clear cookies when restarting + if ((expireCookies || sanitizeCookies) && !restart) { + for (let window of state.windows) { + delete window.cookies; + } + } + } + + stopWatchFinish("COLLECT_DATA_MS", "COLLECT_DATA_LONGEST_OP_MS"); + return this._writeState(state); + }, + + /** + * Saves the current session state. Collects data asynchronously and calls + * _saveState() to collect data again (with a cache hit rate of hopefully + * 100%) and write to disk afterwards. + */ + _saveStateAsync: function () { + // Allow scheduling delayed saves again. + this._timeoutID = null; + + // Write to disk. + this._saveState(); + }, + + /** + * Write the given state object to disk. + */ + _writeState: function (state) { + // We update the time stamp before writing so that we don't write again + // too soon, if saving is requested before the write completes. Without + // this update we may save repeatedly if actions cause a runDelayed + // before writing has completed. See Bug 902280 + this.updateLastSaveTime(); + + // Write (atomically) to a session file, using a tmp file. Once the session + // file is successfully updated, save the time stamp of the last save and + // notify the observers. + return SessionFile.write(state).then(() => { + this.updateLastSaveTime(); + notify(null, "sessionstore-state-write-complete"); + }, console.error); + }, +}; diff --git a/application/basilisk/components/sessionstore/SessionStorage.jsm b/application/basilisk/components/sessionstore/SessionStorage.jsm new file mode 100644 index 000000000..705139ebf --- /dev/null +++ b/application/basilisk/components/sessionstore/SessionStorage.jsm @@ -0,0 +1,173 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["SessionStorage"]; + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); + +// Returns the principal for a given |frame| contained in a given |docShell|. +function getPrincipalForFrame(docShell, frame) { + let ssm = Services.scriptSecurityManager; + let uri = frame.document.documentURIObject; + return ssm.getDocShellCodebasePrincipal(uri, docShell); +} + +this.SessionStorage = Object.freeze({ + /** + * Updates all sessionStorage "super cookies" + * @param docShell + * That tab's docshell (containing the sessionStorage) + * @param frameTree + * The docShell's FrameTree instance. + * @return Returns a nested object that will have hosts as keys and per-host + * session storage data as strings. For example: + * {"example.com": {"key": "value", "my_number": "123"}} + */ + collect: function (docShell, frameTree) { + return SessionStorageInternal.collect(docShell, frameTree); + }, + + /** + * Restores all sessionStorage "super cookies". + * @param aDocShell + * A tab's docshell (containing the sessionStorage) + * @param aStorageData + * A nested object with storage data to be restored that has hosts as + * keys and per-host session storage data as strings. For example: + * {"example.com": {"key": "value", "my_number": "123"}} + */ + restore: function (aDocShell, aStorageData) { + SessionStorageInternal.restore(aDocShell, aStorageData); + }, +}); + +var SessionStorageInternal = { + /** + * Reads all session storage data from the given docShell. + * @param docShell + * A tab's docshell (containing the sessionStorage) + * @param frameTree + * The docShell's FrameTree instance. + * @return Returns a nested object that will have hosts as keys and per-host + * session storage data as strings. For example: + * {"example.com": {"key": "value", "my_number": "123"}} + */ + collect: function (docShell, frameTree) { + let data = {}; + let visitedOrigins = new Set(); + + frameTree.forEach(frame => { + let principal = getPrincipalForFrame(docShell, frame); + if (!principal) { + return; + } + + // Get the origin of the current history entry + // and use that as a key for the per-principal storage data. + let origin = principal.origin; + if (visitedOrigins.has(origin)) { + // Don't read a host twice. + return; + } + + // Mark the current origin as visited. + visitedOrigins.add(origin); + + let originData = this._readEntry(principal, docShell); + if (Object.keys(originData).length) { + data[origin] = originData; + } + }); + + return Object.keys(data).length ? data : null; + }, + + /** + * Writes session storage data to the given tab. + * @param aDocShell + * A tab's docshell (containing the sessionStorage) + * @param aStorageData + * A nested object with storage data to be restored that has hosts as + * keys and per-host session storage data as strings. For example: + * {"example.com": {"key": "value", "my_number": "123"}} + */ + restore: function (aDocShell, aStorageData) { + for (let origin of Object.keys(aStorageData)) { + let data = aStorageData[origin]; + + let principal; + + try { + let attrs = aDocShell.getOriginAttributes(); + let originURI = Services.io.newURI(origin, null, null); + principal = Services.scriptSecurityManager.createCodebasePrincipal(originURI, attrs); + } catch (e) { + console.error(e); + continue; + } + + let storageManager = aDocShell.QueryInterface(Ci.nsIDOMStorageManager); + let window = aDocShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow); + + // There is no need to pass documentURI, it's only used to fill documentURI property of + // domstorage event, which in this case has no consumer. Prevention of events in case + // of missing documentURI will be solved in a followup bug to bug 600307. + let storage = storageManager.createStorage(window, principal, "", aDocShell.usePrivateBrowsing); + + for (let key of Object.keys(data)) { + try { + storage.setItem(key, data[key]); + } catch (e) { + // throws e.g. for URIs that can't have sessionStorage + console.error(e); + } + } + } + }, + + /** + * Reads an entry in the session storage data contained in a tab's history. + * @param aURI + * That history entry uri + * @param aDocShell + * A tab's docshell (containing the sessionStorage) + */ + _readEntry: function (aPrincipal, aDocShell) { + let hostData = {}; + let storage; + + let window = aDocShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow); + + try { + let storageManager = aDocShell.QueryInterface(Ci.nsIDOMStorageManager); + storage = storageManager.getStorage(window, aPrincipal); + storage.length; // XXX: Bug 1232955 - storage.length can throw, catch that failure + } catch (e) { + // sessionStorage might throw if it's turned off, see bug 458954 + storage = null; + } + + if (storage && storage.length) { + for (let i = 0; i < storage.length; i++) { + try { + let key = storage.key(i); + hostData[key] = storage.getItem(key); + } catch (e) { + // This currently throws for secured items (cf. bug 442048). + } + } + } + + return hostData; + } +}; diff --git a/application/basilisk/components/sessionstore/SessionStore.jsm b/application/basilisk/components/sessionstore/SessionStore.jsm new file mode 100644 index 000000000..6b30943f3 --- /dev/null +++ b/application/basilisk/components/sessionstore/SessionStore.jsm @@ -0,0 +1,4746 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["SessionStore"]; + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; + +// Current version of the format used by Session Restore. +const FORMAT_VERSION = 1; + +const TAB_STATE_NEEDS_RESTORE = 1; +const TAB_STATE_RESTORING = 2; +const TAB_STATE_WILL_RESTORE = 3; + +// A new window has just been restored. At this stage, tabs are generally +// not restored. +const NOTIFY_SINGLE_WINDOW_RESTORED = "sessionstore-single-window-restored"; +const NOTIFY_WINDOWS_RESTORED = "sessionstore-windows-restored"; +const NOTIFY_BROWSER_STATE_RESTORED = "sessionstore-browser-state-restored"; +const NOTIFY_LAST_SESSION_CLEARED = "sessionstore-last-session-cleared"; +const NOTIFY_RESTORING_ON_STARTUP = "sessionstore-restoring-on-startup"; +const NOTIFY_INITIATING_MANUAL_RESTORE = "sessionstore-initiating-manual-restore"; + +const NOTIFY_TAB_RESTORED = "sessionstore-debug-tab-restored"; // WARNING: debug-only + +// Maximum number of tabs to restore simultaneously. Previously controlled by +// the browser.sessionstore.max_concurrent_tabs pref. +const MAX_CONCURRENT_TAB_RESTORES = 3; + +// Amount (in CSS px) by which we allow window edges to be off-screen +// when restoring a window, before we override the saved position to +// pull the window back within the available screen area. +const SCREEN_EDGE_SLOP = 8; + +// global notifications observed +const OBSERVING = [ + "browser-window-before-show", "domwindowclosed", + "quit-application-granted", "browser-lastwindow-close-granted", + "quit-application", "browser:purge-session-history", + "browser:purge-domain-data", + "idle-daily", +]; + +// XUL Window properties to (re)store +// Restored in restoreDimensions() +const WINDOW_ATTRIBUTES = ["width", "height", "screenX", "screenY", "sizemode"]; + +// Hideable window features to (re)store +// Restored in restoreWindowFeatures() +const WINDOW_HIDEABLE_FEATURES = [ + "menubar", "toolbar", "locationbar", "personalbar", "statusbar", "scrollbars" +]; + +// Messages that will be received via the Frame Message Manager. +const MESSAGES = [ + // The content script sends us data that has been invalidated and needs to + // be saved to disk. + "SessionStore:update", + + // The restoreHistory code has run. This is a good time to run SSTabRestoring. + "SessionStore:restoreHistoryComplete", + + // The load for the restoring tab has begun. We update the URL bar at this + // time; if we did it before, the load would overwrite it. + "SessionStore:restoreTabContentStarted", + + // All network loads for a restoring tab are done, so we should + // consider restoring another tab in the queue. The document has + // been restored, and forms have been filled. We trigger + // SSTabRestored at this time. + "SessionStore:restoreTabContentComplete", + + // A crashed tab was revived by navigating to a different page. Remove its + // browser from the list of crashed browsers to stop ignoring its messages. + "SessionStore:crashedTabRevived", + + // The content script encountered an error. + "SessionStore:error", +]; + +// The list of messages we accept from <xul:browser>s that have no tab +// assigned, or whose windows have gone away. Those are for example the +// ones that preload about:newtab pages, or from browsers where the window +// has just been closed. +const NOTAB_MESSAGES = new Set([ + // For a description see above. + "SessionStore:crashedTabRevived", + + // For a description see above. + "SessionStore:update", + + // For a description see above. + "SessionStore:error", +]); + +// The list of messages we accept without an "epoch" parameter. +// See getCurrentEpoch() and friends to find out what an "epoch" is. +const NOEPOCH_MESSAGES = new Set([ + // For a description see above. + "SessionStore:crashedTabRevived", + + // For a description see above. + "SessionStore:error", +]); + +// The list of messages we want to receive even during the short period after a +// frame has been removed from the DOM and before its frame script has finished +// unloading. +const CLOSED_MESSAGES = new Set([ + // For a description see above. + "SessionStore:crashedTabRevived", + + // For a description see above. + "SessionStore:update", + + // For a description see above. + "SessionStore:error", +]); + +// These are tab events that we listen to. +const TAB_EVENTS = [ + "TabOpen", "TabBrowserInserted", "TabClose", "TabSelect", "TabShow", "TabHide", "TabPinned", + "TabUnpinned" +]; + +const NS_XUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm", this); +Cu.import("resource://gre/modules/Promise.jsm", this); +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/Task.jsm", this); +Cu.import("resource://gre/modules/TelemetryStopwatch.jsm", this); +Cu.import("resource://gre/modules/TelemetryTimestamps.jsm", this); +Cu.import("resource://gre/modules/Timer.jsm", this); +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); +Cu.import("resource://gre/modules/debug.js", this); +Cu.import("resource://gre/modules/osfile.jsm", this); + +XPCOMUtils.defineLazyServiceGetter(this, "gSessionStartup", + "@mozilla.org/browser/sessionstartup;1", "nsISessionStartup"); +XPCOMUtils.defineLazyServiceGetter(this, "gScreenManager", + "@mozilla.org/gfx/screenmanager;1", "nsIScreenManager"); +XPCOMUtils.defineLazyServiceGetter(this, "Telemetry", + "@mozilla.org/base/telemetry;1", "nsITelemetry"); +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow", + "resource:///modules/RecentWindow.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "GlobalState", + "resource:///modules/sessionstore/GlobalState.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyFilter", + "resource:///modules/sessionstore/PrivacyFilter.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "RunState", + "resource:///modules/sessionstore/RunState.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ScratchpadManager", + "resource://devtools/client/scratchpad/scratchpad-manager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionSaver", + "resource:///modules/sessionstore/SessionSaver.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionCookies", + "resource:///modules/sessionstore/SessionCookies.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionFile", + "resource:///modules/sessionstore/SessionFile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabAttributes", + "resource:///modules/sessionstore/TabAttributes.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabCrashHandler", + "resource:///modules/ContentCrashHandlers.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabState", + "resource:///modules/sessionstore/TabState.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabStateCache", + "resource:///modules/sessionstore/TabStateCache.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabStateFlusher", + "resource:///modules/sessionstore/TabStateFlusher.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://gre/modules/sessionstore/Utils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ViewSourceBrowser", + "resource://gre/modules/ViewSourceBrowser.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); + +Object.defineProperty(this, "HUDService", { + get: function HUDService_getter() { + let devtools = Cu.import("resource://devtools/shared/Loader.jsm", {}).devtools; + return devtools.require("devtools/client/webconsole/hudservice").HUDService; + }, + configurable: true, + enumerable: true +}); + +/** + * |true| if we are in debug mode, |false| otherwise. + * Debug mode is controlled by preference browser.sessionstore.debug + */ +var gDebuggingEnabled = false; +function debug(aMsg) { + if (gDebuggingEnabled) { + aMsg = ("SessionStore: " + aMsg).replace(/\S{80}/g, "$&\n"); + Services.console.logStringMessage(aMsg); + } +} + +this.SessionStore = { + get promiseInitialized() { + return SessionStoreInternal.promiseInitialized; + }, + + get canRestoreLastSession() { + return SessionStoreInternal.canRestoreLastSession; + }, + + set canRestoreLastSession(val) { + SessionStoreInternal.canRestoreLastSession = val; + }, + + get lastClosedObjectType() { + return SessionStoreInternal.lastClosedObjectType; + }, + + init: function ss_init() { + SessionStoreInternal.init(); + }, + + getBrowserState: function ss_getBrowserState() { + return SessionStoreInternal.getBrowserState(); + }, + + setBrowserState: function ss_setBrowserState(aState) { + SessionStoreInternal.setBrowserState(aState); + }, + + getWindowState: function ss_getWindowState(aWindow) { + return SessionStoreInternal.getWindowState(aWindow); + }, + + setWindowState: function ss_setWindowState(aWindow, aState, aOverwrite) { + SessionStoreInternal.setWindowState(aWindow, aState, aOverwrite); + }, + + getTabState: function ss_getTabState(aTab) { + return SessionStoreInternal.getTabState(aTab); + }, + + setTabState: function ss_setTabState(aTab, aState) { + SessionStoreInternal.setTabState(aTab, aState); + }, + + duplicateTab: function ss_duplicateTab(aWindow, aTab, aDelta = 0) { + return SessionStoreInternal.duplicateTab(aWindow, aTab, aDelta); + }, + + getClosedTabCount: function ss_getClosedTabCount(aWindow) { + return SessionStoreInternal.getClosedTabCount(aWindow); + }, + + getClosedTabData: function ss_getClosedTabData(aWindow, aAsString = true) { + return SessionStoreInternal.getClosedTabData(aWindow, aAsString); + }, + + undoCloseTab: function ss_undoCloseTab(aWindow, aIndex) { + return SessionStoreInternal.undoCloseTab(aWindow, aIndex); + }, + + forgetClosedTab: function ss_forgetClosedTab(aWindow, aIndex) { + return SessionStoreInternal.forgetClosedTab(aWindow, aIndex); + }, + + getClosedWindowCount: function ss_getClosedWindowCount() { + return SessionStoreInternal.getClosedWindowCount(); + }, + + getClosedWindowData: function ss_getClosedWindowData(aAsString = true) { + return SessionStoreInternal.getClosedWindowData(aAsString); + }, + + undoCloseWindow: function ss_undoCloseWindow(aIndex) { + return SessionStoreInternal.undoCloseWindow(aIndex); + }, + + forgetClosedWindow: function ss_forgetClosedWindow(aIndex) { + return SessionStoreInternal.forgetClosedWindow(aIndex); + }, + + getWindowValue: function ss_getWindowValue(aWindow, aKey) { + return SessionStoreInternal.getWindowValue(aWindow, aKey); + }, + + setWindowValue: function ss_setWindowValue(aWindow, aKey, aStringValue) { + SessionStoreInternal.setWindowValue(aWindow, aKey, aStringValue); + }, + + deleteWindowValue: function ss_deleteWindowValue(aWindow, aKey) { + SessionStoreInternal.deleteWindowValue(aWindow, aKey); + }, + + getTabValue: function ss_getTabValue(aTab, aKey) { + return SessionStoreInternal.getTabValue(aTab, aKey); + }, + + setTabValue: function ss_setTabValue(aTab, aKey, aStringValue) { + SessionStoreInternal.setTabValue(aTab, aKey, aStringValue); + }, + + deleteTabValue: function ss_deleteTabValue(aTab, aKey) { + SessionStoreInternal.deleteTabValue(aTab, aKey); + }, + + getGlobalValue: function ss_getGlobalValue(aKey) { + return SessionStoreInternal.getGlobalValue(aKey); + }, + + setGlobalValue: function ss_setGlobalValue(aKey, aStringValue) { + SessionStoreInternal.setGlobalValue(aKey, aStringValue); + }, + + deleteGlobalValue: function ss_deleteGlobalValue(aKey) { + SessionStoreInternal.deleteGlobalValue(aKey); + }, + + persistTabAttribute: function ss_persistTabAttribute(aName) { + SessionStoreInternal.persistTabAttribute(aName); + }, + + restoreLastSession: function ss_restoreLastSession() { + SessionStoreInternal.restoreLastSession(); + }, + + getCurrentState: function (aUpdateAll) { + return SessionStoreInternal.getCurrentState(aUpdateAll); + }, + + reviveCrashedTab(aTab) { + return SessionStoreInternal.reviveCrashedTab(aTab); + }, + + reviveAllCrashedTabs() { + return SessionStoreInternal.reviveAllCrashedTabs(); + }, + + navigateAndRestore(tab, loadArguments, historyIndex) { + return SessionStoreInternal.navigateAndRestore(tab, loadArguments, historyIndex); + }, + + getSessionHistory(tab, updatedCallback) { + return SessionStoreInternal.getSessionHistory(tab, updatedCallback); + }, + + undoCloseById(aClosedId) { + return SessionStoreInternal.undoCloseById(aClosedId); + }, + + /** + * Determines whether the passed version number is compatible with + * the current version number of the SessionStore. + * + * @param version The format and version of the file, as an array, e.g. + * ["sessionrestore", 1] + */ + isFormatVersionCompatible(version) { + if (!version) { + return false; + } + if (!Array.isArray(version)) { + // Improper format. + return false; + } + if (version[0] != "sessionrestore") { + // Not a Session Restore file. + return false; + } + let number = Number.parseFloat(version[1]); + if (Number.isNaN(number)) { + return false; + } + return number <= FORMAT_VERSION; + }, +}; + +// Freeze the SessionStore object. We don't want anyone to modify it. +Object.freeze(SessionStore); + +var SessionStoreInternal = { + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIDOMEventListener, + Ci.nsIObserver, + Ci.nsISupportsWeakReference + ]), + + _globalState: new GlobalState(), + + // A counter to be used to generate a unique ID for each closed tab or window. + _nextClosedId: 0, + + // During the initial restore and setBrowserState calls tracks the number of + // windows yet to be restored + _restoreCount: -1, + + // For each <browser> element, records the current epoch. + _browserEpochs: new WeakMap(), + + // Any browsers that fires the oop-browser-crashed event gets stored in + // here - that way we know which browsers to ignore messages from (until + // they get restored). + _crashedBrowsers: new WeakSet(), + + // A map (xul:browser -> nsIFrameLoader) that maps a browser to the last + // associated frameLoader we heard about. + _lastKnownFrameLoader: new WeakMap(), + + // A map (xul:browser -> object) that maps a browser associated with a + // recently closed tab to all its necessary state information we need to + // properly handle final update message. + _closedTabs: new WeakMap(), + + // A map (xul:browser -> object) that maps a browser associated with a + // recently closed tab due to a window closure to the tab state information + // that is being stored in _closedWindows for that tab. + _closedWindowTabs: new WeakMap(), + + // A set of window data that has the potential to be saved in the _closedWindows + // array for the session. We will remove window data from this set whenever + // forgetClosedWindow is called for the window, or when session history is + // purged, so that we don't accidentally save that data after the flush has + // completed. Closed tabs use a more complicated mechanism for this particular + // problem. When forgetClosedTab is called, the browser is removed from the + // _closedTabs map, so its data is not recorded. In the purge history case, + // the closedTabs array per window is overwritten so that once the flush is + // complete, the tab would only ever add itself to an array that SessionStore + // no longer cares about. Bug 1230636 has been filed to make the tab case + // work more like the window case, which is more explicit, and easier to + // reason about. + _saveableClosedWindowData: new WeakSet(), + + // A map (xul:browser -> object) that maps a browser that is switching + // remoteness via navigateAndRestore, to the loadArguments that were + // most recently passed when calling navigateAndRestore. + _remotenessChangingBrowsers: new WeakMap(), + + // whether a setBrowserState call is in progress + _browserSetState: false, + + // time in milliseconds when the session was started (saved across sessions), + // defaults to now if no session was restored or timestamp doesn't exist + _sessionStartTime: Date.now(), + + // states for all currently opened windows + _windows: {}, + + // counter for creating unique window IDs + _nextWindowID: 0, + + // states for all recently closed windows + _closedWindows: [], + + // collection of session states yet to be restored + _statesToRestore: {}, + + // counts the number of crashes since the last clean start + _recentCrashes: 0, + + // whether the last window was closed and should be restored + _restoreLastWindow: false, + + // number of tabs currently restoring + _tabsRestoringCount: 0, + + // When starting Firefox with a single private window, this is the place + // where we keep the session we actually wanted to restore in case the user + // decides to later open a non-private window as well. + _deferredInitialState: null, + + // A promise resolved once initialization is complete + _deferredInitialized: (function () { + let deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + return deferred; + })(), + + // Whether session has been initialized + _sessionInitialized: false, + + // Promise that is resolved when we're ready to initialize + // and restore the session. + _promiseReadyForInitialization: null, + + // Keep busy state counters per window. + _windowBusyStates: new WeakMap(), + + /** + * A promise fulfilled once initialization is complete. + */ + get promiseInitialized() { + return this._deferredInitialized.promise; + }, + + get canRestoreLastSession() { + return LastSession.canRestore; + }, + + set canRestoreLastSession(val) { + // Cheat a bit; only allow false. + if (!val) { + LastSession.clear(); + } + }, + + /** + * Returns a string describing the last closed object, either "tab" or "window". + * + * This was added to support the sessions.restore WebExtensions API. + */ + get lastClosedObjectType() { + if (this._closedWindows.length) { + // Since there are closed windows, we need to check if there's a closed tab + // in one of the currently open windows that was closed after the + // last-closed window. + let tabTimestamps = []; + let windowsEnum = Services.wm.getEnumerator("navigator:browser"); + while (windowsEnum.hasMoreElements()) { + let window = windowsEnum.getNext(); + let windowState = this._windows[window.__SSi]; + if (windowState && windowState._closedTabs[0]) { + tabTimestamps.push(windowState._closedTabs[0].closedAt); + } + } + if (!tabTimestamps.length || + (tabTimestamps.sort((a, b) => b - a)[0] < this._closedWindows[0].closedAt)) { + return "window"; + } + } + return "tab"; + }, + + /** + * Initialize the sessionstore service. + */ + init: function () { + if (this._initialized) { + throw new Error("SessionStore.init() must only be called once!"); + } + + TelemetryTimestamps.add("sessionRestoreInitialized"); + OBSERVING.forEach(function(aTopic) { + Services.obs.addObserver(this, aTopic, true); + }, this); + + this._initPrefs(); + this._initialized = true; + }, + + /** + * Initialize the session using the state provided by SessionStartup + */ + initSession: function () { + TelemetryStopwatch.start("FX_SESSION_RESTORE_STARTUP_INIT_SESSION_MS"); + let state; + let ss = gSessionStartup; + + if (ss.doRestore() || + ss.sessionType == Ci.nsISessionStartup.DEFER_SESSION) { + state = ss.state; + } + + if (state) { + try { + // If we're doing a DEFERRED session, then we want to pull pinned tabs + // out so they can be restored. + if (ss.sessionType == Ci.nsISessionStartup.DEFER_SESSION) { + let [iniState, remainingState] = this._prepDataForDeferredRestore(state); + // If we have a iniState with windows, that means that we have windows + // with app tabs to restore. + if (iniState.windows.length) + state = iniState; + else + state = null; + + if (remainingState.windows.length) { + LastSession.setState(remainingState); + } + } + else { + // Get the last deferred session in case the user still wants to + // restore it + LastSession.setState(state.lastSessionState); + + if (ss.previousSessionCrashed) { + this._recentCrashes = (state.session && + state.session.recentCrashes || 0) + 1; + + if (this._needsRestorePage(state, this._recentCrashes)) { + // replace the crashed session with a restore-page-only session + let url = "about:sessionrestore"; + let formdata = {id: {sessionData: state}, url}; + let entry = {url, triggeringPrincipal_base64: Utils.SERIALIZED_SYSTEMPRINCIPAL }; + state = { windows: [{ tabs: [{ entries: [entry], formdata }] }] }; + } else if (this._hasSingleTabWithURL(state.windows, + "about:welcomeback")) { + // On a single about:welcomeback URL that crashed, replace about:welcomeback + // with about:sessionrestore, to make clear to the user that we crashed. + state.windows[0].tabs[0].entries[0].url = "about:sessionrestore"; + state.windows[0].tabs[0].entries[0].triggeringPrincipal_base64 = Utils.SERIALIZED_SYSTEMPRINCIPAL; + } + } + + // Update the session start time using the restored session state. + this._updateSessionStartTime(state); + + // make sure that at least the first window doesn't have anything hidden + delete state.windows[0].hidden; + // Since nothing is hidden in the first window, it cannot be a popup + delete state.windows[0].isPopup; + // We don't want to minimize and then open a window at startup. + if (state.windows[0].sizemode == "minimized") + state.windows[0].sizemode = "normal"; + // clear any lastSessionWindowID attributes since those don't matter + // during normal restore + state.windows.forEach(function(aWindow) { + delete aWindow.__lastSessionWindowID; + }); + } + } + catch (ex) { debug("The session file is invalid: " + ex); } + } + + // at this point, we've as good as resumed the session, so we can + // clear the resume_session_once flag, if it's set + if (!RunState.isQuitting && + this._prefBranch.getBoolPref("sessionstore.resume_session_once")) + this._prefBranch.setBoolPref("sessionstore.resume_session_once", false); + + TelemetryStopwatch.finish("FX_SESSION_RESTORE_STARTUP_INIT_SESSION_MS"); + return state; + }, + + _initPrefs : function() { + this._prefBranch = Services.prefs.getBranch("browser."); + + gDebuggingEnabled = this._prefBranch.getBoolPref("sessionstore.debug"); + + Services.prefs.addObserver("browser.sessionstore.debug", () => { + gDebuggingEnabled = this._prefBranch.getBoolPref("sessionstore.debug"); + }, false); + + this._max_tabs_undo = this._prefBranch.getIntPref("sessionstore.max_tabs_undo"); + this._prefBranch.addObserver("sessionstore.max_tabs_undo", this, true); + + this._max_windows_undo = this._prefBranch.getIntPref("sessionstore.max_windows_undo"); + this._prefBranch.addObserver("sessionstore.max_windows_undo", this, true); + }, + + /** + * Called on application shutdown, after notifications: + * quit-application-granted, quit-application + */ + _uninit: function ssi_uninit() { + if (!this._initialized) { + throw new Error("SessionStore is not initialized."); + } + + // Prepare to close the session file and write the last state. + RunState.setClosing(); + + // save all data for session resuming + if (this._sessionInitialized) { + SessionSaver.run(); + } + + // clear out priority queue in case it's still holding refs + TabRestoreQueue.reset(); + + // Make sure to cancel pending saves. + SessionSaver.cancel(); + }, + + /** + * Handle notifications + */ + observe: function ssi_observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "browser-window-before-show": // catch new windows + this.onBeforeBrowserWindowShown(aSubject); + break; + case "domwindowclosed": // catch closed windows + this.onClose(aSubject); + break; + case "quit-application-granted": + let syncShutdown = aData == "syncShutdown"; + this.onQuitApplicationGranted(syncShutdown); + break; + case "browser-lastwindow-close-granted": + this.onLastWindowCloseGranted(); + break; + case "quit-application": + this.onQuitApplication(aData); + break; + case "browser:purge-session-history": // catch sanitization + this.onPurgeSessionHistory(); + break; + case "browser:purge-domain-data": + this.onPurgeDomainData(aData); + break; + case "nsPref:changed": // catch pref changes + this.onPrefChange(aData); + break; + case "idle-daily": + this.onIdleDaily(); + break; + } + }, + + /** + * This method handles incoming messages sent by the session store content + * script via the Frame Message Manager or Parent Process Message Manager, + * and thus enables communication with OOP tabs. + */ + receiveMessage(aMessage) { + // If we got here, that means we're dealing with a frame message + // manager message, so the target will be a <xul:browser>. + var browser = aMessage.target; + let win = browser.ownerGlobal; + let tab = win ? win.gBrowser.getTabForBrowser(browser) : null; + + // Ensure we receive only specific messages from <xul:browser>s that + // have no tab or window assigned, e.g. the ones that preload + // about:newtab pages, or windows that have closed. + if (!tab && !NOTAB_MESSAGES.has(aMessage.name)) { + throw new Error(`received unexpected message '${aMessage.name}' ` + + `from a browser that has no tab or window`); + } + + let data = aMessage.data || {}; + let hasEpoch = data.hasOwnProperty("epoch"); + + // Most messages sent by frame scripts require to pass an epoch. + if (!hasEpoch && !NOEPOCH_MESSAGES.has(aMessage.name)) { + throw new Error(`received message '${aMessage.name}' without an epoch`); + } + + // Ignore messages from previous epochs. + if (hasEpoch && !this.isCurrentEpoch(browser, data.epoch)) { + return; + } + + switch (aMessage.name) { + case "SessionStore:update": + // |browser.frameLoader| might be empty if the browser was already + // destroyed and its tab removed. In that case we still have the last + // frameLoader we know about to compare. + let frameLoader = browser.frameLoader || + this._lastKnownFrameLoader.get(browser.permanentKey); + + // If the message isn't targeting the latest frameLoader discard it. + if (frameLoader != aMessage.targetFrameLoader) { + return; + } + + if (aMessage.data.isFinal) { + // If this the final message we need to resolve all pending flush + // requests for the given browser as they might have been sent too + // late and will never respond. If they have been sent shortly after + // switching a browser's remoteness there isn't too much data to skip. + TabStateFlusher.resolveAll(browser); + } else if (aMessage.data.flushID) { + // This is an update kicked off by an async flush request. Notify the + // TabStateFlusher so that it can finish the request and notify its + // consumer that's waiting for the flush to be done. + TabStateFlusher.resolve(browser, aMessage.data.flushID); + } + + // Ignore messages from <browser> elements that have crashed + // and not yet been revived. + if (this._crashedBrowsers.has(browser.permanentKey)) { + return; + } + + // Record telemetry measurements done in the child and update the tab's + // cached state. Mark the window as dirty and trigger a delayed write. + this.recordTelemetry(aMessage.data.telemetry); + TabState.update(browser, aMessage.data); + this.saveStateDelayed(win); + + // Handle any updates sent by the child after the tab was closed. This + // might be the final update as sent by the "unload" handler but also + // any async update message that was sent before the child unloaded. + if (this._closedTabs.has(browser.permanentKey)) { + let {closedTabs, tabData} = this._closedTabs.get(browser.permanentKey); + + // Update the closed tab's state. This will be reflected in its + // window's list of closed tabs as that refers to the same object. + TabState.copyFromCache(browser, tabData.state); + + // Is this the tab's final message? + if (aMessage.data.isFinal) { + // We expect no further updates. + this._closedTabs.delete(browser.permanentKey); + // The tab state no longer needs this reference. + delete tabData.permanentKey; + + // Determine whether the tab state is worth saving. + let shouldSave = this._shouldSaveTabState(tabData.state); + let index = closedTabs.indexOf(tabData); + + if (shouldSave && index == -1) { + // If the tab state is worth saving and we didn't push it onto + // the list of closed tabs when it was closed (because we deemed + // the state not worth saving) then add it to the window's list + // of closed tabs now. + this.saveClosedTabData(closedTabs, tabData); + } else if (!shouldSave && index > -1) { + // Remove from the list of closed tabs. The update messages sent + // after the tab was closed changed enough state so that we no + // longer consider its data interesting enough to keep around. + this.removeClosedTabData(closedTabs, index); + } + } + } + break; + case "SessionStore:restoreHistoryComplete": + // Notify the tabbrowser that the tab chrome has been restored. + let tabData = TabState.collect(tab); + + // wall-paper fix for bug 439675: make sure that the URL to be loaded + // is always visible in the address bar if no other value is present + let activePageData = tabData.entries[tabData.index - 1] || null; + let uri = activePageData ? activePageData.url || null : null; + // NB: we won't set initial URIs (about:home, about:newtab, etc.) here + // because their load will not normally trigger a location bar clearing + // when they finish loading (to avoid race conditions where we then + // clear user input instead), so we shouldn't set them here either. + // They also don't fall under the issues in bug 439675 where user input + // needs to be preserved if the load doesn't succeed. + // We also don't do this for remoteness updates, where it should not + // be necessary. + if (!browser.userTypedValue && uri && !data.isRemotenessUpdate && + !win.gInitialPages.includes(uri)) { + browser.userTypedValue = uri; + } + + // If the page has a title, set it. + if (activePageData) { + if (activePageData.title) { + tab.label = activePageData.title; + tab.crop = "end"; + } else if (activePageData.url != "about:blank") { + tab.label = activePageData.url; + tab.crop = "center"; + } + } else if (tab.hasAttribute("customizemode")) { + win.gCustomizeMode.setTab(tab); + } + + // Restore the tab icon. + if ("image" in tabData) { + // Use the serialized contentPrincipal with the new icon load. + let loadingPrincipal = Utils.deserializePrincipal(tabData.iconLoadingPrincipal); + win.gBrowser.setIcon(tab, tabData.image, loadingPrincipal); + TabStateCache.update(browser, { image: null, iconLoadingPrincipal: null }); + } + + let event = win.document.createEvent("Events"); + event.initEvent("SSTabRestoring", true, false); + tab.dispatchEvent(event); + break; + case "SessionStore:restoreTabContentStarted": + if (browser.__SS_restoreState == TAB_STATE_NEEDS_RESTORE) { + // If a load not initiated by sessionstore was started in a + // previously pending tab. Mark the tab as no longer pending. + this.markTabAsRestoring(tab); + } else if (!data.isRemotenessUpdate) { + // If the user was typing into the URL bar when we crashed, but hadn't hit + // enter yet, then we just need to write that value to the URL bar without + // loading anything. This must happen after the load, as the load will clear + // userTypedValue. + let tabData = TabState.collect(tab); + if (tabData.userTypedValue && !tabData.userTypedClear && !browser.userTypedValue) { + browser.userTypedValue = tabData.userTypedValue; + win.URLBarSetURI(); + } + + // Remove state we don't need any longer. + TabStateCache.update(browser, { + userTypedValue: null, userTypedClear: null + }); + } + break; + case "SessionStore:restoreTabContentComplete": + // This callback is used exclusively by tests that want to + // monitor the progress of network loads. + if (gDebuggingEnabled) { + Services.obs.notifyObservers(browser, NOTIFY_TAB_RESTORED, null); + } + + SessionStoreInternal._resetLocalTabRestoringState(tab); + SessionStoreInternal.restoreNextTab(); + + this._sendTabRestoredNotification(tab, data.isRemotenessUpdate); + break; + case "SessionStore:crashedTabRevived": + // The browser was revived by navigating to a different page + // manually, so we remove it from the ignored browser set. + this._crashedBrowsers.delete(browser.permanentKey); + break; + case "SessionStore:error": + this.reportInternalError(data); + TabStateFlusher.resolveAll(browser, false, "Received error from the content process"); + break; + default: + throw new Error(`received unknown message '${aMessage.name}'`); + break; + } + }, + + /** + * Record telemetry measurements stored in an object. + * @param telemetry + * {histogramID: value, ...} An object mapping histogramIDs to the + * value to be recorded for that ID, + */ + recordTelemetry: function (telemetry) { + for (let histogramId in telemetry){ + Telemetry.getHistogramById(histogramId).add(telemetry[histogramId]); + } + }, + + /* ........ Window Event Handlers .............. */ + + /** + * Implement nsIDOMEventListener for handling various window and tab events + */ + handleEvent: function ssi_handleEvent(aEvent) { + let win = aEvent.currentTarget.ownerGlobal; + let target = aEvent.originalTarget; + switch (aEvent.type) { + case "TabOpen": + this.onTabAdd(win); + break; + case "TabBrowserInserted": + this.onTabBrowserInserted(win, target); + break; + case "TabClose": + // `adoptedBy` will be set if the tab was closed because it is being + // moved to a new window. + if (!aEvent.detail.adoptedBy) + this.onTabClose(win, target); + this.onTabRemove(win, target); + break; + case "TabSelect": + this.onTabSelect(win); + break; + case "TabShow": + this.onTabShow(win, target); + break; + case "TabHide": + this.onTabHide(win, target); + break; + case "TabPinned": + case "TabUnpinned": + case "SwapDocShells": + this.saveStateDelayed(win); + break; + case "oop-browser-crashed": + this.onBrowserCrashed(target); + break; + case "XULFrameLoaderCreated": + if (target.namespaceURI == NS_XUL && + target.localName == "browser" && + target.frameLoader && + target.permanentKey) { + this._lastKnownFrameLoader.set(target.permanentKey, target.frameLoader); + this.resetEpoch(target); + } + break; + default: + throw new Error(`unhandled event ${aEvent.type}?`); + } + this._clearRestoringWindows(); + }, + + /** + * Generate a unique window identifier + * @return string + * A unique string to identify a window + */ + _generateWindowID: function ssi_generateWindowID() { + return "window" + (this._nextWindowID++); + }, + + /** + * Registers and tracks a given window. + * + * @param aWindow + * Window reference + */ + onLoad(aWindow) { + // return if window has already been initialized + if (aWindow && aWindow.__SSi && this._windows[aWindow.__SSi]) + return; + + // ignore windows opened while shutting down + if (RunState.isQuitting) + return; + + // Assign the window a unique identifier we can use to reference + // internal data about the window. + aWindow.__SSi = this._generateWindowID(); + + let mm = aWindow.getGroupMessageManager("browsers"); + MESSAGES.forEach(msg => { + let listenWhenClosed = CLOSED_MESSAGES.has(msg); + mm.addMessageListener(msg, this, listenWhenClosed); + }); + + // Load the frame script after registering listeners. + mm.loadFrameScript("chrome://browser/content/content-sessionStore.js", true); + + // and create its data object + this._windows[aWindow.__SSi] = { tabs: [], selected: 0, _closedTabs: [], busy: false }; + + if (PrivateBrowsingUtils.isWindowPrivate(aWindow)) + this._windows[aWindow.__SSi].isPrivate = true; + if (!this._isWindowLoaded(aWindow)) + this._windows[aWindow.__SSi]._restoring = true; + if (!aWindow.toolbar.visible) + this._windows[aWindow.__SSi].isPopup = true; + + let tabbrowser = aWindow.gBrowser; + + // add tab change listeners to all already existing tabs + for (let i = 0; i < tabbrowser.tabs.length; i++) { + this.onTabBrowserInserted(aWindow, tabbrowser.tabs[i]); + } + // notification of tab add/remove/selection/show/hide + TAB_EVENTS.forEach(function(aEvent) { + tabbrowser.tabContainer.addEventListener(aEvent, this, true); + }, this); + + // Keep track of a browser's latest frameLoader. + aWindow.gBrowser.addEventListener("XULFrameLoaderCreated", this); + }, + + /** + * Initializes a given window. + * + * Windows are registered as soon as they are created but we need to wait for + * the session file to load, and the initial window's delayed startup to + * finish before initializing a window, i.e. restoring data into it. + * + * @param aWindow + * Window reference + * @param aInitialState + * The initial state to be loaded after startup (optional) + */ + initializeWindow(aWindow, aInitialState = null) { + let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow); + + // perform additional initialization when the first window is loading + if (RunState.isStopped) { + RunState.setRunning(); + + // restore a crashed session resp. resume the last session if requested + if (aInitialState) { + // Don't write to disk right after startup. Set the last time we wrote + // to disk to NOW() to enforce a full interval before the next write. + SessionSaver.updateLastSaveTime(); + + if (isPrivateWindow) { + // We're starting with a single private window. Save the state we + // actually wanted to restore so that we can do it later in case + // the user opens another, non-private window. + this._deferredInitialState = gSessionStartup.state; + + // Nothing to restore now, notify observers things are complete. + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED, ""); + } else { + TelemetryTimestamps.add("sessionRestoreRestoring"); + this._restoreCount = aInitialState.windows ? aInitialState.windows.length : 0; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(aInitialState); + + let overwrite = this._isCmdLineEmpty(aWindow, aInitialState); + let options = {firstWindow: true, overwriteTabs: overwrite}; + this.restoreWindows(aWindow, aInitialState, options); + } + } + else { + // Nothing to restore, notify observers things are complete. + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED, ""); + } + } + // this window was opened by _openWindowWithState + else if (!this._isWindowLoaded(aWindow)) { + let state = this._statesToRestore[aWindow.__SS_restoreID]; + let options = {overwriteTabs: true, isFollowUp: state.windows.length == 1}; + this.restoreWindow(aWindow, state.windows[0], options); + } + // The user opened another, non-private window after starting up with + // a single private one. Let's restore the session we actually wanted to + // restore at startup. + else if (this._deferredInitialState && !isPrivateWindow && + aWindow.toolbar.visible) { + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(this._deferredInitialState); + + this._restoreCount = this._deferredInitialState.windows ? + this._deferredInitialState.windows.length : 0; + this.restoreWindows(aWindow, this._deferredInitialState, {firstWindow: true}); + this._deferredInitialState = null; + } + else if (this._restoreLastWindow && aWindow.toolbar.visible && + this._closedWindows.length && !isPrivateWindow) { + + // default to the most-recently closed window + // don't use popup windows + let closedWindowState = null; + let closedWindowIndex; + for (let i = 0; i < this._closedWindows.length; i++) { + // Take the first non-popup, point our object at it, and break out. + if (!this._closedWindows[i].isPopup) { + closedWindowState = this._closedWindows[i]; + closedWindowIndex = i; + break; + } + } + + if (closedWindowState) { + let newWindowState; + if (AppConstants.platform == "macosx" || !this._doResumeSession()) { + // We want to split the window up into pinned tabs and unpinned tabs. + // Pinned tabs should be restored. If there are any remaining tabs, + // they should be added back to _closedWindows. + // We'll cheat a little bit and reuse _prepDataForDeferredRestore + // even though it wasn't built exactly for this. + let [appTabsState, normalTabsState] = + this._prepDataForDeferredRestore({ windows: [closedWindowState] }); + + // These are our pinned tabs, which we should restore + if (appTabsState.windows.length) { + newWindowState = appTabsState.windows[0]; + delete newWindowState.__lastSessionWindowID; + } + + // In case there were no unpinned tabs, remove the window from _closedWindows + if (!normalTabsState.windows.length) { + this._closedWindows.splice(closedWindowIndex, 1); + } + // Or update _closedWindows with the modified state + else { + delete normalTabsState.windows[0].__lastSessionWindowID; + this._closedWindows[closedWindowIndex] = normalTabsState.windows[0]; + } + } + else { + // If we're just restoring the window, make sure it gets removed from + // _closedWindows. + this._closedWindows.splice(closedWindowIndex, 1); + newWindowState = closedWindowState; + delete newWindowState.hidden; + } + + if (newWindowState) { + // Ensure that the window state isn't hidden + this._restoreCount = 1; + let state = { windows: [newWindowState] }; + let options = {overwriteTabs: this._isCmdLineEmpty(aWindow, state)}; + this.restoreWindow(aWindow, newWindowState, options); + } + } + // we actually restored the session just now. + this._prefBranch.setBoolPref("sessionstore.resume_session_once", false); + } + if (this._restoreLastWindow && aWindow.toolbar.visible) { + // always reset (if not a popup window) + // we don't want to restore a window directly after, for example, + // undoCloseWindow was executed. + this._restoreLastWindow = false; + } + }, + + /** + * Called right before a new browser window is shown. + * @param aWindow + * Window reference + */ + onBeforeBrowserWindowShown: function (aWindow) { + // Register the window. + this.onLoad(aWindow); + + // Just call initializeWindow() directly if we're initialized already. + if (this._sessionInitialized) { + this.initializeWindow(aWindow); + return; + } + + // The very first window that is opened creates a promise that is then + // re-used by all subsequent windows. The promise will be used to tell + // when we're ready for initialization. + if (!this._promiseReadyForInitialization) { + // Wait for the given window's delayed startup to be finished. + let promise = new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic) { + if (aWindow == subject) { + Services.obs.removeObserver(obs, topic); + resolve(); + } + }, "browser-delayed-startup-finished", false); + }); + + // We are ready for initialization as soon as the session file has been + // read from disk and the initial window's delayed startup has finished. + this._promiseReadyForInitialization = + Promise.all([promise, gSessionStartup.onceInitialized]); + } + + // We can't call this.onLoad since initialization + // hasn't completed, so we'll wait until it is done. + // Even if additional windows are opened and wait + // for initialization as well, the first opened + // window should execute first, and this.onLoad + // will be called with the initialState. + this._promiseReadyForInitialization.then(() => { + if (aWindow.closed) { + return; + } + + if (this._sessionInitialized) { + this.initializeWindow(aWindow); + } else { + let initialState = this.initSession(); + this._sessionInitialized = true; + + if (initialState) { + Services.obs.notifyObservers(null, NOTIFY_RESTORING_ON_STARTUP, ""); + } + TelemetryStopwatch.start("FX_SESSION_RESTORE_STARTUP_ONLOAD_INITIAL_WINDOW_MS"); + this.initializeWindow(aWindow, initialState); + TelemetryStopwatch.finish("FX_SESSION_RESTORE_STARTUP_ONLOAD_INITIAL_WINDOW_MS"); + + // Let everyone know we're done. + this._deferredInitialized.resolve(); + } + }, console.error); + }, + + /** + * On window close... + * - remove event listeners from tabs + * - save all window data + * @param aWindow + * Window reference + */ + onClose: function ssi_onClose(aWindow) { + // this window was about to be restored - conserve its original data, if any + let isFullyLoaded = this._isWindowLoaded(aWindow); + if (!isFullyLoaded) { + if (!aWindow.__SSi) { + aWindow.__SSi = this._generateWindowID(); + } + + this._windows[aWindow.__SSi] = this._statesToRestore[aWindow.__SS_restoreID]; + delete this._statesToRestore[aWindow.__SS_restoreID]; + delete aWindow.__SS_restoreID; + } + + // ignore windows not tracked by SessionStore + if (!aWindow.__SSi || !this._windows[aWindow.__SSi]) { + return; + } + + // notify that the session store will stop tracking this window so that + // extensions can store any data about this window in session store before + // that's not possible anymore + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowClosing", true, false); + aWindow.dispatchEvent(event); + + if (this.windowToFocus && this.windowToFocus == aWindow) { + delete this.windowToFocus; + } + + var tabbrowser = aWindow.gBrowser; + + let browsers = Array.from(tabbrowser.browsers); + + TAB_EVENTS.forEach(function(aEvent) { + tabbrowser.tabContainer.removeEventListener(aEvent, this, true); + }, this); + + aWindow.gBrowser.removeEventListener("XULFrameLoaderCreated", this); + + let winData = this._windows[aWindow.__SSi]; + + // Collect window data only when *not* closed during shutdown. + if (RunState.isRunning) { + // Grab the most recent window data. The tab data will be updated + // once we finish flushing all of the messages from the tabs. + let tabMap = this._collectWindowData(aWindow); + + for (let [tab, tabData] of tabMap) { + let permanentKey = tab.linkedBrowser.permanentKey; + this._closedWindowTabs.set(permanentKey, tabData); + } + + if (isFullyLoaded) { + winData.title = tabbrowser.selectedBrowser.contentTitle || tabbrowser.selectedTab.label; + winData.title = this._replaceLoadingTitle(winData.title, tabbrowser, + tabbrowser.selectedTab); + SessionCookies.update([winData]); + } + + if (AppConstants.platform != "macosx") { + // Until we decide otherwise elsewhere, this window is part of a series + // of closing windows to quit. + winData._shouldRestore = true; + } + + // Store the window's close date to figure out when each individual tab + // was closed. This timestamp should allow re-arranging data based on how + // recently something was closed. + winData.closedAt = Date.now(); + + // we don't want to save the busy state + delete winData.busy; + + // When closing windows one after the other until Firefox quits, we + // will move those closed in series back to the "open windows" bucket + // before writing to disk. If however there is only a single window + // with tabs we deem not worth saving then we might end up with a + // random closed or even a pop-up window re-opened. To prevent that + // we explicitly allow saving an "empty" window state. + let isLastWindow = + Object.keys(this._windows).length == 1 && + !this._closedWindows.some(win => win._shouldRestore || false); + + // clear this window from the list, since it has definitely been closed. + delete this._windows[aWindow.__SSi]; + + // This window has the potential to be saved in the _closedWindows + // array (maybeSaveClosedWindows gets the final call on that). + this._saveableClosedWindowData.add(winData); + + // Now we have to figure out if this window is worth saving in the _closedWindows + // Object. + // + // We're about to flush the tabs from this window, but it's possible that we + // might never hear back from the content process(es) in time before the user + // chooses to restore the closed window. So we do the following: + // + // 1) Use the tab state cache to determine synchronously if the window is + // worth stashing in _closedWindows. + // 2) Flush the window. + // 3) When the flush is complete, revisit our decision to store the window + // in _closedWindows, and add/remove as necessary. + if (!winData.isPrivate) { + // Remove any open private tabs the window may contain. + PrivacyFilter.filterPrivateTabs(winData); + this.maybeSaveClosedWindow(winData, isLastWindow); + } + + TabStateFlusher.flushWindow(aWindow).then(() => { + // At this point, aWindow is closed! You should probably not try to + // access any DOM elements from aWindow within this callback unless + // you're holding on to them in the closure. + + for (let browser of browsers) { + if (this._closedWindowTabs.has(browser.permanentKey)) { + let tabData = this._closedWindowTabs.get(browser.permanentKey); + TabState.copyFromCache(browser, tabData); + this._closedWindowTabs.delete(browser.permanentKey); + } + } + + // Save non-private windows if they have at + // least one saveable tab or are the last window. + if (!winData.isPrivate) { + // It's possible that a tab switched its privacy state at some point + // before our flush, so we need to filter again. + PrivacyFilter.filterPrivateTabs(winData); + this.maybeSaveClosedWindow(winData, isLastWindow); + } + + // Update the tabs data now that we've got the most + // recent information. + this.cleanUpWindow(aWindow, winData, browsers); + + // save the state without this window to disk + this.saveStateDelayed(); + }); + } else { + this.cleanUpWindow(aWindow, winData, browsers); + } + + for (let i = 0; i < tabbrowser.tabs.length; i++) { + this.onTabRemove(aWindow, tabbrowser.tabs[i], true); + } + }, + + /** + * Clean up the message listeners on a window that has finally + * gone away. Call this once you're sure you don't want to hear + * from any of this windows tabs from here forward. + * + * @param aWindow + * The browser window we're cleaning up. + * @param winData + * The data for the window that we should hold in the + * DyingWindowCache in case anybody is still holding a + * reference to it. + */ + cleanUpWindow(aWindow, winData, browsers) { + // Any leftover TabStateFlusher Promises need to be resolved now, + // since we're about to remove the message listeners. + for (let browser of browsers) { + TabStateFlusher.resolveAll(browser); + } + + // Cache the window state until it is completely gone. + DyingWindowCache.set(aWindow, winData); + + let mm = aWindow.getGroupMessageManager("browsers"); + MESSAGES.forEach(msg => mm.removeMessageListener(msg, this)); + + this._saveableClosedWindowData.delete(winData); + delete aWindow.__SSi; + }, + + /** + * Decides whether or not a closed window should be put into the + * _closedWindows Object. This might be called multiple times per + * window, and will do the right thing of moving the window data + * in or out of _closedWindows if the winData indicates that our + * need for saving it has changed. + * + * @param winData + * The data for the closed window that we might save. + * @param isLastWindow + * Whether or not the window being closed is the last + * browser window. Callers of this function should pass + * in the value of SessionStoreInternal.atLastWindow for + * this argument, and pass in the same value if they happen + * to call this method again asynchronously (for example, after + * a window flush). + */ + maybeSaveClosedWindow(winData, isLastWindow) { + // Make sure SessionStore is still running, and make sure that we + // haven't chosen to forget this window. + if (RunState.isRunning && this._saveableClosedWindowData.has(winData)) { + // Determine whether the window has any tabs worth saving. + let hasSaveableTabs = winData.tabs.some(this._shouldSaveTabState); + + // Note that we might already have this window stored in + // _closedWindows from a previous call to this function. + let winIndex = this._closedWindows.indexOf(winData); + let alreadyStored = (winIndex != -1); + let shouldStore = (hasSaveableTabs || isLastWindow); + + if (shouldStore && !alreadyStored) { + let index = this._closedWindows.findIndex(win => { + return win.closedAt < winData.closedAt; + }); + + // If we found no tab closed before our + // tab then just append it to the list. + if (index == -1) { + index = this._closedWindows.length; + } + + // About to save the closed window, add a unique ID. + winData.closedId = this._nextClosedId++; + + // Insert tabData at the right position. + this._closedWindows.splice(index, 0, winData); + this._capClosedWindows(); + } else if (!shouldStore && alreadyStored) { + this._closedWindows.splice(winIndex, 1); + } + } + }, + + /** + * On quit application granted + */ + onQuitApplicationGranted: function ssi_onQuitApplicationGranted(syncShutdown=false) { + // Collect an initial snapshot of window data before we do the flush + this._forEachBrowserWindow((win) => { + this._collectWindowData(win); + }); + + // Now add an AsyncShutdown blocker that'll spin the event loop + // until the windows have all been flushed. + + // This progress object will track the state of async window flushing + // and will help us debug things that go wrong with our AsyncShutdown + // blocker. + let progress = { total: -1, current: -1 }; + + // We're going down! Switch state so that we treat closing windows and + // tabs correctly. + RunState.setQuitting(); + + if (!syncShutdown) { + // We've got some time to shut down, so let's do this properly. + // To prevent blocker from breaking the 60 sec limit(which will cause a + // crash) of async shutdown during flushing all windows, we resolve the + // promise passed to blocker once: + // 1. the flushing exceed 50 sec, or + // 2. 'oop-frameloader-crashed' or 'ipc:content-shutdown' is observed. + // Thus, Firefox still can open the last session on next startup. + AsyncShutdown.quitApplicationGranted.addBlocker( + "SessionStore: flushing all windows", + () => { + var promises = []; + promises.push(this.flushAllWindowsAsync(progress)); + promises.push(this.looseTimer(50000)); + + var promiseOFC = new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic) { + Services.obs.removeObserver(obs, topic); + resolve(); + }, "oop-frameloader-crashed", false); + }); + promises.push(promiseOFC); + + var promiseICS = new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic) { + Services.obs.removeObserver(obs, topic); + resolve(); + }, "ipc:content-shutdown", false); + }); + promises.push(promiseICS); + + return Promise.race(promises); + }, + () => progress); + } else { + // We have to shut down NOW, which means we only get to save whatever + // we already had cached. + } + }, + + /** + * An async Task that iterates all open browser windows and flushes + * any outstanding messages from their tabs. This will also close + * all of the currently open windows while we wait for the flushes + * to complete. + * + * @param progress (Object) + * Optional progress object that will be updated as async + * window flushing progresses. flushAllWindowsSync will + * write to the following properties: + * + * total (int): + * The total number of windows to be flushed. + * current (int): + * The current window that we're waiting for a flush on. + * + * @return Promise + */ + flushAllWindowsAsync: Task.async(function*(progress={}) { + let windowPromises = new Map(); + // We collect flush promises and close each window immediately so that + // the user can't start changing any window state while we're waiting + // for the flushes to finish. + this._forEachBrowserWindow((win) => { + windowPromises.set(win, TabStateFlusher.flushWindow(win)); + + // We have to wait for these messages to come up from + // each window and each browser. In the meantime, hide + // the windows to improve perceived shutdown speed. + let baseWin = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIDocShellTreeItem) + .treeOwner + .QueryInterface(Ci.nsIBaseWindow); + baseWin.visibility = false; + }); + + progress.total = windowPromises.size; + progress.current = 0; + + // We'll iterate through the Promise array, yielding each one, so as to + // provide useful progress information to AsyncShutdown. + for (let [win, promise] of windowPromises) { + yield promise; + this._collectWindowData(win); + progress.current++; + }; + + // We must cache this because _getMostRecentBrowserWindow will always + // return null by the time quit-application occurs. + var activeWindow = this._getMostRecentBrowserWindow(); + if (activeWindow) + this.activeWindowSSiCache = activeWindow.__SSi || ""; + DirtyWindows.clear(); + }), + + /** + * On last browser window close + */ + onLastWindowCloseGranted: function ssi_onLastWindowCloseGranted() { + // last browser window is quitting. + // remember to restore the last window when another browser window is opened + // do not account for pref(resume_session_once) at this point, as it might be + // set by another observer getting this notice after us + this._restoreLastWindow = true; + }, + + /** + * On quitting application + * @param aData + * String type of quitting + */ + onQuitApplication: function ssi_onQuitApplication(aData) { + if (aData == "restart") { + this._prefBranch.setBoolPref("sessionstore.resume_session_once", true); + // The browser:purge-session-history notification fires after the + // quit-application notification so unregister the + // browser:purge-session-history notification to prevent clearing + // session data on disk on a restart. It is also unnecessary to + // perform any other sanitization processing on a restart as the + // browser is about to exit anyway. + Services.obs.removeObserver(this, "browser:purge-session-history"); + } + + if (aData != "restart") { + // Throw away the previous session on shutdown + LastSession.clear(); + } + + this._uninit(); + }, + + /** + * On purge of session history + */ + onPurgeSessionHistory: function ssi_onPurgeSessionHistory() { + SessionFile.wipe(); + // If the browser is shutting down, simply return after clearing the + // session data on disk as this notification fires after the + // quit-application notification so the browser is about to exit. + if (RunState.isQuitting) + return; + LastSession.clear(); + + let openWindows = {}; + // Collect open windows. + this._forEachBrowserWindow(({__SSi: id}) => openWindows[id] = true); + + // also clear all data about closed tabs and windows + for (let ix in this._windows) { + if (ix in openWindows) { + this._windows[ix]._closedTabs = []; + } else { + delete this._windows[ix]; + } + } + // also clear all data about closed windows + this._closedWindows = []; + // give the tabbrowsers a chance to clear their histories first + var win = this._getMostRecentBrowserWindow(); + if (win) { + win.setTimeout(() => SessionSaver.run(), 0); + } else if (RunState.isRunning) { + SessionSaver.run(); + } + + this._clearRestoringWindows(); + this._saveableClosedWindowData = new WeakSet(); + }, + + /** + * On purge of domain data + * @param aData + * String domain data + */ + onPurgeDomainData: function ssi_onPurgeDomainData(aData) { + // does a session history entry contain a url for the given domain? + function containsDomain(aEntry) { + if (Utils.hasRootDomain(aEntry.url, aData)) { + return true; + } + return aEntry.children && aEntry.children.some(containsDomain, this); + } + // remove all closed tabs containing a reference to the given domain + for (let ix in this._windows) { + let closedTabs = this._windows[ix]._closedTabs; + for (let i = closedTabs.length - 1; i >= 0; i--) { + if (closedTabs[i].state.entries.some(containsDomain, this)) + closedTabs.splice(i, 1); + } + } + // remove all open & closed tabs containing a reference to the given + // domain in closed windows + for (let ix = this._closedWindows.length - 1; ix >= 0; ix--) { + let closedTabs = this._closedWindows[ix]._closedTabs; + let openTabs = this._closedWindows[ix].tabs; + let openTabCount = openTabs.length; + for (let i = closedTabs.length - 1; i >= 0; i--) + if (closedTabs[i].state.entries.some(containsDomain, this)) + closedTabs.splice(i, 1); + for (let j = openTabs.length - 1; j >= 0; j--) { + if (openTabs[j].entries.some(containsDomain, this)) { + openTabs.splice(j, 1); + if (this._closedWindows[ix].selected > j) + this._closedWindows[ix].selected--; + } + } + if (openTabs.length == 0) { + this._closedWindows.splice(ix, 1); + } + else if (openTabs.length != openTabCount) { + // Adjust the window's title if we removed an open tab + let selectedTab = openTabs[this._closedWindows[ix].selected - 1]; + // some duplication from restoreHistory - make sure we get the correct title + let activeIndex = (selectedTab.index || selectedTab.entries.length) - 1; + if (activeIndex >= selectedTab.entries.length) + activeIndex = selectedTab.entries.length - 1; + this._closedWindows[ix].title = selectedTab.entries[activeIndex].title; + } + } + + if (RunState.isRunning) { + SessionSaver.run(); + } + + this._clearRestoringWindows(); + }, + + /** + * On preference change + * @param aData + * String preference changed + */ + onPrefChange: function ssi_onPrefChange(aData) { + switch (aData) { + // if the user decreases the max number of closed tabs they want + // preserved update our internal states to match that max + case "sessionstore.max_tabs_undo": + this._max_tabs_undo = this._prefBranch.getIntPref("sessionstore.max_tabs_undo"); + for (let ix in this._windows) { + this._windows[ix]._closedTabs.splice(this._max_tabs_undo, this._windows[ix]._closedTabs.length); + } + break; + case "sessionstore.max_windows_undo": + this._max_windows_undo = this._prefBranch.getIntPref("sessionstore.max_windows_undo"); + this._capClosedWindows(); + break; + } + }, + + /** + * save state when new tab is added + * @param aWindow + * Window reference + */ + onTabAdd: function ssi_onTabAdd(aWindow) { + this.saveStateDelayed(aWindow); + }, + + /** + * set up listeners for a new tab + * @param aWindow + * Window reference + * @param aTab + * Tab reference + */ + onTabBrowserInserted: function ssi_onTabBrowserInserted(aWindow, aTab) { + let browser = aTab.linkedBrowser; + browser.addEventListener("SwapDocShells", this); + browser.addEventListener("oop-browser-crashed", this); + + if (browser.frameLoader) { + this._lastKnownFrameLoader.set(browser.permanentKey, browser.frameLoader); + } + }, + + /** + * remove listeners for a tab + * @param aWindow + * Window reference + * @param aTab + * Tab reference + * @param aNoNotification + * bool Do not save state if we're updating an existing tab + */ + onTabRemove: function ssi_onTabRemove(aWindow, aTab, aNoNotification) { + let browser = aTab.linkedBrowser; + browser.removeEventListener("SwapDocShells", this); + browser.removeEventListener("oop-browser-crashed", this); + + // If this tab was in the middle of restoring or still needs to be restored, + // we need to reset that state. If the tab was restoring, we will attempt to + // restore the next tab. + let previousState = browser.__SS_restoreState; + if (previousState) { + this._resetTabRestoringState(aTab); + if (previousState == TAB_STATE_RESTORING) + this.restoreNextTab(); + } + + if (!aNoNotification) { + this.saveStateDelayed(aWindow); + } + }, + + /** + * When a tab closes, collect its properties + * @param aWindow + * Window reference + * @param aTab + * Tab reference + */ + onTabClose: function ssi_onTabClose(aWindow, aTab) { + // notify the tabbrowser that the tab state will be retrieved for the last time + // (so that extension authors can easily set data on soon-to-be-closed tabs) + var event = aWindow.document.createEvent("Events"); + event.initEvent("SSTabClosing", true, false); + aTab.dispatchEvent(event); + + // don't update our internal state if we don't have to + if (this._max_tabs_undo == 0) { + return; + } + + // Get the latest data for this tab (generally, from the cache) + let tabState = TabState.collect(aTab); + + // Don't save private tabs + let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow); + if (!isPrivateWindow && tabState.isPrivate) { + return; + } + + // Store closed-tab data for undo. + let tabbrowser = aWindow.gBrowser; + let tabTitle = this._replaceLoadingTitle(aTab.label, tabbrowser, aTab); + let {permanentKey} = aTab.linkedBrowser; + + let tabData = { + permanentKey, + state: tabState, + title: tabTitle, + image: tabbrowser.getIcon(aTab), + iconLoadingPrincipal: Utils.serializePrincipal(aTab.linkedBrowser.contentPrincipal), + pos: aTab._tPos, + closedAt: Date.now() + }; + + let closedTabs = this._windows[aWindow.__SSi]._closedTabs; + + // Determine whether the tab contains any information worth saving. Note + // that there might be pending state changes queued in the child that + // didn't reach the parent yet. If a tab is emptied before closing then we + // might still remove it from the list of closed tabs later. + if (this._shouldSaveTabState(tabState)) { + // Save the tab state, for now. We might push a valid tab out + // of the list but those cases should be extremely rare and + // do probably never occur when using the browser normally. + // (Tests or add-ons might do weird things though.) + this.saveClosedTabData(closedTabs, tabData); + } + + // Remember the closed tab to properly handle any last updates included in + // the final "update" message sent by the frame script's unload handler. + this._closedTabs.set(permanentKey, {closedTabs, tabData}); + }, + + /** + * Insert a given |tabData| object into the list of |closedTabs|. We will + * determine the right insertion point based on the .closedAt properties of + * all tabs already in the list. The list will be truncated to contain a + * maximum of |this._max_tabs_undo| entries. + * + * @param closedTabs (array) + * The list of closed tabs for a window. + * @param tabData (object) + * The tabData to be inserted. + */ + saveClosedTabData(closedTabs, tabData) { + // Find the index of the first tab in the list + // of closed tabs that was closed before our tab. + let index = closedTabs.findIndex(tab => { + return tab.closedAt < tabData.closedAt; + }); + + // If we found no tab closed before our + // tab then just append it to the list. + if (index == -1) { + index = closedTabs.length; + } + + // About to save the closed tab, add a unique ID. + tabData.closedId = this._nextClosedId++; + + // Insert tabData at the right position. + closedTabs.splice(index, 0, tabData); + + // Truncate the list of closed tabs, if needed. + if (closedTabs.length > this._max_tabs_undo) { + closedTabs.splice(this._max_tabs_undo, closedTabs.length); + } + }, + + /** + * Remove the closed tab data at |index| from the list of |closedTabs|. If + * the tab's final message is still pending we will simply discard it when + * it arrives so that the tab doesn't reappear in the list. + * + * @param closedTabs (array) + * The list of closed tabs for a window. + * @param index (uint) + * The index of the tab to remove. + */ + removeClosedTabData(closedTabs, index) { + // Remove the given index from the list. + let [closedTab] = closedTabs.splice(index, 1); + + // If the closed tab's state still has a .permanentKey property then we + // haven't seen its final update message yet. Remove it from the map of + // closed tabs so that we will simply discard its last messages and will + // not add it back to the list of closed tabs again. + if (closedTab.permanentKey) { + this._closedTabs.delete(closedTab.permanentKey); + this._closedWindowTabs.delete(closedTab.permanentKey); + delete closedTab.permanentKey; + } + + return closedTab; + }, + + /** + * When a tab is selected, save session data + * @param aWindow + * Window reference + */ + onTabSelect: function ssi_onTabSelect(aWindow) { + if (RunState.isRunning) { + this._windows[aWindow.__SSi].selected = aWindow.gBrowser.tabContainer.selectedIndex; + + let tab = aWindow.gBrowser.selectedTab; + let browser = tab.linkedBrowser; + + if (browser.__SS_restoreState && + browser.__SS_restoreState == TAB_STATE_NEEDS_RESTORE) { + // If __SS_restoreState is still on the browser and it is + // TAB_STATE_NEEDS_RESTORE, then then we haven't restored + // this tab yet. + // + // It's possible that this tab was recently revived, and that + // we've deferred showing the tab crashed page for it (if the + // tab crashed in the background). If so, we need to re-enter + // the crashed state, since we'll be showing the tab crashed + // page. + if (TabCrashHandler.willShowCrashedTab(browser)) { + this.enterCrashedState(browser); + } else { + this.restoreTabContent(tab); + } + } + } + }, + + onTabShow: function ssi_onTabShow(aWindow, aTab) { + // If the tab hasn't been restored yet, move it into the right bucket + if (aTab.linkedBrowser.__SS_restoreState && + aTab.linkedBrowser.__SS_restoreState == TAB_STATE_NEEDS_RESTORE) { + TabRestoreQueue.hiddenToVisible(aTab); + + // let's kick off tab restoration again to ensure this tab gets restored + // with "restore_hidden_tabs" == false (now that it has become visible) + this.restoreNextTab(); + } + + // Default delay of 2 seconds gives enough time to catch multiple TabShow + // events. This used to be due to changing groups in 'tab groups'. We + // might be able to get rid of this now? + this.saveStateDelayed(aWindow); + }, + + onTabHide: function ssi_onTabHide(aWindow, aTab) { + // If the tab hasn't been restored yet, move it into the right bucket + if (aTab.linkedBrowser.__SS_restoreState && + aTab.linkedBrowser.__SS_restoreState == TAB_STATE_NEEDS_RESTORE) { + TabRestoreQueue.visibleToHidden(aTab); + } + + // Default delay of 2 seconds gives enough time to catch multiple TabHide + // events. This used to be due to changing groups in 'tab groups'. We + // might be able to get rid of this now? + this.saveStateDelayed(aWindow); + }, + + /** + * Handler for the event that is fired when a <xul:browser> crashes. + * + * @param aWindow + * The window that the crashed browser belongs to. + * @param aBrowser + * The <xul:browser> that is now in the crashed state. + */ + onBrowserCrashed: function(aBrowser) { + NS_ASSERT(aBrowser.isRemoteBrowser, + "Only remote browsers should be able to crash"); + + this.enterCrashedState(aBrowser); + // The browser crashed so we might never receive flush responses. + // Resolve all pending flush requests for the crashed browser. + TabStateFlusher.resolveAll(aBrowser); + }, + + /** + * Called when a browser is showing or is about to show the tab + * crashed page. This method causes SessionStore to ignore the + * tab until it's restored. + * + * @param browser + * The <xul:browser> that is about to show the crashed page. + */ + enterCrashedState(browser) { + this._crashedBrowsers.add(browser.permanentKey); + + let win = browser.ownerGlobal; + + // If we hadn't yet restored, or were still in the midst of + // restoring this browser at the time of the crash, we need + // to reset its state so that we can try to restore it again + // when the user revives the tab from the crash. + if (browser.__SS_restoreState) { + let tab = win.gBrowser.getTabForBrowser(browser); + this._resetLocalTabRestoringState(tab); + } + }, + + // Clean up data that has been closed a long time ago. + // Do not reschedule a save. This will wait for the next regular + // save. + onIdleDaily: function() { + // Remove old closed windows + this._cleanupOldData([this._closedWindows]); + + // Remove closed tabs of closed windows + this._cleanupOldData(this._closedWindows.map((winData) => winData._closedTabs)); + + // Remove closed tabs of open windows + this._cleanupOldData(Object.keys(this._windows).map((key) => this._windows[key]._closedTabs)); + }, + + // Remove "old" data from an array + _cleanupOldData: function(targets) { + const TIME_TO_LIVE = this._prefBranch.getIntPref("sessionstore.cleanup.forget_closed_after"); + const now = Date.now(); + + for (let array of targets) { + for (let i = array.length - 1; i >= 0; --i) { + let data = array[i]; + // Make sure that we have a timestamp to tell us when the target + // has been closed. If we don't have a timestamp, default to a + // safe timestamp: just now. + data.closedAt = data.closedAt || now; + if (now - data.closedAt > TIME_TO_LIVE) { + array.splice(i, 1); + } + } + } + }, + + /* ........ nsISessionStore API .............. */ + + getBrowserState: function ssi_getBrowserState() { + let state = this.getCurrentState(); + + // Don't include the last session state in getBrowserState(). + delete state.lastSessionState; + + // Don't include any deferred initial state. + delete state.deferredInitialState; + + return JSON.stringify(state); + }, + + setBrowserState: function ssi_setBrowserState(aState) { + this._handleClosedWindows(); + + try { + var state = JSON.parse(aState); + } + catch (ex) { /* invalid state object - don't restore anything */ } + if (!state) { + throw Components.Exception("Invalid state string: not JSON", Cr.NS_ERROR_INVALID_ARG); + } + if (!state.windows) { + throw Components.Exception("No windows", Cr.NS_ERROR_INVALID_ARG); + } + + this._browserSetState = true; + + // Make sure the priority queue is emptied out + this._resetRestoringState(); + + var window = this._getMostRecentBrowserWindow(); + if (!window) { + this._restoreCount = 1; + this._openWindowWithState(state); + return; + } + + // close all other browser windows + this._forEachBrowserWindow(function(aWindow) { + if (aWindow != window) { + aWindow.close(); + this.onClose(aWindow); + } + }); + + // make sure closed window data isn't kept + this._closedWindows = []; + + // determine how many windows are meant to be restored + this._restoreCount = state.windows ? state.windows.length : 0; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(state); + + // restore to the given state + this.restoreWindows(window, state, {overwriteTabs: true}); + }, + + getWindowState: function ssi_getWindowState(aWindow) { + if ("__SSi" in aWindow) { + return JSON.stringify(this._getWindowState(aWindow)); + } + + if (DyingWindowCache.has(aWindow)) { + let data = DyingWindowCache.get(aWindow); + return JSON.stringify({ windows: [data] }); + } + + throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG); + }, + + setWindowState: function ssi_setWindowState(aWindow, aState, aOverwrite) { + if (!aWindow.__SSi) { + throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + + this.restoreWindows(aWindow, aState, {overwriteTabs: aOverwrite}); + }, + + getTabState: function ssi_getTabState(aTab) { + if (!aTab.ownerGlobal.__SSi) { + throw Components.Exception("Default view is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + + let tabState = TabState.collect(aTab); + + return JSON.stringify(tabState); + }, + + setTabState(aTab, aState) { + // Remove the tab state from the cache. + // Note that we cannot simply replace the contents of the cache + // as |aState| can be an incomplete state that will be completed + // by |restoreTabs|. + let tabState = JSON.parse(aState); + if (!tabState) { + throw Components.Exception("Invalid state string: not JSON", Cr.NS_ERROR_INVALID_ARG); + } + if (typeof tabState != "object") { + throw Components.Exception("Not an object", Cr.NS_ERROR_INVALID_ARG); + } + if (!("entries" in tabState)) { + throw Components.Exception("Invalid state object: no entries", Cr.NS_ERROR_INVALID_ARG); + } + + let window = aTab.ownerGlobal; + if (!("__SSi" in window)) { + throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + + if (aTab.linkedBrowser.__SS_restoreState) { + this._resetTabRestoringState(aTab); + } + + this.restoreTab(aTab, tabState); + }, + + duplicateTab: function ssi_duplicateTab(aWindow, aTab, aDelta = 0, aRestoreImmediately = true) { + if (!aTab.ownerGlobal.__SSi) { + throw Components.Exception("Default view is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + if (!aWindow.gBrowser) { + throw Components.Exception("Invalid window object: no gBrowser", Cr.NS_ERROR_INVALID_ARG); + } + + // Create a new tab. + let userContextId = aTab.getAttribute("usercontextid"); + let newTab = aTab == aWindow.gBrowser.selectedTab ? + aWindow.gBrowser.addTab(null, {relatedToCurrent: true, ownerTab: aTab, userContextId}) : + aWindow.gBrowser.addTab(null, {userContextId}); + + // Set tab title to "Connecting..." and start the throbber to pretend we're + // doing something while actually waiting for data from the frame script. + aWindow.gBrowser.setTabTitleLoading(newTab); + newTab.setAttribute("busy", "true"); + + // Collect state before flushing. + let tabState = TabState.clone(aTab); + + // Flush to get the latest tab state to duplicate. + let browser = aTab.linkedBrowser; + TabStateFlusher.flush(browser).then(() => { + // The new tab might have been closed in the meantime. + if (newTab.closing || !newTab.linkedBrowser) { + return; + } + + let window = newTab.ownerGlobal; + + // The tab or its window might be gone. + if (!window || !window.__SSi) { + return; + } + + // Update state with flushed data. We can't use TabState.clone() here as + // the tab to duplicate may have already been closed. In that case we + // only have access to the <xul:browser>. + let options = {includePrivateData: true}; + TabState.copyFromCache(browser, tabState, options); + + tabState.index += aDelta; + tabState.index = Math.max(1, Math.min(tabState.index, tabState.entries.length)); + tabState.pinned = false; + + // Restore the state into the new tab. + this.restoreTab(newTab, tabState, { + restoreImmediately: aRestoreImmediately + }); + }); + + return newTab; + }, + + getClosedTabCount: function ssi_getClosedTabCount(aWindow) { + if ("__SSi" in aWindow) { + return this._windows[aWindow.__SSi]._closedTabs.length; + } + + if (!DyingWindowCache.has(aWindow)) { + throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + + return DyingWindowCache.get(aWindow)._closedTabs.length; + }, + + getClosedTabData: function ssi_getClosedTabData(aWindow, aAsString = true) { + if ("__SSi" in aWindow) { + return aAsString ? + JSON.stringify(this._windows[aWindow.__SSi]._closedTabs) : + Cu.cloneInto(this._windows[aWindow.__SSi]._closedTabs, {}); + } + + if (!DyingWindowCache.has(aWindow)) { + throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + + let data = DyingWindowCache.get(aWindow); + return aAsString ? JSON.stringify(data._closedTabs) : Cu.cloneInto(data._closedTabs, {}); + }, + + undoCloseTab: function ssi_undoCloseTab(aWindow, aIndex) { + if (!aWindow.__SSi) { + throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + + var closedTabs = this._windows[aWindow.__SSi]._closedTabs; + + // default to the most-recently closed tab + aIndex = aIndex || 0; + if (!(aIndex in closedTabs)) { + throw Components.Exception("Invalid index: not in the closed tabs", Cr.NS_ERROR_INVALID_ARG); + } + + // fetch the data of closed tab, while removing it from the array + let {state, pos} = this.removeClosedTabData(closedTabs, aIndex); + + // create a new tab + let tabbrowser = aWindow.gBrowser; + let tab = tabbrowser.selectedTab = tabbrowser.addTab(null, state); + + // restore tab content + this.restoreTab(tab, state); + + // restore the tab's position + tabbrowser.moveTabTo(tab, pos); + + // focus the tab's content area (bug 342432) + tab.linkedBrowser.focus(); + + return tab; + }, + + forgetClosedTab: function ssi_forgetClosedTab(aWindow, aIndex) { + if (!aWindow.__SSi) { + throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + + var closedTabs = this._windows[aWindow.__SSi]._closedTabs; + + // default to the most-recently closed tab + aIndex = aIndex || 0; + if (!(aIndex in closedTabs)) { + throw Components.Exception("Invalid index: not in the closed tabs", Cr.NS_ERROR_INVALID_ARG); + } + + // remove closed tab from the array + this.removeClosedTabData(closedTabs, aIndex); + }, + + getClosedWindowCount: function ssi_getClosedWindowCount() { + return this._closedWindows.length; + }, + + getClosedWindowData: function ssi_getClosedWindowData(aAsString = true) { + return aAsString ? JSON.stringify(this._closedWindows) : Cu.cloneInto(this._closedWindows, {}); + }, + + undoCloseWindow: function ssi_undoCloseWindow(aIndex) { + if (!(aIndex in this._closedWindows)) { + throw Components.Exception("Invalid index: not in the closed windows", Cr.NS_ERROR_INVALID_ARG); + } + + // reopen the window + let state = { windows: this._closedWindows.splice(aIndex, 1) }; + delete state.windows[0].closedAt; // Window is now open. + + let window = this._openWindowWithState(state); + this.windowToFocus = window; + return window; + }, + + forgetClosedWindow: function ssi_forgetClosedWindow(aIndex) { + // default to the most-recently closed window + aIndex = aIndex || 0; + if (!(aIndex in this._closedWindows)) { + throw Components.Exception("Invalid index: not in the closed windows", Cr.NS_ERROR_INVALID_ARG); + } + + // remove closed window from the array + let winData = this._closedWindows[aIndex]; + this._closedWindows.splice(aIndex, 1); + this._saveableClosedWindowData.delete(winData); + }, + + getWindowValue: function ssi_getWindowValue(aWindow, aKey) { + if ("__SSi" in aWindow) { + var data = this._windows[aWindow.__SSi].extData || {}; + return data[aKey] || ""; + } + + if (DyingWindowCache.has(aWindow)) { + let data = DyingWindowCache.get(aWindow).extData || {}; + return data[aKey] || ""; + } + + throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG); + }, + + setWindowValue: function ssi_setWindowValue(aWindow, aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setWindowValue only accepts string values"); + } + + if (!("__SSi" in aWindow)) { + throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + if (!this._windows[aWindow.__SSi].extData) { + this._windows[aWindow.__SSi].extData = {}; + } + this._windows[aWindow.__SSi].extData[aKey] = aStringValue; + this.saveStateDelayed(aWindow); + }, + + deleteWindowValue: function ssi_deleteWindowValue(aWindow, aKey) { + if (aWindow.__SSi && this._windows[aWindow.__SSi].extData && + this._windows[aWindow.__SSi].extData[aKey]) + delete this._windows[aWindow.__SSi].extData[aKey]; + this.saveStateDelayed(aWindow); + }, + + getTabValue: function ssi_getTabValue(aTab, aKey) { + return (aTab.__SS_extdata || {})[aKey] || ""; + }, + + setTabValue: function ssi_setTabValue(aTab, aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setTabValue only accepts string values"); + } + + // If the tab hasn't been restored, then set the data there, otherwise we + // could lose newly added data. + if (!aTab.__SS_extdata) { + aTab.__SS_extdata = {}; + } + + aTab.__SS_extdata[aKey] = aStringValue; + this.saveStateDelayed(aTab.ownerGlobal); + }, + + deleteTabValue: function ssi_deleteTabValue(aTab, aKey) { + if (aTab.__SS_extdata && aKey in aTab.__SS_extdata) { + delete aTab.__SS_extdata[aKey]; + this.saveStateDelayed(aTab.ownerGlobal); + } + }, + + getGlobalValue: function ssi_getGlobalValue(aKey) { + return this._globalState.get(aKey); + }, + + setGlobalValue: function ssi_setGlobalValue(aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setGlobalValue only accepts string values"); + } + + this._globalState.set(aKey, aStringValue); + this.saveStateDelayed(); + }, + + deleteGlobalValue: function ssi_deleteGlobalValue(aKey) { + this._globalState.delete(aKey); + this.saveStateDelayed(); + }, + + persistTabAttribute: function ssi_persistTabAttribute(aName) { + if (TabAttributes.persist(aName)) { + this.saveStateDelayed(); + } + }, + + + /** + * Undoes the closing of a tab or window which corresponds + * to the closedId passed in. + * + * @param aClosedId + * The closedId of the tab or window + * + * @returns a tab or window object + */ + undoCloseById(aClosedId) { + // Check for a window first. + for (let i = 0, l = this._closedWindows.length; i < l; i++) { + if (this._closedWindows[i].closedId == aClosedId) { + return this.undoCloseWindow(i); + } + } + + // Check for a tab. + let windowsEnum = Services.wm.getEnumerator("navigator:browser"); + while (windowsEnum.hasMoreElements()) { + let window = windowsEnum.getNext(); + let windowState = this._windows[window.__SSi]; + if (windowState) { + for (let j = 0, l = windowState._closedTabs.length; j < l; j++) { + if (windowState._closedTabs[j].closedId == aClosedId) { + return this.undoCloseTab(window, j); + } + } + } + } + + // Neither a tab nor a window was found, return undefined and let the caller decide what to do about it. + return undefined; + }, + + /** + * Restores the session state stored in LastSession. This will attempt + * to merge data into the current session. If a window was opened at startup + * with pinned tab(s), then the remaining data from the previous session for + * that window will be opened into that window. Otherwise new windows will + * be opened. + */ + restoreLastSession: function ssi_restoreLastSession() { + // Use the public getter since it also checks PB mode + if (!this.canRestoreLastSession) { + throw Components.Exception("Last session can not be restored"); + } + + Services.obs.notifyObservers(null, NOTIFY_INITIATING_MANUAL_RESTORE, ""); + + // First collect each window with its id... + let windows = {}; + this._forEachBrowserWindow(function(aWindow) { + if (aWindow.__SS_lastSessionWindowID) + windows[aWindow.__SS_lastSessionWindowID] = aWindow; + }); + + let lastSessionState = LastSession.getState(); + + // This shouldn't ever be the case... + if (!lastSessionState.windows.length) { + throw Components.Exception("lastSessionState has no windows", Cr.NS_ERROR_UNEXPECTED); + } + + // We're technically doing a restore, so set things up so we send the + // notification when we're done. We want to send "sessionstore-browser-state-restored". + this._restoreCount = lastSessionState.windows.length; + this._browserSetState = true; + + // We want to re-use the last opened window instead of opening a new one in + // the case where it's "empty" and not associated with a window in the session. + // We will do more processing via _prepWindowToRestoreInto if we need to use + // the lastWindow. + let lastWindow = this._getMostRecentBrowserWindow(); + let canUseLastWindow = lastWindow && + !lastWindow.__SS_lastSessionWindowID; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(lastSessionState); + + // Restore into windows or open new ones as needed. + for (let i = 0; i < lastSessionState.windows.length; i++) { + let winState = lastSessionState.windows[i]; + let lastSessionWindowID = winState.__lastSessionWindowID; + // delete lastSessionWindowID so we don't add that to the window again + delete winState.__lastSessionWindowID; + + // See if we can use an open window. First try one that is associated with + // the state we're trying to restore and then fallback to the last selected + // window. + let windowToUse = windows[lastSessionWindowID]; + if (!windowToUse && canUseLastWindow) { + windowToUse = lastWindow; + canUseLastWindow = false; + } + + let [canUseWindow, canOverwriteTabs] = this._prepWindowToRestoreInto(windowToUse); + + // If there's a window already open that we can restore into, use that + if (canUseWindow) { + // Since we're not overwriting existing tabs, we want to merge _closedTabs, + // putting existing ones first. Then make sure we're respecting the max pref. + if (winState._closedTabs && winState._closedTabs.length) { + let curWinState = this._windows[windowToUse.__SSi]; + curWinState._closedTabs = curWinState._closedTabs.concat(winState._closedTabs); + curWinState._closedTabs.splice(this._max_tabs_undo, curWinState._closedTabs.length); + } + + // Restore into that window - pretend it's a followup since we'll already + // have a focused window. + //XXXzpao This is going to merge extData together (taking what was in + // winState over what is in the window already. + let options = {overwriteTabs: canOverwriteTabs, isFollowUp: true}; + this.restoreWindow(windowToUse, winState, options); + } + else { + this._openWindowWithState({ windows: [winState] }); + } + } + + // Merge closed windows from this session with ones from last session + if (lastSessionState._closedWindows) { + this._closedWindows = this._closedWindows.concat(lastSessionState._closedWindows); + this._capClosedWindows(); + } + + // Scratchpad + if (lastSessionState.scratchpads) { + ScratchpadManager.restoreSession(lastSessionState.scratchpads); + } + + // The Browser Console + if (lastSessionState.browserConsole) { + HUDService.restoreBrowserConsoleSession(); + } + + // Set data that persists between sessions + this._recentCrashes = lastSessionState.session && + lastSessionState.session.recentCrashes || 0; + + // Update the session start time using the restored session state. + this._updateSessionStartTime(lastSessionState); + + LastSession.clear(); + }, + + /** + * Revive a crashed tab and restore its state from before it crashed. + * + * @param aTab + * A <xul:tab> linked to a crashed browser. This is a no-op if the + * browser hasn't actually crashed, or is not associated with a tab. + * This function will also throw if the browser happens to be remote. + */ + reviveCrashedTab(aTab) { + if (!aTab) { + throw new Error("SessionStore.reviveCrashedTab expected a tab, but got null."); + } + + let browser = aTab.linkedBrowser; + if (!this._crashedBrowsers.has(browser.permanentKey)) { + return; + } + + // Sanity check - the browser to be revived should not be remote + // at this point. + if (browser.isRemoteBrowser) { + throw new Error("SessionStore.reviveCrashedTab: " + + "Somehow a crashed browser is still remote.") + } + + // We put the browser at about:blank in case the user is + // restoring tabs on demand. This way, the user won't see + // a flash of the about:tabcrashed page after selecting + // the revived tab. + aTab.removeAttribute("crashed"); + browser.loadURI("about:blank", null, null); + + let data = TabState.collect(aTab); + this.restoreTab(aTab, data, { + forceOnDemand: true, + }); + }, + + /** + * Revive all crashed tabs and reset the crashed tabs count to 0. + */ + reviveAllCrashedTabs() { + let windowsEnum = Services.wm.getEnumerator("navigator:browser"); + while (windowsEnum.hasMoreElements()) { + let window = windowsEnum.getNext(); + for (let tab of window.gBrowser.tabs) { + this.reviveCrashedTab(tab); + } + } + }, + + /** + * Navigate the given |tab| by first collecting its current state and then + * either changing only the index of the currently shown history entry, + * or restoring the exact same state again and passing the new URL to load + * in |loadArguments|. Use this method to seamlessly switch between pages + * loaded in the parent and pages loaded in the child process. + * + * This method might be called multiple times before it has finished + * flushing the browser tab. If that occurs, the loadArguments from + * the most recent call to navigateAndRestore will be used once the + * flush has finished. + */ + navigateAndRestore(tab, loadArguments, historyIndex) { + let window = tab.ownerGlobal; + NS_ASSERT(window.__SSi, "tab's window must be tracked"); + let browser = tab.linkedBrowser; + + // Were we already waiting for a flush from a previous call to + // navigateAndRestore on this tab? + let alreadyRestoring = + this._remotenessChangingBrowsers.has(browser.permanentKey); + + // Stash the most recent loadArguments in this WeakMap so that + // we know to use it when the TabStateFlusher.flush resolves. + this._remotenessChangingBrowsers.set(browser.permanentKey, loadArguments); + + if (alreadyRestoring) { + // This tab was already being restored to run in the + // correct process. We're done here. + return; + } + + // Set tab title to "Connecting..." and start the throbber to pretend we're + // doing something while actually waiting for data from the frame script. + window.gBrowser.setTabTitleLoading(tab); + tab.setAttribute("busy", "true"); + + // Flush to get the latest tab state. + TabStateFlusher.flush(browser).then(() => { + // loadArguments might have been overwritten by multiple calls + // to navigateAndRestore while we waited for the tab to flush, + // so we use the most recently stored one. + let recentLoadArguments = + this._remotenessChangingBrowsers.get(browser.permanentKey); + this._remotenessChangingBrowsers.delete(browser.permanentKey); + + // The tab might have been closed/gone in the meantime. + if (tab.closing || !tab.linkedBrowser) { + return; + } + + let window = tab.ownerGlobal; + + // The tab or its window might be gone. + if (!window || !window.__SSi || window.closed) { + return; + } + + let tabState = TabState.clone(tab); + let options = { + restoreImmediately: true, + // We want to make sure that this information is passed to restoreTab + // whether or not a historyIndex is passed in. Thus, we extract it from + // the loadArguments. + reloadInFreshProcess: !!recentLoadArguments.reloadInFreshProcess, + }; + + if (historyIndex >= 0) { + tabState.index = historyIndex + 1; + tabState.index = Math.max(1, Math.min(tabState.index, tabState.entries.length)); + } else { + options.loadArguments = recentLoadArguments; + } + + // Need to reset restoring tabs. + if (tab.linkedBrowser.__SS_restoreState) { + this._resetLocalTabRestoringState(tab); + } + + // Restore the state into the tab. + this.restoreTab(tab, tabState, options); + }); + + tab.linkedBrowser.__SS_restoreState = TAB_STATE_WILL_RESTORE; + }, + + /** + * Retrieves the latest session history information for a tab. The cached data + * is returned immediately, but a callback may be provided that supplies + * up-to-date data when or if it is available. The callback is passed a single + * argument with data in the same format as the return value. + * + * @param tab tab to retrieve the session history for + * @param updatedCallback function to call with updated data as the single argument + * @returns a object containing 'index' specifying the current index, and an + * array 'entries' containing an object for each history item. + */ + getSessionHistory(tab, updatedCallback) { + if (updatedCallback) { + TabStateFlusher.flush(tab.linkedBrowser).then(() => { + let sessionHistory = this.getSessionHistory(tab); + if (sessionHistory) { + updatedCallback(sessionHistory); + } + }); + } + + // Don't continue if the tab was closed before TabStateFlusher.flush resolves. + if (tab.linkedBrowser) { + let tabState = TabState.collect(tab); + return { index: tabState.index - 1, entries: tabState.entries } + } + }, + + /** + * See if aWindow is usable for use when restoring a previous session via + * restoreLastSession. If usable, prepare it for use. + * + * @param aWindow + * the window to inspect & prepare + * @returns [canUseWindow, canOverwriteTabs] + * canUseWindow: can the window be used to restore into + * canOverwriteTabs: all of the current tabs are home pages and we + * can overwrite them + */ + _prepWindowToRestoreInto: function ssi_prepWindowToRestoreInto(aWindow) { + if (!aWindow) + return [false, false]; + + // We might be able to overwrite the existing tabs instead of just adding + // the previous session's tabs to the end. This will be set if possible. + let canOverwriteTabs = false; + + // Look at the open tabs in comparison to home pages. If all the tabs are + // home pages then we'll end up overwriting all of them. Otherwise we'll + // just close the tabs that match home pages. Tabs with the about:blank + // URI will always be overwritten. + let homePages = ["about:blank"]; + let removableTabs = []; + let tabbrowser = aWindow.gBrowser; + let normalTabsLen = tabbrowser.tabs.length - tabbrowser._numPinnedTabs; + let startupPref = this._prefBranch.getIntPref("startup.page"); + if (startupPref == 1) + homePages = homePages.concat(aWindow.gHomeButton.getHomePage().split("|")); + + for (let i = tabbrowser._numPinnedTabs; i < tabbrowser.tabs.length; i++) { + let tab = tabbrowser.tabs[i]; + if (homePages.indexOf(tab.linkedBrowser.currentURI.spec) != -1) { + removableTabs.push(tab); + } + } + + if (tabbrowser.tabs.length == removableTabs.length) { + canOverwriteTabs = true; + } + else { + // If we're not overwriting all of the tabs, then close the home tabs. + for (let i = removableTabs.length - 1; i >= 0; i--) { + tabbrowser.removeTab(removableTabs.pop(), { animate: false }); + } + } + + return [true, canOverwriteTabs]; + }, + + /* ........ Saving Functionality .............. */ + + /** + * Store window dimensions, visibility, sidebar + * @param aWindow + * Window reference + */ + _updateWindowFeatures: function ssi_updateWindowFeatures(aWindow) { + var winData = this._windows[aWindow.__SSi]; + + WINDOW_ATTRIBUTES.forEach(function(aAttr) { + winData[aAttr] = this._getWindowDimension(aWindow, aAttr); + }, this); + + var hidden = WINDOW_HIDEABLE_FEATURES.filter(function(aItem) { + return aWindow[aItem] && !aWindow[aItem].visible; + }); + if (hidden.length != 0) + winData.hidden = hidden.join(","); + else if (winData.hidden) + delete winData.hidden; + + var sidebar = aWindow.document.getElementById("sidebar-box").getAttribute("sidebarcommand"); + if (sidebar) + winData.sidebar = sidebar; + else if (winData.sidebar) + delete winData.sidebar; + }, + + /** + * gather session data as object + * @param aUpdateAll + * Bool update all windows + * @returns object + */ + getCurrentState: function (aUpdateAll) { + this._handleClosedWindows(); + + var activeWindow = this._getMostRecentBrowserWindow(); + + TelemetryStopwatch.start("FX_SESSION_RESTORE_COLLECT_ALL_WINDOWS_DATA_MS"); + if (RunState.isRunning) { + // update the data for all windows with activities since the last save operation + this._forEachBrowserWindow(function(aWindow) { + if (!this._isWindowLoaded(aWindow)) // window data is still in _statesToRestore + return; + if (aUpdateAll || DirtyWindows.has(aWindow) || aWindow == activeWindow) { + this._collectWindowData(aWindow); + } + else { // always update the window features (whose change alone never triggers a save operation) + this._updateWindowFeatures(aWindow); + } + }); + DirtyWindows.clear(); + } + TelemetryStopwatch.finish("FX_SESSION_RESTORE_COLLECT_ALL_WINDOWS_DATA_MS"); + + // An array that at the end will hold all current window data. + var total = []; + // The ids of all windows contained in 'total' in the same order. + var ids = []; + // The number of window that are _not_ popups. + var nonPopupCount = 0; + var ix; + + // collect the data for all windows + for (ix in this._windows) { + if (this._windows[ix]._restoring) // window data is still in _statesToRestore + continue; + total.push(this._windows[ix]); + ids.push(ix); + if (!this._windows[ix].isPopup) + nonPopupCount++; + } + + TelemetryStopwatch.start("FX_SESSION_RESTORE_COLLECT_COOKIES_MS"); + SessionCookies.update(total); + TelemetryStopwatch.finish("FX_SESSION_RESTORE_COLLECT_COOKIES_MS"); + + // collect the data for all windows yet to be restored + for (ix in this._statesToRestore) { + for (let winData of this._statesToRestore[ix].windows) { + total.push(winData); + if (!winData.isPopup) + nonPopupCount++; + } + } + + // shallow copy this._closedWindows to preserve current state + let lastClosedWindowsCopy = this._closedWindows.slice(); + + if (AppConstants.platform != "macosx") { + // If no non-popup browser window remains open, return the state of the last + // closed window(s). We only want to do this when we're actually "ending" + // the session. + //XXXzpao We should do this for _restoreLastWindow == true, but that has + // its own check for popups. c.f. bug 597619 + if (nonPopupCount == 0 && lastClosedWindowsCopy.length > 0 && + RunState.isQuitting) { + // prepend the last non-popup browser window, so that if the user loads more tabs + // at startup we don't accidentally add them to a popup window + do { + total.unshift(lastClosedWindowsCopy.shift()) + } while (total[0].isPopup && lastClosedWindowsCopy.length > 0) + } + } + + if (activeWindow) { + this.activeWindowSSiCache = activeWindow.__SSi || ""; + } + ix = ids.indexOf(this.activeWindowSSiCache); + // We don't want to restore focus to a minimized window or a window which had all its + // tabs stripped out (doesn't exist). + if (ix != -1 && total[ix] && total[ix].sizemode == "minimized") + ix = -1; + + let session = { + lastUpdate: Date.now(), + startTime: this._sessionStartTime, + recentCrashes: this._recentCrashes + }; + + let state = { + version: ["sessionrestore", FORMAT_VERSION], + windows: total, + selectedWindow: ix + 1, + _closedWindows: lastClosedWindowsCopy, + session: session, + global: this._globalState.getState() + }; + + // Scratchpad + if (Cu.isModuleLoaded("resource://devtools/client/scratchpad/scratchpad-manager.jsm")) { + // get open Scratchpad window states too + let scratchpads = ScratchpadManager.getSessionState(); + if (scratchpads && scratchpads.length) { + state.scratchpads = scratchpads; + } + } + + // The Browser Console + state.browserConsole = HUDService.getBrowserConsoleSessionState(); + + // Persist the last session if we deferred restoring it + if (LastSession.canRestore) { + state.lastSessionState = LastSession.getState(); + } + + // If we were called by the SessionSaver and started with only a private + // window we want to pass the deferred initial state to not lose the + // previous session. + if (this._deferredInitialState) { + state.deferredInitialState = this._deferredInitialState; + } + + return state; + }, + + /** + * serialize session data for a window + * @param aWindow + * Window reference + * @returns string + */ + _getWindowState: function ssi_getWindowState(aWindow) { + if (!this._isWindowLoaded(aWindow)) + return this._statesToRestore[aWindow.__SS_restoreID]; + + if (RunState.isRunning) { + this._collectWindowData(aWindow); + } + + let windows = [this._windows[aWindow.__SSi]]; + SessionCookies.update(windows); + + return { windows: windows }; + }, + + /** + * Gathers data about a window and its tabs, and updates its + * entry in this._windows. + * + * @param aWindow + * Window references. + * @returns a Map mapping the browser tabs from aWindow to the tab + * entry that was put into the window data in this._windows. + */ + _collectWindowData: function ssi_collectWindowData(aWindow) { + let tabMap = new Map(); + + if (!this._isWindowLoaded(aWindow)) + return tabMap; + + let tabbrowser = aWindow.gBrowser; + let tabs = tabbrowser.tabs; + let winData = this._windows[aWindow.__SSi]; + let tabsData = winData.tabs = []; + + // update the internal state data for this window + for (let tab of tabs) { + let tabData = TabState.collect(tab); + tabMap.set(tab, tabData); + tabsData.push(tabData); + } + winData.selected = tabbrowser.mTabBox.selectedIndex + 1; + + this._updateWindowFeatures(aWindow); + + // Make sure we keep __SS_lastSessionWindowID around for cases like entering + // or leaving PB mode. + if (aWindow.__SS_lastSessionWindowID) + this._windows[aWindow.__SSi].__lastSessionWindowID = + aWindow.__SS_lastSessionWindowID; + + DirtyWindows.remove(aWindow); + return tabMap; + }, + + /* ........ Restoring Functionality .............. */ + + /** + * restore features to a single window + * @param aWindow + * Window reference to the window to use for restoration + * @param winData + * JS object + * @param aOptions + * {overwriteTabs: true} to overwrite existing tabs w/ new ones + * {isFollowUp: true} if this is not the restoration of the 1st window + * {firstWindow: true} if this is the first non-private window we're + * restoring in this session, that might open an + * external link as well + */ + restoreWindow: function ssi_restoreWindow(aWindow, winData, aOptions = {}) { + let overwriteTabs = aOptions && aOptions.overwriteTabs; + let isFollowUp = aOptions && aOptions.isFollowUp; + let firstWindow = aOptions && aOptions.firstWindow; + + if (isFollowUp) { + this.windowToFocus = aWindow; + } + + // initialize window if necessary + if (aWindow && (!aWindow.__SSi || !this._windows[aWindow.__SSi])) + this.onLoad(aWindow); + + TelemetryStopwatch.start("FX_SESSION_RESTORE_RESTORE_WINDOW_MS"); + + // We're not returning from this before we end up calling restoreTabs + // for this window, so make sure we send the SSWindowStateBusy event. + this._setWindowStateBusy(aWindow); + + if (!winData.tabs) { + winData.tabs = []; + } + + // don't restore a single blank tab when we've had an external + // URL passed in for loading at startup (cf. bug 357419) + else if (firstWindow && !overwriteTabs && winData.tabs.length == 1 && + (!winData.tabs[0].entries || winData.tabs[0].entries.length == 0)) { + winData.tabs = []; + } + + var tabbrowser = aWindow.gBrowser; + var openTabCount = overwriteTabs ? tabbrowser.browsers.length : -1; + var newTabCount = winData.tabs.length; + var tabs = []; + + // disable smooth scrolling while adding, moving, removing and selecting tabs + var tabstrip = tabbrowser.tabContainer.mTabstrip; + var smoothScroll = tabstrip.smoothScroll; + tabstrip.smoothScroll = false; + + // unpin all tabs to ensure they are not reordered in the next loop + if (overwriteTabs) { + for (let t = tabbrowser._numPinnedTabs - 1; t > -1; t--) + tabbrowser.unpinTab(tabbrowser.tabs[t]); + } + + // We need to keep track of the initially open tabs so that they + // can be moved to the end of the restored tabs. + let initialTabs = []; + if (!overwriteTabs && firstWindow) { + initialTabs = Array.slice(tabbrowser.tabs); + } + + // make sure that the selected tab won't be closed in order to + // prevent unnecessary flickering + if (overwriteTabs && tabbrowser.selectedTab._tPos >= newTabCount) + tabbrowser.moveTabTo(tabbrowser.selectedTab, newTabCount - 1); + + let numVisibleTabs = 0; + + for (var t = 0; t < newTabCount; t++) { + // When trying to restore into existing tab, we also take the userContextId + // into account if present. + let userContextId = winData.tabs[t].userContextId; + let reuseExisting = t < openTabCount && + (tabbrowser.tabs[t].getAttribute("usercontextid") == (userContextId || "")); + // If the tab is pinned, then we'll be loading it right away, and + // there's no need to cause a remoteness flip by loading it initially + // non-remote. + let forceNotRemote = !winData.tabs[t].pinned; + let tab = reuseExisting ? tabbrowser.tabs[t] : + tabbrowser.addTab("about:blank", + {skipAnimation: true, + forceNotRemote, + userContextId}); + + // If we inserted a new tab because the userContextId didn't match with the + // open tab, even though `t < openTabCount`, we need to remove that open tab + // and put the newly added tab in its place. + if (!reuseExisting && t < openTabCount) { + tabbrowser.removeTab(tabbrowser.tabs[t]); + tabbrowser.moveTabTo(tab, t); + } + + tabs.push(tab); + + if (winData.tabs[t].pinned) + tabbrowser.pinTab(tabs[t]); + + if (winData.tabs[t].hidden) { + tabbrowser.hideTab(tabs[t]); + } + else { + tabbrowser.showTab(tabs[t]); + numVisibleTabs++; + } + + if (!!winData.tabs[t].muted != tabs[t].linkedBrowser.audioMuted) { + tabs[t].toggleMuteAudio(winData.tabs[t].muteReason); + } + } + + if (!overwriteTabs && firstWindow) { + // Move the originally open tabs to the end + let endPosition = tabbrowser.tabs.length - 1; + for (let i = 0; i < initialTabs.length; i++) { + tabbrowser.moveTabTo(initialTabs[i], endPosition); + } + } + + // if all tabs to be restored are hidden, make the first one visible + if (!numVisibleTabs && winData.tabs.length) { + winData.tabs[0].hidden = false; + tabbrowser.showTab(tabs[0]); + } + + // If overwriting tabs, we want to reset each tab's "restoring" state. Since + // we're overwriting those tabs, they should no longer be restoring. The + // tabs will be rebuilt and marked if they need to be restored after loading + // state (in restoreTabs). + if (overwriteTabs) { + for (let i = 0; i < tabbrowser.tabs.length; i++) { + let tab = tabbrowser.tabs[i]; + if (tabbrowser.browsers[i].__SS_restoreState) + this._resetTabRestoringState(tab); + } + } + + // We want to correlate the window with data from the last session, so + // assign another id if we have one. Otherwise clear so we don't do + // anything with it. + delete aWindow.__SS_lastSessionWindowID; + if (winData.__lastSessionWindowID) + aWindow.__SS_lastSessionWindowID = winData.__lastSessionWindowID; + + // when overwriting tabs, remove all superflous ones + if (overwriteTabs && newTabCount < openTabCount) { + Array.slice(tabbrowser.tabs, newTabCount, openTabCount) + .forEach(tabbrowser.removeTab, tabbrowser); + } + + if (overwriteTabs) { + this.restoreWindowFeatures(aWindow, winData); + delete this._windows[aWindow.__SSi].extData; + } + if (winData.cookies) { + SessionCookies.restore(winData.cookies); + } + if (winData.extData) { + if (!this._windows[aWindow.__SSi].extData) { + this._windows[aWindow.__SSi].extData = {}; + } + for (var key in winData.extData) { + this._windows[aWindow.__SSi].extData[key] = winData.extData[key]; + } + } + + let newClosedTabsData = winData._closedTabs || []; + + if (overwriteTabs || firstWindow) { + // Overwrite existing closed tabs data when overwriteTabs=true + // or we're the first window to be restored. + this._windows[aWindow.__SSi]._closedTabs = newClosedTabsData; + } else if (this._max_tabs_undo > 0) { + // If we merge tabs, we also want to merge closed tabs data. We'll assume + // the restored tabs were closed more recently and append the current list + // of closed tabs to the new one... + newClosedTabsData = + newClosedTabsData.concat(this._windows[aWindow.__SSi]._closedTabs); + + // ... and make sure that we don't exceed the max number of closed tabs + // we can restore. + this._windows[aWindow.__SSi]._closedTabs = + newClosedTabsData.slice(0, this._max_tabs_undo); + } + + // Restore tabs, if any. + if (winData.tabs.length) { + this.restoreTabs(aWindow, tabs, winData.tabs, + (overwriteTabs ? (parseInt(winData.selected || "1")) : 0)); + } + + // set smoothScroll back to the original value + tabstrip.smoothScroll = smoothScroll; + + TelemetryStopwatch.finish("FX_SESSION_RESTORE_RESTORE_WINDOW_MS"); + + this._setWindowStateReady(aWindow); + + this._sendWindowRestoredNotification(aWindow); + + Services.obs.notifyObservers(aWindow, NOTIFY_SINGLE_WINDOW_RESTORED, ""); + + this._sendRestoreCompletedNotifications(); + }, + + /** + * Restore multiple windows using the provided state. + * @param aWindow + * Window reference to the first window to use for restoration. + * Additionally required windows will be opened. + * @param aState + * JS object or JSON string + * @param aOptions + * {overwriteTabs: true} to overwrite existing tabs w/ new ones + * {isFollowUp: true} if this is not the restoration of the 1st window + * {firstWindow: true} if this is the first non-private window we're + * restoring in this session, that might open an + * external link as well + */ + restoreWindows: function ssi_restoreWindows(aWindow, aState, aOptions = {}) { + let isFollowUp = aOptions && aOptions.isFollowUp; + + if (isFollowUp) { + this.windowToFocus = aWindow; + } + + // initialize window if necessary + if (aWindow && (!aWindow.__SSi || !this._windows[aWindow.__SSi])) + this.onLoad(aWindow); + + let root; + try { + root = (typeof aState == "string") ? JSON.parse(aState) : aState; + } + catch (ex) { // invalid state object - don't restore anything + debug(ex); + this._sendRestoreCompletedNotifications(); + return; + } + + // Restore closed windows if any. + if (root._closedWindows) { + this._closedWindows = root._closedWindows; + } + + // We're done here if there are no windows. + if (!root.windows || !root.windows.length) { + this._sendRestoreCompletedNotifications(); + return; + } + + if (!root.selectedWindow || root.selectedWindow > root.windows.length) { + root.selectedWindow = 0; + } + + // open new windows for all further window entries of a multi-window session + // (unless they don't contain any tab data) + let winData; + for (var w = 1; w < root.windows.length; w++) { + winData = root.windows[w]; + if (winData && winData.tabs && winData.tabs[0]) { + var window = this._openWindowWithState({ windows: [winData] }); + if (w == root.selectedWindow - 1) { + this.windowToFocus = window; + } + } + } + + this.restoreWindow(aWindow, root.windows[0], aOptions); + + // Scratchpad + if (aState.scratchpads) { + ScratchpadManager.restoreSession(aState.scratchpads); + } + + // The Browser Console + if (aState.browserConsole) { + HUDService.restoreBrowserConsoleSession(); + } + }, + + /** + * Manage history restoration for a window + * @param aWindow + * Window to restore the tabs into + * @param aTabs + * Array of tab references + * @param aTabData + * Array of tab data + * @param aSelectTab + * Index of the tab to select. This is a 1-based index where "1" + * indicates the first tab should be selected, and "0" indicates that + * the currently selected tab will not be changed. + */ + restoreTabs(aWindow, aTabs, aTabData, aSelectTab) { + var tabbrowser = aWindow.gBrowser; + + if (!this._isWindowLoaded(aWindow)) { + // from now on, the data will come from the actual window + delete this._statesToRestore[aWindow.__SS_restoreID]; + delete aWindow.__SS_restoreID; + delete this._windows[aWindow.__SSi]._restoring; + } + + let numTabsToRestore = aTabs.length; + let numTabsInWindow = tabbrowser.tabs.length; + let tabsDataArray = this._windows[aWindow.__SSi].tabs; + + // Update the window state in case we shut down without being notified. + // Individual tab states will be taken care of by restoreTab() below. + if (numTabsInWindow == numTabsToRestore) { + // Remove all previous tab data. + tabsDataArray.length = 0; + } else { + // Remove all previous tab data except tabs that should not be overriden. + tabsDataArray.splice(numTabsInWindow - numTabsToRestore); + } + + // Let the tab data array have the right number of slots. + tabsDataArray.length = numTabsInWindow; + + // If provided, set the selected tab. + if (aSelectTab > 0 && aSelectTab <= aTabs.length) { + tabbrowser.selectedTab = aTabs[aSelectTab - 1]; + + // Update the window state in case we shut down without being notified. + this._windows[aWindow.__SSi].selected = aSelectTab; + } + + // Restore all tabs. + for (let t = 0; t < aTabs.length; t++) { + this.restoreTab(aTabs[t], aTabData[t]); + } + }, + + // Restores the given tab state for a given tab. + restoreTab(tab, tabData, options = {}) { + NS_ASSERT(!tab.linkedBrowser.__SS_restoreState, + "must reset tab before calling restoreTab()"); + + let restoreImmediately = options.restoreImmediately; + let loadArguments = options.loadArguments; + let browser = tab.linkedBrowser; + let window = tab.ownerGlobal; + let tabbrowser = window.gBrowser; + let forceOnDemand = options.forceOnDemand; + let reloadInFreshProcess = options.reloadInFreshProcess; + + let willRestoreImmediately = restoreImmediately || + tabbrowser.selectedBrowser == browser || + loadArguments; + + if (!willRestoreImmediately && !forceOnDemand) { + TabRestoreQueue.add(tab); + } + + this._maybeUpdateBrowserRemoteness({ tabbrowser, tab, + willRestoreImmediately }); + + // Increase the busy state counter before modifying the tab. + this._setWindowStateBusy(window); + + // It's important to set the window state to dirty so that + // we collect their data for the first time when saving state. + DirtyWindows.add(window); + + // In case we didn't collect/receive data for any tabs yet we'll have to + // fill the array with at least empty tabData objects until |_tPos| or + // we'll end up with |null| entries. + for (let otherTab of Array.slice(tabbrowser.tabs, 0, tab._tPos)) { + let emptyState = {entries: [], lastAccessed: otherTab.lastAccessed}; + this._windows[window.__SSi].tabs.push(emptyState); + } + + // Update the tab state in case we shut down without being notified. + this._windows[window.__SSi].tabs[tab._tPos] = tabData; + + // Prepare the tab so that it can be properly restored. We'll pin/unpin + // and show/hide tabs as necessary. We'll also attach a copy of the tab's + // data in case we close it before it's been restored. + if (tabData.pinned) { + tabbrowser.pinTab(tab); + } else { + tabbrowser.unpinTab(tab); + } + + if (tabData.hidden) { + tabbrowser.hideTab(tab); + } else { + tabbrowser.showTab(tab); + } + + if (!!tabData.muted != browser.audioMuted) { + tab.toggleMuteAudio(tabData.muteReason); + } + + if (tabData.lastAccessed) { + tab.updateLastAccessed(tabData.lastAccessed); + } + + if ("attributes" in tabData) { + // Ensure that we persist tab attributes restored from previous sessions. + Object.keys(tabData.attributes).forEach(a => TabAttributes.persist(a)); + } + + if (!tabData.entries) { + tabData.entries = []; + } + if (tabData.extData) { + tab.__SS_extdata = Cu.cloneInto(tabData.extData, {}); + } else { + delete tab.__SS_extdata; + } + + // Tab is now open. + delete tabData.closedAt; + + // Ensure the index is in bounds. + let activeIndex = (tabData.index || tabData.entries.length) - 1; + activeIndex = Math.min(activeIndex, tabData.entries.length - 1); + activeIndex = Math.max(activeIndex, 0); + + // Save the index in case we updated it above. + tabData.index = activeIndex + 1; + + // Start a new epoch to discard all frame script messages relating to a + // previous epoch. All async messages that are still on their way to chrome + // will be ignored and don't override any tab data set when restoring. + let epoch = this.startNextEpoch(browser); + + // keep the data around to prevent dataloss in case + // a tab gets closed before it's been properly restored + browser.__SS_restoreState = TAB_STATE_NEEDS_RESTORE; + browser.setAttribute("pending", "true"); + tab.setAttribute("pending", "true"); + + // If we're restoring this tab, it certainly shouldn't be in + // the ignored set anymore. + this._crashedBrowsers.delete(browser.permanentKey); + + // Update the persistent tab state cache with |tabData| information. + TabStateCache.update(browser, { + history: {entries: tabData.entries, index: tabData.index}, + scroll: tabData.scroll || null, + storage: tabData.storage || null, + formdata: tabData.formdata || null, + disallow: tabData.disallow || null, + pageStyle: tabData.pageStyle || null, + + // This information is only needed until the tab has finished restoring. + // When that's done it will be removed from the cache and we always + // collect it in TabState._collectBaseTabData(). + image: tabData.image || "", + iconLoadingPrincipal: tabData.iconLoadingPrincipal || null, + userTypedValue: tabData.userTypedValue || "", + userTypedClear: tabData.userTypedClear || 0 + }); + + browser.messageManager.sendAsyncMessage("SessionStore:restoreHistory", + {tabData: tabData, epoch: epoch, loadArguments}); + + // Restore tab attributes. + if ("attributes" in tabData) { + TabAttributes.set(tab, tabData.attributes); + } + + // This could cause us to ignore MAX_CONCURRENT_TAB_RESTORES a bit, but + // it ensures each window will have its selected tab loaded. + if (willRestoreImmediately) { + this.restoreTabContent(tab, loadArguments, reloadInFreshProcess); + } else if (!forceOnDemand) { + this.restoreNextTab(); + } + + // Decrease the busy state counter after we're done. + this._setWindowStateReady(window); + }, + + /** + * Kicks off restoring the given tab. + * + * @param aTab + * the tab to restore + * @param aLoadArguments + * optional load arguments used for loadURI() + * @param aReloadInFreshProcess + * true if we want to reload into a fresh process + */ + restoreTabContent: function (aTab, aLoadArguments = null, aReloadInFreshProcess = false) { + if (aTab.hasAttribute("customizemode") && !aLoadArguments) { + return; + } + + let browser = aTab.linkedBrowser; + let window = aTab.ownerGlobal; + let tabbrowser = window.gBrowser; + let tabData = TabState.clone(aTab); + let activeIndex = tabData.index - 1; + let activePageData = tabData.entries[activeIndex] || null; + let uri = activePageData ? activePageData.url || null : null; + if (aLoadArguments) { + uri = aLoadArguments.uri; + if (aLoadArguments.userContextId) { + browser.setAttribute("usercontextid", aLoadArguments.userContextId); + } + } + + // We have to mark this tab as restoring first, otherwise + // the "pending" attribute will be applied to the linked + // browser, which removes it from the display list. We cannot + // flip the remoteness of any browser that is not being displayed. + this.markTabAsRestoring(aTab); + + let isRemotenessUpdate = false; + if (aReloadInFreshProcess) { + isRemotenessUpdate = tabbrowser.switchBrowserIntoFreshProcess(browser); + } else { + isRemotenessUpdate = tabbrowser.updateBrowserRemotenessByURL(browser, uri); + } + + if (isRemotenessUpdate) { + // We updated the remoteness, so we need to send the history down again. + // + // Start a new epoch to discard all frame script messages relating to a + // previous epoch. All async messages that are still on their way to chrome + // will be ignored and don't override any tab data set when restoring. + let epoch = this.startNextEpoch(browser); + + browser.messageManager.sendAsyncMessage("SessionStore:restoreHistory", { + tabData: tabData, + epoch: epoch, + loadArguments: aLoadArguments, + isRemotenessUpdate, + }); + + } + + // If the restored browser wants to show view source content, start up a + // view source browser that will load the required frame script. + if (uri && ViewSourceBrowser.isViewSource(uri)) { + new ViewSourceBrowser(browser); + } + + browser.messageManager.sendAsyncMessage("SessionStore:restoreTabContent", + {loadArguments: aLoadArguments, isRemotenessUpdate}); + }, + + /** + * Marks a given pending tab as restoring. + * + * @param aTab + * the pending tab to mark as restoring + */ + markTabAsRestoring(aTab) { + let browser = aTab.linkedBrowser; + if (browser.__SS_restoreState != TAB_STATE_NEEDS_RESTORE) { + throw new Error("Given tab is not pending."); + } + + // Make sure that this tab is removed from the priority queue. + TabRestoreQueue.remove(aTab); + + // Increase our internal count. + this._tabsRestoringCount++; + + // Set this tab's state to restoring + browser.__SS_restoreState = TAB_STATE_RESTORING; + browser.removeAttribute("pending"); + aTab.removeAttribute("pending"); + }, + + /** + * This _attempts_ to restore the next available tab. If the restore fails, + * then we will attempt the next one. + * There are conditions where this won't do anything: + * if we're in the process of quitting + * if there are no tabs to restore + * if we have already reached the limit for number of tabs to restore + */ + restoreNextTab: function ssi_restoreNextTab() { + // If we call in here while quitting, we don't actually want to do anything + if (RunState.isQuitting) + return; + + // Don't exceed the maximum number of concurrent tab restores. + if (this._tabsRestoringCount >= MAX_CONCURRENT_TAB_RESTORES) + return; + + let tab = TabRestoreQueue.shift(); + if (tab) { + this.restoreTabContent(tab); + } + }, + + /** + * Restore visibility and dimension features to a window + * @param aWindow + * Window reference + * @param aWinData + * Object containing session data for the window + */ + restoreWindowFeatures: function ssi_restoreWindowFeatures(aWindow, aWinData) { + var hidden = (aWinData.hidden)?aWinData.hidden.split(","):[]; + WINDOW_HIDEABLE_FEATURES.forEach(function(aItem) { + aWindow[aItem].visible = hidden.indexOf(aItem) == -1; + }); + + if (aWinData.isPopup) { + this._windows[aWindow.__SSi].isPopup = true; + if (aWindow.gURLBar) { + aWindow.gURLBar.readOnly = true; + aWindow.gURLBar.setAttribute("enablehistory", "false"); + } + } + else { + delete this._windows[aWindow.__SSi].isPopup; + if (aWindow.gURLBar) { + aWindow.gURLBar.readOnly = false; + aWindow.gURLBar.setAttribute("enablehistory", "true"); + } + } + + var _this = this; + aWindow.setTimeout(function() { + _this.restoreDimensions.apply(_this, [aWindow, + +(aWinData.width || 0), + +(aWinData.height || 0), + "screenX" in aWinData ? +aWinData.screenX : NaN, + "screenY" in aWinData ? +aWinData.screenY : NaN, + aWinData.sizemode || "", aWinData.sidebar || ""]); + }, 0); + }, + + /** + * Restore a window's dimensions + * @param aWidth + * Window width + * @param aHeight + * Window height + * @param aLeft + * Window left + * @param aTop + * Window top + * @param aSizeMode + * Window size mode (eg: maximized) + * @param aSidebar + * Sidebar command + */ + restoreDimensions: function ssi_restoreDimensions(aWindow, aWidth, aHeight, aLeft, aTop, aSizeMode, aSidebar) { + var win = aWindow; + var _this = this; + function win_(aName) { return _this._getWindowDimension(win, aName); } + + // find available space on the screen where this window is being placed + let screen = gScreenManager.screenForRect(aLeft, aTop, aWidth, aHeight); + if (screen) { + let screenLeft = {}, screenTop = {}, screenWidth = {}, screenHeight = {}; + screen.GetAvailRectDisplayPix(screenLeft, screenTop, screenWidth, screenHeight); + // screenX/Y are based on the origin of the screen's desktop-pixel coordinate space + let screenLeftCss = screenLeft.value; + let screenTopCss = screenTop.value; + // convert screen's device pixel dimensions to CSS px dimensions + screen.GetAvailRect(screenLeft, screenTop, screenWidth, screenHeight); + let cssToDevScale = screen.defaultCSSScaleFactor; + let screenRightCss = screenLeftCss + screenWidth.value / cssToDevScale; + let screenBottomCss = screenTopCss + screenHeight.value / cssToDevScale; + + // Pull the window within the screen's bounds (allowing a little slop + // for windows that may be deliberately placed with their border off-screen + // as when Win10 "snaps" a window to the left/right edge -- bug 1276516). + // First, ensure the left edge is large enough... + if (aLeft < screenLeftCss - SCREEN_EDGE_SLOP) { + aLeft = screenLeftCss; + } + // Then check the resulting right edge, and reduce it if necessary. + let right = aLeft + aWidth; + if (right > screenRightCss + SCREEN_EDGE_SLOP) { + right = screenRightCss; + // See if we can move the left edge leftwards to maintain width. + if (aLeft > screenLeftCss) { + aLeft = Math.max(right - aWidth, screenLeftCss); + } + } + // Finally, update aWidth to account for the adjusted left and right edges. + aWidth = right - aLeft; + + // And do the same in the vertical dimension. + if (aTop < screenTopCss - SCREEN_EDGE_SLOP) { + aTop = screenTopCss; + } + let bottom = aTop + aHeight; + if (bottom > screenBottomCss + SCREEN_EDGE_SLOP) { + bottom = screenBottomCss; + if (aTop > screenTopCss) { + aTop = Math.max(bottom - aHeight, screenTopCss); + } + } + aHeight = bottom - aTop; + } + + // only modify those aspects which aren't correct yet + if (!isNaN(aLeft) && !isNaN(aTop) && (aLeft != win_("screenX") || aTop != win_("screenY"))) { + aWindow.moveTo(aLeft, aTop); + } + if (aWidth && aHeight && (aWidth != win_("width") || aHeight != win_("height"))) { + // Don't resize the window if it's currently maximized and we would + // maximize it again shortly after. + if (aSizeMode != "maximized" || win_("sizemode") != "maximized") { + aWindow.resizeTo(aWidth, aHeight); + } + } + if (aSizeMode && win_("sizemode") != aSizeMode) + { + switch (aSizeMode) + { + case "maximized": + aWindow.maximize(); + break; + case "minimized": + aWindow.minimize(); + break; + case "normal": + aWindow.restore(); + break; + } + } + var sidebar = aWindow.document.getElementById("sidebar-box"); + if (sidebar.getAttribute("sidebarcommand") != aSidebar) { + aWindow.SidebarUI.show(aSidebar); + } + // since resizing/moving a window brings it to the foreground, + // we might want to re-focus the last focused window + if (this.windowToFocus) { + this.windowToFocus.focus(); + } + }, + + /* ........ Disk Access .............. */ + + /** + * Save the current session state to disk, after a delay. + * + * @param aWindow (optional) + * Will mark the given window as dirty so that we will recollect its + * data before we start writing. + */ + saveStateDelayed: function (aWindow = null) { + if (aWindow) { + DirtyWindows.add(aWindow); + } + + SessionSaver.runDelayed(); + }, + + /* ........ Auxiliary Functions .............. */ + + /** + * Determines whether or not a tab that is being restored needs + * to have its remoteness flipped first. + * + * @param (object) with the following properties: + * + * tabbrowser (<xul:tabbrowser>): + * The tabbrowser that the browser belongs to. + * + * tab (<xul:tab>): + * The tab being restored + * + * willRestoreImmediately (bool): + * true if the tab is going to have its content + * restored immediately by the caller. + * + */ + _maybeUpdateBrowserRemoteness({ tabbrowser, tab, + willRestoreImmediately }) { + // If the browser we're attempting to restore happens to be + // remote, we need to flip it back to non-remote if it's going + // to go into the pending background tab state. This is to make + // sure that a background tab can't crash if it hasn't yet + // been restored. + // + // Normally, when a window is restored, the tabs that SessionStore + // inserts are non-remote - but the initial browser is, by default, + // remote, so this check and flip covers this case. The other case + // is when window state is overwriting the state of an existing + // window with some remote tabs. + let browser = tab.linkedBrowser; + + // There are two ways that a tab might start restoring its content + // very soon - either the caller is going to restore the content + // immediately, or the TabRestoreQueue is set up so that the tab + // content is going to be restored in the very near future. In + // either case, we don't want to flip remoteness, since the browser + // will soon be loading content. + let willRestore = willRestoreImmediately || + TabRestoreQueue.willRestoreSoon(tab); + + if (browser.isRemoteBrowser && !willRestore) { + tabbrowser.updateBrowserRemoteness(browser, false); + } + }, + + /** + * Update the session start time and send a telemetry measurement + * for the number of days elapsed since the session was started. + * + * @param state + * The session state. + */ + _updateSessionStartTime: function ssi_updateSessionStartTime(state) { + // Attempt to load the session start time from the session state + if (state.session && state.session.startTime) { + this._sessionStartTime = state.session.startTime; + } + }, + + /** + * call a callback for all currently opened browser windows + * (might miss the most recent one) + * @param aFunc + * Callback each window is passed to + */ + _forEachBrowserWindow: function ssi_forEachBrowserWindow(aFunc) { + var windowsEnum = Services.wm.getEnumerator("navigator:browser"); + + while (windowsEnum.hasMoreElements()) { + var window = windowsEnum.getNext(); + if (window.__SSi && !window.closed) { + aFunc.call(this, window); + } + } + }, + + /** + * Returns most recent window + * @returns Window reference + */ + _getMostRecentBrowserWindow: function ssi_getMostRecentBrowserWindow() { + return RecentWindow.getMostRecentBrowserWindow({ allowPopups: true }); + }, + + /** + * Calls onClose for windows that are determined to be closed but aren't + * destroyed yet, which would otherwise cause getBrowserState and + * setBrowserState to treat them as open windows. + */ + _handleClosedWindows: function ssi_handleClosedWindows() { + var windowsEnum = Services.wm.getEnumerator("navigator:browser"); + + while (windowsEnum.hasMoreElements()) { + var window = windowsEnum.getNext(); + if (window.closed) { + this.onClose(window); + } + } + }, + + /** + * open a new browser window for a given session state + * called when restoring a multi-window session + * @param aState + * Object containing session data + */ + _openWindowWithState: function ssi_openWindowWithState(aState) { + var argString = Cc["@mozilla.org/supports-string;1"]. + createInstance(Ci.nsISupportsString); + argString.data = ""; + + // Build feature string + let features = "chrome,dialog=no,macsuppressanimation,all"; + let winState = aState.windows[0]; + WINDOW_ATTRIBUTES.forEach(function(aFeature) { + // Use !isNaN as an easy way to ignore sizemode and check for numbers + if (aFeature in winState && !isNaN(winState[aFeature])) + features += "," + aFeature + "=" + winState[aFeature]; + }); + + if (winState.isPrivate) { + features += ",private"; + } + + var window = + Services.ww.openWindow(null, this._prefBranch.getCharPref("chromeURL"), + "_blank", features, argString); + + do { + var ID = "window" + Math.random(); + } while (ID in this._statesToRestore); + this._statesToRestore[(window.__SS_restoreID = ID)] = aState; + + return window; + }, + + /** + * Whether or not to resume session, if not recovering from a crash. + * @returns bool + */ + _doResumeSession: function ssi_doResumeSession() { + return this._prefBranch.getIntPref("startup.page") == 3 || + this._prefBranch.getBoolPref("sessionstore.resume_session_once"); + }, + + /** + * whether the user wants to load any other page at startup + * (except the homepage) - needed for determining whether to overwrite the current tabs + * C.f.: nsBrowserContentHandler's defaultArgs implementation. + * @returns bool + */ + _isCmdLineEmpty: function ssi_isCmdLineEmpty(aWindow, aState) { + var pinnedOnly = aState.windows && + aState.windows.every(win => + win.tabs.every(tab => tab.pinned)); + + let hasFirstArgument = aWindow.arguments && aWindow.arguments[0]; + if (!pinnedOnly) { + let defaultArgs = Cc["@mozilla.org/browser/clh;1"]. + getService(Ci.nsIBrowserHandler).defaultArgs; + if (aWindow.arguments && + aWindow.arguments[0] && + aWindow.arguments[0] == defaultArgs) + hasFirstArgument = false; + } + + return !hasFirstArgument; + }, + + /** + * on popup windows, the XULWindow's attributes seem not to be set correctly + * we use thus JSDOMWindow attributes for sizemode and normal window attributes + * (and hope for reasonable values when maximized/minimized - since then + * outerWidth/outerHeight aren't the dimensions of the restored window) + * @param aWindow + * Window reference + * @param aAttribute + * String sizemode | width | height | other window attribute + * @returns string + */ + _getWindowDimension: function ssi_getWindowDimension(aWindow, aAttribute) { + if (aAttribute == "sizemode") { + switch (aWindow.windowState) { + case aWindow.STATE_FULLSCREEN: + case aWindow.STATE_MAXIMIZED: + return "maximized"; + case aWindow.STATE_MINIMIZED: + return "minimized"; + default: + return "normal"; + } + } + + var dimension; + switch (aAttribute) { + case "width": + dimension = aWindow.outerWidth; + break; + case "height": + dimension = aWindow.outerHeight; + break; + default: + dimension = aAttribute in aWindow ? aWindow[aAttribute] : ""; + break; + } + + if (aWindow.windowState == aWindow.STATE_NORMAL) { + return dimension; + } + return aWindow.document.documentElement.getAttribute(aAttribute) || dimension; + }, + + /** + * @param aState is a session state + * @param aRecentCrashes is the number of consecutive crashes + * @returns whether a restore page will be needed for the session state + */ + _needsRestorePage: function ssi_needsRestorePage(aState, aRecentCrashes) { + const SIX_HOURS_IN_MS = 6 * 60 * 60 * 1000; + + // don't display the page when there's nothing to restore + let winData = aState.windows || null; + if (!winData || winData.length == 0) + return false; + + // don't wrap a single about:sessionrestore page + if (this._hasSingleTabWithURL(winData, "about:sessionrestore") || + this._hasSingleTabWithURL(winData, "about:welcomeback")) { + return false; + } + + // don't automatically restore in Safe Mode + if (Services.appinfo.inSafeMode) + return true; + + let max_resumed_crashes = + this._prefBranch.getIntPref("sessionstore.max_resumed_crashes"); + let sessionAge = aState.session && aState.session.lastUpdate && + (Date.now() - aState.session.lastUpdate); + + return max_resumed_crashes != -1 && + (aRecentCrashes > max_resumed_crashes || + sessionAge && sessionAge >= SIX_HOURS_IN_MS); + }, + + /** + * @param aWinData is the set of windows in session state + * @param aURL is the single URL we're looking for + * @returns whether the window data contains only the single URL passed + */ + _hasSingleTabWithURL: function(aWinData, aURL) { + if (aWinData && + aWinData.length == 1 && + aWinData[0].tabs && + aWinData[0].tabs.length == 1 && + aWinData[0].tabs[0].entries && + aWinData[0].tabs[0].entries.length == 1) { + return aURL == aWinData[0].tabs[0].entries[0].url; + } + return false; + }, + + /** + * Determine if the tab state we're passed is something we should save. This + * is used when closing a tab or closing a window with a single tab + * + * @param aTabState + * The current tab state + * @returns boolean + */ + _shouldSaveTabState: function ssi_shouldSaveTabState(aTabState) { + // If the tab has only a transient about: history entry, no other + // session history, and no userTypedValue, then we don't actually want to + // store this tab's data. + return aTabState.entries.length && + !(aTabState.entries.length == 1 && + (aTabState.entries[0].url == "about:blank" || + aTabState.entries[0].url == "about:newtab" || + aTabState.entries[0].url == "about:privatebrowsing") && + !aTabState.userTypedValue); + }, + + /** + * This is going to take a state as provided at startup (via + * nsISessionStartup.state) and split it into 2 parts. The first part + * (defaultState) will be a state that should still be restored at startup, + * while the second part (state) is a state that should be saved for later. + * defaultState will be comprised of windows with only pinned tabs, extracted + * from state. It will contain the cookies that go along with the history + * entries in those tabs. It will also contain window position information. + * + * defaultState will be restored at startup. state will be passed into + * LastSession and will be kept in case the user explicitly wants + * to restore the previous session (publicly exposed as restoreLastSession). + * + * @param state + * The state, presumably from nsISessionStartup.state + * @returns [defaultState, state] + */ + _prepDataForDeferredRestore: function ssi_prepDataForDeferredRestore(state) { + // Make sure that we don't modify the global state as provided by + // nsSessionStartup.state. + state = Cu.cloneInto(state, {}); + + let defaultState = { windows: [], selectedWindow: 1 }; + + state.selectedWindow = state.selectedWindow || 1; + + // Look at each window, remove pinned tabs, adjust selectedindex, + // remove window if necessary. + for (let wIndex = 0; wIndex < state.windows.length;) { + let window = state.windows[wIndex]; + window.selected = window.selected || 1; + // We're going to put the state of the window into this object + let pinnedWindowState = { tabs: [], cookies: []}; + for (let tIndex = 0; tIndex < window.tabs.length;) { + if (window.tabs[tIndex].pinned) { + // Adjust window.selected + if (tIndex + 1 < window.selected) + window.selected -= 1; + else if (tIndex + 1 == window.selected) + pinnedWindowState.selected = pinnedWindowState.tabs.length + 2; + // + 2 because the tab isn't actually in the array yet + + // Now add the pinned tab to our window + pinnedWindowState.tabs = + pinnedWindowState.tabs.concat(window.tabs.splice(tIndex, 1)); + // We don't want to increment tIndex here. + continue; + } + tIndex++; + } + + // At this point the window in the state object has been modified (or not) + // We want to build the rest of this new window object if we have pinnedTabs. + if (pinnedWindowState.tabs.length) { + // First get the other attributes off the window + WINDOW_ATTRIBUTES.forEach(function(attr) { + if (attr in window) { + pinnedWindowState[attr] = window[attr]; + delete window[attr]; + } + }); + // We're just copying position data into the pinned window. + // Not copying over: + // - _closedTabs + // - extData + // - isPopup + // - hidden + + // Assign a unique ID to correlate the window to be opened with the + // remaining data + window.__lastSessionWindowID = pinnedWindowState.__lastSessionWindowID + = "" + Date.now() + Math.random(); + + // Extract the cookies that belong with each pinned tab + this._splitCookiesFromWindow(window, pinnedWindowState); + + // Actually add this window to our defaultState + defaultState.windows.push(pinnedWindowState); + // Remove the window from the state if it doesn't have any tabs + if (!window.tabs.length) { + if (wIndex + 1 <= state.selectedWindow) + state.selectedWindow -= 1; + else if (wIndex + 1 == state.selectedWindow) + defaultState.selectedIndex = defaultState.windows.length + 1; + + state.windows.splice(wIndex, 1); + // We don't want to increment wIndex here. + continue; + } + + + } + wIndex++; + } + + return [defaultState, state]; + }, + + /** + * Splits out the cookies from aWinState into aTargetWinState based on the + * tabs that are in aTargetWinState. + * This alters the state of aWinState and aTargetWinState. + */ + _splitCookiesFromWindow: + function ssi_splitCookiesFromWindow(aWinState, aTargetWinState) { + if (!aWinState.cookies || !aWinState.cookies.length) + return; + + // Get the hosts for history entries in aTargetWinState + let cookieHosts = SessionCookies.getHostsForWindow(aTargetWinState); + + // By creating a regex we reduce overhead and there is only one loop pass + // through either array (cookieHosts and aWinState.cookies). + let hosts = Object.keys(cookieHosts).join("|").replace(/\./g, "\\."); + // If we don't actually have any hosts, then we don't want to do anything. + if (!hosts.length) + return; + let cookieRegex = new RegExp(".*(" + hosts + ")"); + for (let cIndex = 0; cIndex < aWinState.cookies.length;) { + if (cookieRegex.test(aWinState.cookies[cIndex].host)) { + aTargetWinState.cookies = + aTargetWinState.cookies.concat(aWinState.cookies.splice(cIndex, 1)); + continue; + } + cIndex++; + } + }, + + _sendRestoreCompletedNotifications: function ssi_sendRestoreCompletedNotifications() { + // not all windows restored, yet + if (this._restoreCount > 1) { + this._restoreCount--; + return; + } + + // observers were already notified + if (this._restoreCount == -1) + return; + + // This was the last window restored at startup, notify observers. + Services.obs.notifyObservers(null, + this._browserSetState ? NOTIFY_BROWSER_STATE_RESTORED : NOTIFY_WINDOWS_RESTORED, + ""); + + this._browserSetState = false; + this._restoreCount = -1; + }, + + /** + * Set the given window's busy state + * @param aWindow the window + * @param aValue the window's busy state + */ + _setWindowStateBusyValue: + function ssi_changeWindowStateBusyValue(aWindow, aValue) { + + this._windows[aWindow.__SSi].busy = aValue; + + // Keep the to-be-restored state in sync because that is returned by + // getWindowState() as long as the window isn't loaded, yet. + if (!this._isWindowLoaded(aWindow)) { + let stateToRestore = this._statesToRestore[aWindow.__SS_restoreID].windows[0]; + stateToRestore.busy = aValue; + } + }, + + /** + * Set the given window's state to 'not busy'. + * @param aWindow the window + */ + _setWindowStateReady: function ssi_setWindowStateReady(aWindow) { + let newCount = (this._windowBusyStates.get(aWindow) || 0) - 1; + if (newCount < 0) { + throw new Error("Invalid window busy state (less than zero)."); + } + this._windowBusyStates.set(aWindow, newCount); + + if (newCount == 0) { + this._setWindowStateBusyValue(aWindow, false); + this._sendWindowStateEvent(aWindow, "Ready"); + } + }, + + /** + * Set the given window's state to 'busy'. + * @param aWindow the window + */ + _setWindowStateBusy: function ssi_setWindowStateBusy(aWindow) { + let newCount = (this._windowBusyStates.get(aWindow) || 0) + 1; + this._windowBusyStates.set(aWindow, newCount); + + if (newCount == 1) { + this._setWindowStateBusyValue(aWindow, true); + this._sendWindowStateEvent(aWindow, "Busy"); + } + }, + + /** + * Dispatch an SSWindowState_____ event for the given window. + * @param aWindow the window + * @param aType the type of event, SSWindowState will be prepended to this string + */ + _sendWindowStateEvent: function ssi_sendWindowStateEvent(aWindow, aType) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowState" + aType, true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch the SSWindowRestored event for the given window. + * @param aWindow + * The window which has been restored + */ + _sendWindowRestoredNotification(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowRestored", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch the SSTabRestored event for the given tab. + * @param aTab + * The tab which has been restored + * @param aIsRemotenessUpdate + * True if this tab was restored due to flip from running from + * out-of-main-process to in-main-process or vice-versa. + */ + _sendTabRestoredNotification(aTab, aIsRemotenessUpdate) { + let event = aTab.ownerDocument.createEvent("CustomEvent"); + event.initCustomEvent("SSTabRestored", true, false, { + isRemotenessUpdate: aIsRemotenessUpdate, + }); + aTab.dispatchEvent(event); + }, + + /** + * @param aWindow + * Window reference + * @returns whether this window's data is still cached in _statesToRestore + * because it's not fully loaded yet + */ + _isWindowLoaded: function ssi_isWindowLoaded(aWindow) { + return !aWindow.__SS_restoreID; + }, + + /** + * Replace "Loading..." with the tab label (with minimal side-effects) + * @param aString is the string the title is stored in + * @param aTabbrowser is a tabbrowser object, containing aTab + * @param aTab is the tab whose title we're updating & using + * + * @returns aString that has been updated with the new title + */ + _replaceLoadingTitle : function ssi_replaceLoadingTitle(aString, aTabbrowser, aTab) { + if (aString == aTabbrowser.mStringBundle.getString("tabs.connecting")) { + aTabbrowser.setTabTitle(aTab); + [aString, aTab.label] = [aTab.label, aString]; + } + return aString; + }, + + /** + * Resize this._closedWindows to the value of the pref, except in the case + * where we don't have any non-popup windows on Windows and Linux. Then we must + * resize such that we have at least one non-popup window. + */ + _capClosedWindows : function ssi_capClosedWindows() { + if (this._closedWindows.length <= this._max_windows_undo) + return; + let spliceTo = this._max_windows_undo; + if (AppConstants.platform != "macosx") { + let normalWindowIndex = 0; + // try to find a non-popup window in this._closedWindows + while (normalWindowIndex < this._closedWindows.length && + !!this._closedWindows[normalWindowIndex].isPopup) + normalWindowIndex++; + if (normalWindowIndex >= this._max_windows_undo) + spliceTo = normalWindowIndex + 1; + } + this._closedWindows.splice(spliceTo, this._closedWindows.length); + }, + + /** + * Clears the set of windows that are "resurrected" before writing to disk to + * make closing windows one after the other until shutdown work as expected. + * + * This function should only be called when we are sure that there has been + * a user action that indicates the browser is actively being used and all + * windows that have been closed before are not part of a series of closing + * windows. + */ + _clearRestoringWindows: function ssi_clearRestoringWindows() { + for (let i = 0; i < this._closedWindows.length; i++) { + delete this._closedWindows[i]._shouldRestore; + } + }, + + /** + * Reset state to prepare for a new session state to be restored. + */ + _resetRestoringState: function ssi_initRestoringState() { + TabRestoreQueue.reset(); + this._tabsRestoringCount = 0; + }, + + /** + * Reset the restoring state for a particular tab. This will be called when + * removing a tab or when a tab needs to be reset (it's being overwritten). + * + * @param aTab + * The tab that will be "reset" + */ + _resetLocalTabRestoringState: function (aTab) { + NS_ASSERT(aTab.linkedBrowser.__SS_restoreState, + "given tab is not restoring"); + + let browser = aTab.linkedBrowser; + + // Keep the tab's previous state for later in this method + let previousState = browser.__SS_restoreState; + + // The browser is no longer in any sort of restoring state. + delete browser.__SS_restoreState; + + aTab.removeAttribute("pending"); + browser.removeAttribute("pending"); + + if (previousState == TAB_STATE_RESTORING) { + if (this._tabsRestoringCount) + this._tabsRestoringCount--; + } else if (previousState == TAB_STATE_NEEDS_RESTORE) { + // Make sure that the tab is removed from the list of tabs to restore. + // Again, this is normally done in restoreTabContent, but that isn't being called + // for this tab. + TabRestoreQueue.remove(aTab); + } + }, + + _resetTabRestoringState: function (tab) { + NS_ASSERT(tab.linkedBrowser.__SS_restoreState, + "given tab is not restoring"); + + let browser = tab.linkedBrowser; + browser.messageManager.sendAsyncMessage("SessionStore:resetRestore", {}); + this._resetLocalTabRestoringState(tab); + }, + + /** + * Each fresh tab starts out with epoch=0. This function can be used to + * start a next epoch by incrementing the current value. It will enables us + * to ignore stale messages sent from previous epochs. The function returns + * the new epoch ID for the given |browser|. + */ + startNextEpoch(browser) { + let next = this.getCurrentEpoch(browser) + 1; + this._browserEpochs.set(browser.permanentKey, next); + return next; + }, + + /** + * Returns the current epoch for the given <browser>. If we haven't assigned + * a new epoch this will default to zero for new tabs. + */ + getCurrentEpoch(browser) { + return this._browserEpochs.get(browser.permanentKey) || 0; + }, + + /** + * Each time a <browser> element is restored, we increment its "epoch". To + * check if a message from content-sessionStore.js is out of date, we can + * compare the epoch received with the message to the <browser> element's + * epoch. This function does that, and returns true if |epoch| is up-to-date + * with respect to |browser|. + */ + isCurrentEpoch: function (browser, epoch) { + return this.getCurrentEpoch(browser) == epoch; + }, + + /** + * Resets the epoch for a given <browser>. We need to this every time we + * receive a hint that a new docShell has been loaded into the browser as + * the frame script starts out with epoch=0. + */ + resetEpoch(browser) { + this._browserEpochs.delete(browser.permanentKey); + }, + + /** + * Handle an error report from a content process. + */ + reportInternalError(data) { + // For the moment, we only report errors through Telemetry. + if (data.telemetry) { + for (let key of Object.keys(data.telemetry)) { + let histogram = Telemetry.getHistogramById(key); + histogram.add(data.telemetry[key]); + } + } + }, + + /** + * Countdown for a given duration, skipping beats if the computer is too busy, + * sleeping or otherwise unavailable. + * + * @param {number} delay An approximate delay to wait in milliseconds (rounded + * up to the closest second). + * + * @return Promise + */ + looseTimer(delay) { + let DELAY_BEAT = 1000; + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + let beats = Math.ceil(delay / DELAY_BEAT); + let promise = new Promise(resolve => { + timer.initWithCallback(function() { + if (beats <= 0) { + resolve(); + } + --beats; + }, DELAY_BEAT, Ci.nsITimer.TYPE_REPEATING_PRECISE_CAN_SKIP); + }); + // Ensure that the timer is both canceled once we are done with it + // and not garbage-collected until then. + promise.then(() => timer.cancel(), () => timer.cancel()); + return promise; + } +}; + +/** + * Priority queue that keeps track of a list of tabs to restore and returns + * the tab we should restore next, based on priority rules. We decide between + * pinned, visible and hidden tabs in that and FIFO order. Hidden tabs are only + * restored with restore_hidden_tabs=true. + */ +var TabRestoreQueue = { + // The separate buckets used to store tabs. + tabs: {priority: [], visible: [], hidden: []}, + + // Preferences used by the TabRestoreQueue to determine which tabs + // are restored automatically and which tabs will be on-demand. + prefs: { + // Lazy getter that returns whether tabs are restored on demand. + get restoreOnDemand() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = {value: value, configurable: true}; + Object.defineProperty(this, "restoreOnDemand", definition); + return value; + } + + const PREF = "browser.sessionstore.restore_on_demand"; + Services.prefs.addObserver(PREF, updateValue, false); + return updateValue(); + }, + + // Lazy getter that returns whether pinned tabs are restored on demand. + get restorePinnedTabsOnDemand() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = {value: value, configurable: true}; + Object.defineProperty(this, "restorePinnedTabsOnDemand", definition); + return value; + } + + const PREF = "browser.sessionstore.restore_pinned_tabs_on_demand"; + Services.prefs.addObserver(PREF, updateValue, false); + return updateValue(); + }, + + // Lazy getter that returns whether we should restore hidden tabs. + get restoreHiddenTabs() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = {value: value, configurable: true}; + Object.defineProperty(this, "restoreHiddenTabs", definition); + return value; + } + + const PREF = "browser.sessionstore.restore_hidden_tabs"; + Services.prefs.addObserver(PREF, updateValue, false); + return updateValue(); + } + }, + + // Resets the queue and removes all tabs. + reset: function () { + this.tabs = {priority: [], visible: [], hidden: []}; + }, + + // Adds a tab to the queue and determines its priority bucket. + add: function (tab) { + let {priority, hidden, visible} = this.tabs; + + if (tab.pinned) { + priority.push(tab); + } else if (tab.hidden) { + hidden.push(tab); + } else { + visible.push(tab); + } + }, + + // Removes a given tab from the queue, if it's in there. + remove: function (tab) { + let {priority, hidden, visible} = this.tabs; + + // We'll always check priority first since we don't + // have an indicator if a tab will be there or not. + let set = priority; + let index = set.indexOf(tab); + + if (index == -1) { + set = tab.hidden ? hidden : visible; + index = set.indexOf(tab); + } + + if (index > -1) { + set.splice(index, 1); + } + }, + + // Returns and removes the tab with the highest priority. + shift: function () { + let set; + let {priority, hidden, visible} = this.tabs; + + let {restoreOnDemand, restorePinnedTabsOnDemand} = this.prefs; + let restorePinned = !(restoreOnDemand && restorePinnedTabsOnDemand); + if (restorePinned && priority.length) { + set = priority; + } else if (!restoreOnDemand) { + if (visible.length) { + set = visible; + } else if (this.prefs.restoreHiddenTabs && hidden.length) { + set = hidden; + } + } + + return set && set.shift(); + }, + + // Moves a given tab from the 'hidden' to the 'visible' bucket. + hiddenToVisible: function (tab) { + let {hidden, visible} = this.tabs; + let index = hidden.indexOf(tab); + + if (index > -1) { + hidden.splice(index, 1); + visible.push(tab); + } + }, + + // Moves a given tab from the 'visible' to the 'hidden' bucket. + visibleToHidden: function (tab) { + let {visible, hidden} = this.tabs; + let index = visible.indexOf(tab); + + if (index > -1) { + visible.splice(index, 1); + hidden.push(tab); + } + }, + + /** + * Returns true if the passed tab is in one of the sets that we're + * restoring content in automatically. + * + * @param tab (<xul:tab>) + * The tab to check + * @returns bool + */ + willRestoreSoon: function (tab) { + let { priority, hidden, visible } = this.tabs; + let { restoreOnDemand, restorePinnedTabsOnDemand, + restoreHiddenTabs } = this.prefs; + let restorePinned = !(restoreOnDemand && restorePinnedTabsOnDemand); + let candidateSet = []; + + if (restorePinned && priority.length) + candidateSet.push(...priority); + + if (!restoreOnDemand) { + if (visible.length) + candidateSet.push(...visible); + + if (restoreHiddenTabs && hidden.length) + candidateSet.push(...hidden); + } + + return candidateSet.indexOf(tab) > -1; + }, +}; + +// A map storing a closed window's state data until it goes aways (is GC'ed). +// This ensures that API clients can still read (but not write) states of +// windows they still hold a reference to but we don't. +var DyingWindowCache = { + _data: new WeakMap(), + + has: function (window) { + return this._data.has(window); + }, + + get: function (window) { + return this._data.get(window); + }, + + set: function (window, data) { + this._data.set(window, data); + }, + + remove: function (window) { + this._data.delete(window); + } +}; + +// A weak set of dirty windows. We use it to determine which windows we need to +// recollect data for when getCurrentState() is called. +var DirtyWindows = { + _data: new WeakMap(), + + has: function (window) { + return this._data.has(window); + }, + + add: function (window) { + return this._data.set(window, true); + }, + + remove: function (window) { + this._data.delete(window); + }, + + clear: function (window) { + this._data = new WeakMap(); + } +}; + +// The state from the previous session (after restoring pinned tabs). This +// state is persisted and passed through to the next session during an app +// restart to make the third party add-on warning not trash the deferred +// session +var LastSession = { + _state: null, + + get canRestore() { + return !!this._state; + }, + + getState: function () { + return this._state; + }, + + setState: function (state) { + this._state = state; + }, + + clear: function () { + if (this._state) { + this._state = null; + Services.obs.notifyObservers(null, NOTIFY_LAST_SESSION_CLEARED, null); + } + } +}; diff --git a/application/basilisk/components/sessionstore/SessionWorker.js b/application/basilisk/components/sessionstore/SessionWorker.js new file mode 100644 index 000000000..7d802a7df --- /dev/null +++ b/application/basilisk/components/sessionstore/SessionWorker.js @@ -0,0 +1,381 @@ +/* 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/. */ + +/** + * A worker dedicated to handle I/O for Session Store. + */ + +"use strict"; + +importScripts("resource://gre/modules/osfile.jsm"); + +var PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js"); + +var File = OS.File; +var Encoder = new TextEncoder(); +var Decoder = new TextDecoder(); + +var worker = new PromiseWorker.AbstractWorker(); +worker.dispatch = function(method, args = []) { + return Agent[method](...args); +}; +worker.postMessage = function(result, ...transfers) { + self.postMessage(result, ...transfers); +}; +worker.close = function() { + self.close(); +}; + +self.addEventListener("message", msg => worker.handleMessage(msg)); + +// The various possible states + +/** + * We just started (we haven't written anything to disk yet) from + * `Paths.clean`. The backup directory may not exist. + */ +const STATE_CLEAN = "clean"; +/** + * We know that `Paths.recovery` is good, either because we just read + * it (we haven't written anything to disk yet) or because have + * already written once to `Paths.recovery` during this session. + * `Paths.clean` is absent or invalid. The backup directory exists. + */ +const STATE_RECOVERY = "recovery"; +/** + * We just started from `Paths.recoverBackupy` (we haven't written + * anything to disk yet). Both `Paths.clean` and `Paths.recovery` are + * absent or invalid. The backup directory exists. + */ +const STATE_RECOVERY_BACKUP = "recoveryBackup"; +/** + * We just started from `Paths.upgradeBackup` (we haven't written + * anything to disk yet). Both `Paths.clean`, `Paths.recovery` and + * `Paths.recoveryBackup` are absent or invalid. The backup directory + * exists. + */ +const STATE_UPGRADE_BACKUP = "upgradeBackup"; +/** + * We just started without a valid session store file (we haven't + * written anything to disk yet). The backup directory may not exist. + */ +const STATE_EMPTY = "empty"; + +var Agent = { + // Path to the files used by the SessionWorker + Paths: null, + + /** + * The current state of the worker, as one of the following strings: + * - "permanent", once the first write has been completed; + * - "empty", before the first write has been completed, + * if we have started without any sessionstore; + * - one of "clean", "recovery", "recoveryBackup", "cleanBackup", + * "upgradeBackup", before the first write has been completed, if + * we have started by loading the corresponding file. + */ + state: null, + + /** + * Number of old upgrade backups that are being kept + */ + maxUpgradeBackups: null, + + /** + * Initialize (or reinitialize) the worker + * + * @param {string} origin Which of sessionstore.js or its backups + * was used. One of the `STATE_*` constants defined above. + * @param {object} paths The paths at which to find the various files. + * @param {object} prefs The preferences the worker needs to known. + */ + init(origin, paths, prefs = {}) { + if (!(origin in paths || origin == STATE_EMPTY)) { + throw new TypeError("Invalid origin: " + origin); + } + + // Check that all required preference values were passed. + for (let pref of ["maxUpgradeBackups", "maxSerializeBack", "maxSerializeForward"]) { + if (!prefs.hasOwnProperty(pref)) { + throw new TypeError(`Missing preference value for ${pref}`); + } + } + + this.state = origin; + this.Paths = paths; + this.maxUpgradeBackups = prefs.maxUpgradeBackups; + this.maxSerializeBack = prefs.maxSerializeBack; + this.maxSerializeForward = prefs.maxSerializeForward; + this.upgradeBackupNeeded = paths.nextUpgradeBackup != paths.upgradeBackup; + return {result: true}; + }, + + /** + * Write the session to disk. + * Write the session to disk, performing any necessary backup + * along the way. + * + * @param {object} state The state to write to disk. + * @param {object} options + * - performShutdownCleanup If |true|, we should + * perform shutdown-time cleanup to ensure that private data + * is not left lying around; + * - isFinalWrite If |true|, write to Paths.clean instead of + * Paths.recovery + */ + write: function (state, options = {}) { + let exn; + let telemetry = {}; + + // Cap the number of backward and forward shistory entries on shutdown. + if (options.isFinalWrite) { + for (let window of state.windows) { + for (let tab of window.tabs) { + let lower = 0; + let upper = tab.entries.length; + + if (this.maxSerializeBack > -1) { + lower = Math.max(lower, tab.index - this.maxSerializeBack - 1); + } + if (this.maxSerializeForward > -1) { + upper = Math.min(upper, tab.index + this.maxSerializeForward); + } + + tab.entries = tab.entries.slice(lower, upper); + tab.index -= lower; + } + } + } + + let stateString = JSON.stringify(state); + let data = Encoder.encode(stateString); + + try { + + if (this.state == STATE_CLEAN || this.state == STATE_EMPTY) { + // The backups directory may not exist yet. In all other cases, + // we have either already read from or already written to this + // directory, so we are satisfied that it exists. + File.makeDir(this.Paths.backups); + } + + if (this.state == STATE_CLEAN) { + // Move $Path.clean out of the way, to avoid any ambiguity as + // to which file is more recent. + File.move(this.Paths.clean, this.Paths.cleanBackup); + } + + let startWriteMs = Date.now(); + + if (options.isFinalWrite) { + // We are shutting down. At this stage, we know that + // $Paths.clean is either absent or corrupted. If it was + // originally present and valid, it has been moved to + // $Paths.cleanBackup a long time ago. We can therefore write + // with the guarantees that we erase no important data. + File.writeAtomic(this.Paths.clean, data, { + tmpPath: this.Paths.clean + ".tmp" + }); + } else if (this.state == STATE_RECOVERY) { + // At this stage, either $Paths.recovery was written >= 15 + // seconds ago during this session or we have just started + // from $Paths.recovery left from the previous session. Either + // way, $Paths.recovery is good. We can move $Path.backup to + // $Path.recoveryBackup without erasing a good file with a bad + // file. + File.writeAtomic(this.Paths.recovery, data, { + tmpPath: this.Paths.recovery + ".tmp", + backupTo: this.Paths.recoveryBackup + }); + } else { + // In other cases, either $Path.recovery is not necessary, or + // it doesn't exist or it has been corrupted. Regardless, + // don't backup $Path.recovery. + File.writeAtomic(this.Paths.recovery, data, { + tmpPath: this.Paths.recovery + ".tmp" + }); + } + + telemetry.FX_SESSION_RESTORE_WRITE_FILE_MS = Date.now() - startWriteMs; + telemetry.FX_SESSION_RESTORE_FILE_SIZE_BYTES = data.byteLength; + + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // If necessary, perform an upgrade backup + let upgradeBackupComplete = false; + if (this.upgradeBackupNeeded + && (this.state == STATE_CLEAN || this.state == STATE_UPGRADE_BACKUP)) { + try { + // If we loaded from `clean`, the file has since then been renamed to `cleanBackup`. + let path = this.state == STATE_CLEAN ? this.Paths.cleanBackup : this.Paths.upgradeBackup; + File.copy(path, this.Paths.nextUpgradeBackup); + this.upgradeBackupNeeded = false; + upgradeBackupComplete = true; + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // Find all backups + let iterator; + let backups = []; // array that will contain the paths to all upgrade backup + let upgradeBackupPrefix = this.Paths.upgradeBackupPrefix; // access for forEach callback + + try { + iterator = new File.DirectoryIterator(this.Paths.backups); + iterator.forEach(function (file) { + if (file.path.startsWith(upgradeBackupPrefix)) { + backups.push(file.path); + } + }, this); + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } finally { + if (iterator) { + iterator.close(); + } + } + + // If too many backups exist, delete them + if (backups.length > this.maxUpgradeBackups) { + // Use alphanumerical sort since dates are in YYYYMMDDHHMMSS format + backups.sort().forEach((file, i) => { + // remove backup file if it is among the first (n-maxUpgradeBackups) files + if (i < backups.length - this.maxUpgradeBackups) { + File.remove(file); + } + }); + } + } + + if (options.performShutdownCleanup && !exn) { + + // During shutdown, if auto-restore is disabled, we need to + // remove possibly sensitive data that has been stored purely + // for crash recovery. Note that this slightly decreases our + // ability to recover from OS-level/hardware-level issue. + + // If an exception was raised, we assume that we still need + // these files. + File.remove(this.Paths.recoveryBackup); + File.remove(this.Paths.recovery); + } + + this.state = STATE_RECOVERY; + + if (exn) { + throw exn; + } + + return { + result: { + upgradeBackup: upgradeBackupComplete + }, + telemetry: telemetry, + }; + }, + + /** + * Wipes all files holding session data from disk. + */ + wipe: function () { + + // Don't stop immediately in case of error. + let exn = null; + + // Erase main session state file + try { + File.remove(this.Paths.clean); + } catch (ex) { + // Don't stop immediately. + exn = exn || ex; + } + + // Wipe the Session Restore directory + try { + this._wipeFromDir(this.Paths.backups, null); + } catch (ex) { + exn = exn || ex; + } + + try { + File.removeDir(this.Paths.backups); + } catch (ex) { + exn = exn || ex; + } + + // Wipe legacy Ression Restore files from the profile directory + try { + this._wipeFromDir(OS.Constants.Path.profileDir, "sessionstore.bak"); + } catch (ex) { + exn = exn || ex; + } + + + this.state = STATE_EMPTY; + if (exn) { + throw exn; + } + + return { result: true }; + }, + + /** + * Wipe a number of files from a directory. + * + * @param {string} path The directory. + * @param {string|null} prefix If provided, only remove files whose + * name starts with a specific prefix. + */ + _wipeFromDir: function(path, prefix) { + // Sanity check + if (typeof prefix == "undefined" || prefix == "") { + throw new TypeError(); + } + + let exn = null; + + let iterator = new File.DirectoryIterator(path); + try { + if (!iterator.exists()) { + return; + } + for (let entry in iterator) { + if (entry.isDir) { + continue; + } + if (!prefix || entry.name.startsWith(prefix)) { + try { + File.remove(entry.path); + } catch (ex) { + // Don't stop immediately + exn = exn || ex; + } + } + } + + if (exn) { + throw exn; + } + } finally { + iterator.close(); + } + }, +}; + +function isNoSuchFileEx(aReason) { + return aReason instanceof OS.File.Error && aReason.becauseNoSuchFile; +} + +/** + * Estimate the number of bytes that a data structure will use on disk + * once serialized. + */ +function getByteLength(str) { + return Encoder.encode(JSON.stringify(str)).byteLength; +} diff --git a/application/basilisk/components/sessionstore/SessionWorker.jsm b/application/basilisk/components/sessionstore/SessionWorker.jsm new file mode 100644 index 000000000..b26e531ac --- /dev/null +++ b/application/basilisk/components/sessionstore/SessionWorker.jsm @@ -0,0 +1,25 @@ +/* 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"; + +/** + * Interface to a dedicated thread handling I/O + */ + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; + +Cu.import("resource://gre/modules/PromiseWorker.jsm", this); +Cu.import("resource://gre/modules/osfile.jsm", this); + +this.EXPORTED_SYMBOLS = ["SessionWorker"]; + +this.SessionWorker = new BasePromiseWorker("resource:///modules/sessionstore/SessionWorker.js"); +// As the Session Worker performs I/O, we can receive instances of +// OS.File.Error, so we need to install a decoder. +this.SessionWorker.ExceptionHandlers["OS.File.Error"] = OS.File.Error.fromMsg; + diff --git a/application/basilisk/components/sessionstore/StartupPerformance.jsm b/application/basilisk/components/sessionstore/StartupPerformance.jsm new file mode 100644 index 000000000..d1b77a237 --- /dev/null +++ b/application/basilisk/components/sessionstore/StartupPerformance.jsm @@ -0,0 +1,234 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["StartupPerformance"]; + +const { utils: Cu, classes: Cc, interfaces: Ci } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "setTimeout", + "resource://gre/modules/Timer.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "clearTimeout", + "resource://gre/modules/Timer.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); + +const COLLECT_RESULTS_AFTER_MS = 10000; + +const OBSERVED_TOPICS = ["sessionstore-restoring-on-startup", "sessionstore-initiating-manual-restore"]; + +this.StartupPerformance = { + /** + * Once we have finished restoring initial tabs, we broadcast on this topic. + */ + RESTORED_TOPIC: "sessionstore-finished-restoring-initial-tabs", + + // Instant at which we have started restoration (notification "sessionstore-restoring-on-startup") + _startTimeStamp: null, + + // Latest instant at which we have finished restoring a tab (DOM event "SSTabRestored") + _latestRestoredTimeStamp: null, + + // A promise resolved once we have finished restoring all the startup tabs. + _promiseFinished: null, + + // Function `resolve()` for `_promiseFinished`. + _resolveFinished: null, + + // A timer + _deadlineTimer: null, + + // `true` once the timer has fired + _hasFired: false, + + // `true` once we are restored + _isRestored: false, + + // Statistics on the session we need to restore. + _totalNumberOfEagerTabs: 0, + _totalNumberOfTabs: 0, + _totalNumberOfWindows: 0, + + init: function() { + for (let topic of OBSERVED_TOPICS) { + Services.obs.addObserver(this, topic, false); + } + }, + + /** + * Return the timestamp at which we finished restoring the latest tab. + * + * This information is not really interesting until we have finished restoring + * tabs. + */ + get latestRestoredTimeStamp() { + return this._latestRestoredTimeStamp; + }, + + /** + * `true` once we have finished restoring startup tabs. + */ + get isRestored() { + return this._isRestored; + }, + + // Called when restoration starts. + // Record the start timestamp, setup the timer and `this._promiseFinished`. + // Behavior is unspecified if there was already an ongoing measure. + _onRestorationStarts: function(isAutoRestore) { + this._latestRestoredTimeStamp = this._startTimeStamp = Date.now(); + this._totalNumberOfEagerTabs = 0; + this._totalNumberOfTabs = 0; + this._totalNumberOfWindows = 0; + + // While we may restore several sessions in a single run of the browser, + // that's a very unusual case, and not really worth measuring, so let's + // stop listening for further restorations. + + for (let topic of OBSERVED_TOPICS) { + Services.obs.removeObserver(this, topic); + } + + Services.obs.addObserver(this, "sessionstore-single-window-restored", false); + this._promiseFinished = new Promise(resolve => { + this._resolveFinished = resolve; + }); + this._promiseFinished.then(() => { + try { + this._isRestored = true; + Services.obs.notifyObservers(null, this.RESTORED_TOPIC, ""); + + if (this._latestRestoredTimeStamp == this._startTimeStamp) { + // Apparently, we haven't restored any tab. + return; + } + + // Once we are done restoring tabs, update Telemetry. + let histogramName = isAutoRestore ? + "FX_SESSION_RESTORE_AUTO_RESTORE_DURATION_UNTIL_EAGER_TABS_RESTORED_MS" : + "FX_SESSION_RESTORE_MANUAL_RESTORE_DURATION_UNTIL_EAGER_TABS_RESTORED_MS"; + let histogram = Services.telemetry.getHistogramById(histogramName); + let delta = this._latestRestoredTimeStamp - this._startTimeStamp; + histogram.add(delta); + + Services.telemetry.getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_EAGER_TABS_RESTORED").add(this._totalNumberOfEagerTabs); + Services.telemetry.getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_TABS_RESTORED").add(this._totalNumberOfTabs); + Services.telemetry.getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_WINDOWS_RESTORED").add(this._totalNumberOfWindows); + + // Reset + this._startTimeStamp = null; + } catch (ex) { + console.error("StartupPerformance: error after resolving promise", ex); + } + }); + }, + + _startTimer: function() { + if (this._hasFired) { + return; + } + if (this._deadlineTimer) { + clearTimeout(this._deadlineTimer); + } + this._deadlineTimer = setTimeout(() => { + try { + this._resolveFinished(); + } catch (ex) { + console.error("StartupPerformance: Error in timeout handler", ex); + } finally { + // Clean up. + this._deadlineTimer = null; + this._hasFired = true; + this._resolveFinished = null; + Services.obs.removeObserver(this, "sessionstore-single-window-restored"); + } + }, COLLECT_RESULTS_AFTER_MS); + }, + + observe: function(subject, topic, details) { + try { + switch (topic) { + case "sessionstore-restoring-on-startup": + this._onRestorationStarts(true); + break; + case "sessionstore-initiating-manual-restore": + this._onRestorationStarts(false); + break; + case "sessionstore-single-window-restored": { + // Session Restore has just opened a window with (initially empty) tabs. + // Some of these tabs will be restored eagerly, while others will be + // restored on demand. The process becomes usable only when all windows + // have finished restored their eager tabs. + // + // While it would be possible to track the restoration of each tab + // from within SessionRestore to determine exactly when the process + // becomes usable, experience shows that this is too invasive. Rather, + // we employ the following heuristic: + // - we maintain a timer of `COLLECT_RESULTS_AFTER_MS` that we expect + // will be triggered only once all tabs have been restored; + // - whenever we restore a new window (hence a bunch of eager tabs), + // we postpone the timer to ensure that the new eager tabs have + // `COLLECT_RESULTS_AFTER_MS` to be restored; + // - whenever a tab is restored, we update + // `this._latestRestoredTimeStamp`; + // - after `COLLECT_RESULTS_AFTER_MS`, we collect the final version + // of `this._latestRestoredTimeStamp`, and use it to determine the + // entire duration of the collection. + // + // Note that this heuristic may be inaccurate if a user clicks + // immediately on a restore-on-demand tab before the end of + // `COLLECT_RESULTS_AFTER_MS`. We assume that this will not + // affect too much the results. + // + // Reset the delay, to give the tabs a little (more) time to restore. + this._startTimer(); + + this._totalNumberOfWindows += 1; + + // Observe the restoration of all tabs. We assume that all tabs of this + // window will have been restored before `COLLECT_RESULTS_AFTER_MS`. + // The last call to `observer` will let us determine how long it took + // to reach that point. + let win = subject; + + let observer = (event) => { + // We don't care about tab restorations that are due to + // a browser flipping from out-of-main-process to in-main-process + // or vice-versa. We only care about restorations that are due + // to the user switching to a lazily restored tab, or for tabs + // that are restoring eagerly. + if (!event.detail.isRemotenessUpdate) { + this._latestRestoredTimeStamp = Date.now(); + this._totalNumberOfEagerTabs += 1; + } + }; + win.gBrowser.tabContainer.addEventListener("SSTabRestored", observer); + this._totalNumberOfTabs += win.gBrowser.tabContainer.itemCount; + + // Once we have finished collecting the results, clean up the observers. + this._promiseFinished.then(() => { + if (!win.gBrowser.tabContainer) { + // May be undefined during shutdown and/or some tests. + return; + } + win.gBrowser.tabContainer.removeEventListener("SSTabRestored", observer); + }); + } + break; + default: + throw new Error(`Unexpected topic ${topic}`); + } + } catch (ex) { + console.error("StartupPerformance error", ex, ex.stack); + throw ex; + } + } +}; diff --git a/application/basilisk/components/sessionstore/TabAttributes.jsm b/application/basilisk/components/sessionstore/TabAttributes.jsm new file mode 100644 index 000000000..8a29680f4 --- /dev/null +++ b/application/basilisk/components/sessionstore/TabAttributes.jsm @@ -0,0 +1,74 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["TabAttributes"]; + +// We never want to directly read or write these attributes. +// 'image' should not be accessed directly but handled by using the +// gBrowser.getIcon()/setIcon() methods. +// 'muted' should not be accessed directly but handled by using the +// tab.linkedBrowser.audioMuted/toggleMuteAudio methods. +// 'pending' is used internal by sessionstore and managed accordingly. +// 'iconLoadingPrincipal' is same as 'image' that it should be handled by +// using the gBrowser.getIcon()/setIcon() methods. +const ATTRIBUTES_TO_SKIP = new Set(["image", "muted", "pending", "iconLoadingPrincipal"]); + +// A set of tab attributes to persist. We will read a given list of tab +// attributes when collecting tab data and will re-set those attributes when +// the given tab data is restored to a new tab. +this.TabAttributes = Object.freeze({ + persist: function (name) { + return TabAttributesInternal.persist(name); + }, + + get: function (tab) { + return TabAttributesInternal.get(tab); + }, + + set: function (tab, data = {}) { + TabAttributesInternal.set(tab, data); + } +}); + +var TabAttributesInternal = { + _attrs: new Set(), + + persist: function (name) { + if (this._attrs.has(name) || ATTRIBUTES_TO_SKIP.has(name)) { + return false; + } + + this._attrs.add(name); + return true; + }, + + get: function (tab) { + let data = {}; + + for (let name of this._attrs) { + if (tab.hasAttribute(name)) { + data[name] = tab.getAttribute(name); + } + } + + return data; + }, + + set: function (tab, data = {}) { + // Clear attributes. + for (let name of this._attrs) { + tab.removeAttribute(name); + } + + // Set attributes. + for (let name in data) { + if (!ATTRIBUTES_TO_SKIP.has(name)) { + tab.setAttribute(name, data[name]); + } + } + } +}; + diff --git a/application/basilisk/components/sessionstore/TabState.jsm b/application/basilisk/components/sessionstore/TabState.jsm new file mode 100644 index 000000000..f22c52fe3 --- /dev/null +++ b/application/basilisk/components/sessionstore/TabState.jsm @@ -0,0 +1,196 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["TabState"]; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyFilter", + "resource:///modules/sessionstore/PrivacyFilter.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabStateCache", + "resource:///modules/sessionstore/TabStateCache.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabAttributes", + "resource:///modules/sessionstore/TabAttributes.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://gre/modules/sessionstore/Utils.jsm"); + +/** + * Module that contains tab state collection methods. + */ +this.TabState = Object.freeze({ + update: function (browser, data) { + TabStateInternal.update(browser, data); + }, + + collect: function (tab) { + return TabStateInternal.collect(tab); + }, + + clone: function (tab) { + return TabStateInternal.clone(tab); + }, + + copyFromCache(browser, tabData, options) { + TabStateInternal.copyFromCache(browser, tabData, options); + }, +}); + +var TabStateInternal = { + /** + * Processes a data update sent by the content script. + */ + update: function (browser, {data}) { + TabStateCache.update(browser, data); + }, + + /** + * Collect data related to a single tab, synchronously. + * + * @param tab + * tabbrowser tab + * + * @returns {TabData} An object with the data for this tab. If the + * tab has not been invalidated since the last call to + * collect(aTab), the same object is returned. + */ + collect: function (tab) { + return this._collectBaseTabData(tab); + }, + + /** + * Collect data related to a single tab, including private data. + * Use with caution. + * + * @param tab + * tabbrowser tab + * + * @returns {object} An object with the data for this tab. This data is never + * cached, it will always be read from the tab and thus be + * up-to-date. + */ + clone: function (tab) { + return this._collectBaseTabData(tab, {includePrivateData: true}); + }, + + /** + * Collects basic tab data for a given tab. + * + * @param tab + * tabbrowser tab + * @param options (object) + * {includePrivateData: true} to always include private data + * + * @returns {object} An object with the basic data for this tab. + */ + _collectBaseTabData: function (tab, options) { + let tabData = { entries: [], lastAccessed: tab.lastAccessed }; + let browser = tab.linkedBrowser; + + if (tab.pinned) { + tabData.pinned = true; + } + + tabData.hidden = tab.hidden; + + if (browser.audioMuted) { + tabData.muted = true; + tabData.muteReason = tab.muteReason; + } + + // Save tab attributes. + tabData.attributes = TabAttributes.get(tab); + + if (tab.__SS_extdata) { + tabData.extData = tab.__SS_extdata; + } + + // Copy data from the tab state cache only if the tab has fully finished + // restoring. We don't want to overwrite data contained in __SS_data. + this.copyFromCache(browser, tabData, options); + + // After copyFromCache() was called we check for properties that are kept + // in the cache only while the tab is pending or restoring. Once that + // happened those properties will be removed from the cache and will + // be read from the tab/browser every time we collect data. + + // Store the tab icon. + if (!("image" in tabData)) { + let tabbrowser = tab.ownerGlobal.gBrowser; + tabData.image = tabbrowser.getIcon(tab); + } + + // Store the serialized contentPrincipal of this tab to use for the icon. + if (!("iconLoadingPrincipal" in tabData)) { + tabData.iconLoadingPrincipal = Utils.serializePrincipal(browser.contentPrincipal); + } + + // If there is a userTypedValue set, then either the user has typed something + // in the URL bar, or a new tab was opened with a URI to load. + // If so, we also track whether we were still in the process of loading something. + if (!("userTypedValue" in tabData) && browser.userTypedValue) { + tabData.userTypedValue = browser.userTypedValue; + // We always used to keep track of the loading state as an integer, where + // '0' indicated the user had typed since the last load (or no load was + // ongoing), and any positive value indicated we had started a load since + // the last time the user typed in the URL bar. Mimic this to keep the + // session store representation in sync, even though we now represent this + // more explicitly: + tabData.userTypedClear = browser.didStartLoadSinceLastUserTyping() ? 1 : 0; + } + + return tabData; + }, + + /** + * Copy data for the given |browser| from the cache to |tabData|. + * + * @param browser (xul:browser) + * The browser belonging to the given |tabData| object. + * @param tabData (object) + * The tab data belonging to the given |tab|. + * @param options (object) + * {includePrivateData: true} to always include private data + */ + copyFromCache(browser, tabData, options = {}) { + let data = TabStateCache.get(browser); + if (!data) { + return; + } + + // The caller may explicitly request to omit privacy checks. + let includePrivateData = options && options.includePrivateData; + let isPinned = !!tabData.pinned; + + for (let key of Object.keys(data)) { + let value = data[key]; + + // Filter sensitive data according to the current privacy level. + if (!includePrivateData) { + if (key === "storage") { + value = PrivacyFilter.filterSessionStorageData(value); + } else if (key === "formdata") { + value = PrivacyFilter.filterFormData(value); + } + } + + if (key === "history") { + tabData.entries = value.entries; + + if (value.hasOwnProperty("userContextId")) { + tabData.userContextId = value.userContextId; + } + + if (value.hasOwnProperty("index")) { + tabData.index = value.index; + } + } else { + tabData[key] = value; + } + } + } +}; diff --git a/application/basilisk/components/sessionstore/TabStateCache.jsm b/application/basilisk/components/sessionstore/TabStateCache.jsm new file mode 100644 index 000000000..9bed315a0 --- /dev/null +++ b/application/basilisk/components/sessionstore/TabStateCache.jsm @@ -0,0 +1,163 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["TabStateCache"]; + +/** + * A cache for tabs data. + * + * This cache implements a weak map from tabs (as XUL elements) + * to tab data (as objects). + * + * Note that we should never cache private data, as: + * - that data is used very seldom by SessionStore; + * - caching private data in addition to public data is memory consuming. + */ +this.TabStateCache = Object.freeze({ + /** + * Retrieves cached data for a given |tab| or associated |browser|. + * + * @param browserOrTab (xul:tab or xul:browser) + * The tab or browser to retrieve cached data for. + * @return (object) + * The cached data stored for the given |tab| + * or associated |browser|. + */ + get: function (browserOrTab) { + return TabStateCacheInternal.get(browserOrTab); + }, + + /** + * Updates cached data for a given |tab| or associated |browser|. + * + * @param browserOrTab (xul:tab or xul:browser) + * The tab or browser belonging to the given tab data. + * @param newData (object) + * The new data to be stored for the given |tab| + * or associated |browser|. + */ + update: function (browserOrTab, newData) { + TabStateCacheInternal.update(browserOrTab, newData); + } +}); + +var TabStateCacheInternal = { + _data: new WeakMap(), + + /** + * Retrieves cached data for a given |tab| or associated |browser|. + * + * @param browserOrTab (xul:tab or xul:browser) + * The tab or browser to retrieve cached data for. + * @return (object) + * The cached data stored for the given |tab| + * or associated |browser|. + */ + get: function (browserOrTab) { + return this._data.get(browserOrTab.permanentKey); + }, + + /** + * Helper function used by update (see below). For message size + * optimization sometimes we don't update the whole session storage + * only the values that have been changed. + * + * @param data (object) + * The cached data where we want to update the changes. + * @param change (object) + * The actual changed values per domain. + */ + updatePartialStorageChange: function (data, change) { + if (!data.storage) { + data.storage = {}; + } + + let storage = data.storage; + for (let domain of Object.keys(change)) { + for (let key of Object.keys(change[domain])) { + let value = change[domain][key]; + if (value === null) { + if (storage[domain] && storage[domain][key]) { + delete storage[domain][key]; + } + } else { + if (!storage[domain]) { + storage[domain] = {}; + } + storage[domain][key] = value; + } + } + } + }, + + /** + * Helper function used by update (see below). For message size + * optimization sometimes we don't update the whole browser history + * only the current index and the tail of the history from a certain + * index (specified by change.fromIdx) + * + * @param data (object) + * The cached data where we want to update the changes. + * @param change (object) + * Object containing the tail of the history array, and + * some additional metadata. + */ + updatePartialHistoryChange: function (data, change) { + const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + + if (!data.history) { + data.history = { entries: [] }; + } + + let history = data.history; + for (let key of Object.keys(change)) { + if (key == "entries") { + if (change.fromIdx != kLastIndex) { + history.entries.splice(change.fromIdx + 1); + while (change.entries.length) { + history.entries.push(change.entries.shift()); + } + } + } else if (key != "fromIndex") { + history[key] = change[key]; + } + } + }, + + /** + * Updates cached data for a given |tab| or associated |browser|. + * + * @param browserOrTab (xul:tab or xul:browser) + * The tab or browser belonging to the given tab data. + * @param newData (object) + * The new data to be stored for the given |tab| + * or associated |browser|. + */ + update: function (browserOrTab, newData) { + let data = this._data.get(browserOrTab.permanentKey) || {}; + + for (let key of Object.keys(newData)) { + if (key == "storagechange") { + this.updatePartialStorageChange(data, newData.storagechange); + continue; + } + + if (key == "historychange") { + this.updatePartialHistoryChange(data, newData.historychange); + continue; + } + + let value = newData[key]; + if (value === null) { + delete data[key]; + } else { + data[key] = value; + } + } + + this._data.set(browserOrTab.permanentKey, data); + } +}; diff --git a/application/basilisk/components/sessionstore/TabStateFlusher.jsm b/application/basilisk/components/sessionstore/TabStateFlusher.jsm new file mode 100644 index 000000000..6397efe9d --- /dev/null +++ b/application/basilisk/components/sessionstore/TabStateFlusher.jsm @@ -0,0 +1,184 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["TabStateFlusher"]; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/Promise.jsm", this); + +/** + * A module that enables async flushes. Updates from frame scripts are + * throttled to be sent only once per second. If an action wants a tab's latest + * state without waiting for a second then it can request an async flush and + * wait until the frame scripts reported back. At this point the parent has the + * latest data and the action can continue. + */ +this.TabStateFlusher = Object.freeze({ + /** + * Requests an async flush for the given browser. Returns a promise that will + * resolve when we heard back from the content process and the parent has + * all the latest data. + */ + flush(browser) { + return TabStateFlusherInternal.flush(browser); + }, + + /** + * Requests an async flush for all browsers of a given window. Returns a Promise + * that will resolve when we've heard back from all browsers. + */ + flushWindow(window) { + return TabStateFlusherInternal.flushWindow(window); + }, + + /** + * Resolves the flush request with the given flush ID. + * + * @param browser (<xul:browser>) + * The browser for which the flush is being resolved. + * @param flushID (int) + * The ID of the flush that was sent to the browser. + * @param success (bool, optional) + * Whether or not the flush succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that a flush failed. + */ + resolve(browser, flushID, success=true, message="") { + TabStateFlusherInternal.resolve(browser, flushID, success, message); + }, + + /** + * Resolves all active flush requests for a given browser. This should be + * used when the content process crashed or the final update message was + * seen. In those cases we can't guarantee to ever hear back from the frame + * script so we just resolve all requests instead of discarding them. + * + * @param browser (<xul:browser>) + * The browser for which all flushes are being resolved. + * @param success (bool, optional) + * Whether or not the flushes succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that the flushes failed. + */ + resolveAll(browser, success=true, message="") { + TabStateFlusherInternal.resolveAll(browser, success, message); + } +}); + +var TabStateFlusherInternal = { + // Stores the last request ID. + _lastRequestID: 0, + + // A map storing all active requests per browser. + _requests: new WeakMap(), + + /** + * Requests an async flush for the given browser. Returns a promise that will + * resolve when we heard back from the content process and the parent has + * all the latest data. + */ + flush(browser) { + let id = ++this._lastRequestID; + let mm = browser.messageManager; + mm.sendAsyncMessage("SessionStore:flush", {id}); + + // Retrieve active requests for given browser. + let permanentKey = browser.permanentKey; + let perBrowserRequests = this._requests.get(permanentKey) || new Map(); + + return new Promise(resolve => { + // Store resolve() so that we can resolve the promise later. + perBrowserRequests.set(id, resolve); + + // Update the flush requests stored per browser. + this._requests.set(permanentKey, perBrowserRequests); + }); + }, + + /** + * Requests an async flush for all browsers of a given window. Returns a Promise + * that will resolve when we've heard back from all browsers. + */ + flushWindow(window) { + let browsers = window.gBrowser.browsers; + let promises = browsers.map((browser) => this.flush(browser)); + return Promise.all(promises); + }, + + /** + * Resolves the flush request with the given flush ID. + * + * @param browser (<xul:browser>) + * The browser for which the flush is being resolved. + * @param flushID (int) + * The ID of the flush that was sent to the browser. + * @param success (bool, optional) + * Whether or not the flush succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that a flush failed. + */ + resolve(browser, flushID, success=true, message="") { + // Nothing to do if there are no pending flushes for the given browser. + if (!this._requests.has(browser.permanentKey)) { + return; + } + + // Retrieve active requests for given browser. + let perBrowserRequests = this._requests.get(browser.permanentKey); + if (!perBrowserRequests.has(flushID)) { + return; + } + + if (!success) { + Cu.reportError("Failed to flush browser: " + message); + } + + // Resolve the request with the given id. + let resolve = perBrowserRequests.get(flushID); + perBrowserRequests.delete(flushID); + resolve(success); + }, + + /** + * Resolves all active flush requests for a given browser. This should be + * used when the content process crashed or the final update message was + * seen. In those cases we can't guarantee to ever hear back from the frame + * script so we just resolve all requests instead of discarding them. + * + * @param browser (<xul:browser>) + * The browser for which all flushes are being resolved. + * @param success (bool, optional) + * Whether or not the flushes succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that the flushes failed. + */ + resolveAll(browser, success=true, message="") { + // Nothing to do if there are no pending flushes for the given browser. + if (!this._requests.has(browser.permanentKey)) { + return; + } + + // Retrieve active requests for given browser. + let perBrowserRequests = this._requests.get(browser.permanentKey); + + if (!success) { + Cu.reportError("Failed to flush browser: " + message); + } + + // Resolve all requests. + for (let resolve of perBrowserRequests.values()) { + resolve(success); + } + + // Clear active requests. + perBrowserRequests.clear(); + } +}; diff --git a/application/basilisk/components/sessionstore/content/aboutSessionRestore.js b/application/basilisk/components/sessionstore/content/aboutSessionRestore.js new file mode 100644 index 000000000..8f265235d --- /dev/null +++ b/application/basilisk/components/sessionstore/content/aboutSessionRestore.js @@ -0,0 +1,373 @@ +/* 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; + } + + try { + gStateObject = JSON.parse(sessionData.value); + } catch (e) { + Cu.reportError(e); + } + + // 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 = []; + if (gStateObject) { + 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; + + if (!treeView.treeBox) { + 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 gTreeData[idx] ? "open" in gTreeData[idx] : false; + }, + 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/application/basilisk/components/sessionstore/content/aboutSessionRestore.xhtml b/application/basilisk/components/sessionstore/content/aboutSessionRestore.xhtml new file mode 100644 index 000000000..bcd9084e7 --- /dev/null +++ b/application/basilisk/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/application/basilisk/components/sessionstore/content/content-sessionStore.js b/application/basilisk/components/sessionstore/content/content-sessionStore.js new file mode 100644 index 000000000..858e35750 --- /dev/null +++ b/application/basilisk/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. +}); diff --git a/application/basilisk/components/sessionstore/jar.mn b/application/basilisk/components/sessionstore/jar.mn new file mode 100644 index 000000000..7e5bc07dc --- /dev/null +++ b/application/basilisk/components/sessionstore/jar.mn @@ -0,0 +1,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/. + +browser.jar: +* content/browser/aboutSessionRestore.xhtml (content/aboutSessionRestore.xhtml) + content/browser/aboutSessionRestore.js (content/aboutSessionRestore.js) + content/browser/content-sessionStore.js (content/content-sessionStore.js) diff --git a/application/basilisk/components/sessionstore/moz.build b/application/basilisk/components/sessionstore/moz.build new file mode 100644 index 000000000..3117f02c7 --- /dev/null +++ b/application/basilisk/components/sessionstore/moz.build @@ -0,0 +1,46 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ['jar.mn'] + +XPIDL_SOURCES += [ + 'nsISessionStartup.idl', + 'nsISessionStore.idl', +] + +XPIDL_MODULE = 'sessionstore' + +EXTRA_COMPONENTS += [ + 'nsSessionStartup.js', + 'nsSessionStore.js', + 'nsSessionStore.manifest', +] + +EXTRA_JS_MODULES.sessionstore = [ + 'ContentRestore.jsm', + 'DocShellCapabilities.jsm', + 'FrameTree.jsm', + 'GlobalState.jsm', + 'PageStyle.jsm', + 'PrivacyFilter.jsm', + 'PrivacyLevel.jsm', + 'RecentlyClosedTabsAndWindowsMenuUtils.jsm', + 'RunState.jsm', + 'SessionCookies.jsm', + 'SessionFile.jsm', + 'SessionHistory.jsm', + 'SessionMigration.jsm', + 'SessionSaver.jsm', + 'SessionStorage.jsm', + 'SessionStore.jsm', + 'SessionWorker.js', + 'SessionWorker.jsm', + 'StartupPerformance.jsm', + 'TabAttributes.jsm', + 'TabState.jsm', + 'TabStateCache.jsm', + 'TabStateFlusher.jsm', +] diff --git a/application/basilisk/components/sessionstore/nsISessionStartup.idl b/application/basilisk/components/sessionstore/nsISessionStartup.idl new file mode 100644 index 000000000..2321ac310 --- /dev/null +++ b/application/basilisk/components/sessionstore/nsISessionStartup.idl @@ -0,0 +1,66 @@ +/* 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/. */ + +#include "nsISupports.idl" + +/** + * nsISessionStore keeps track of the current browsing state - i.e. + * tab history, cookies, scroll state, form data, and window features + * - and allows to restore everything into one window. + */ + +[scriptable, uuid(934697e4-3807-47f8-b6c9-6caa8d83ccd1)] +interface nsISessionStartup: nsISupports +{ + /** + * Return a promise that is resolved once initialization + * is complete. + */ + readonly attribute jsval onceInitialized; + + // Get session state + readonly attribute jsval state; + + /** + * Determines whether there is a pending session restore. Should only be + * called after initialization has completed. + */ + boolean doRestore(); + + /** + * Determines whether automatic session restoration is enabled for this + * launch of the browser. This does not include crash restoration, and will + * return false if restoration will only be caused by a crash. + */ + boolean isAutomaticRestoreEnabled(); + + /** + * Returns whether we will restore a session that ends up replacing the + * homepage. The browser uses this to not start loading the homepage if + * we're going to stop its load anyway shortly after. + * + * This is meant to be an optimization for the average case that loading the + * session file finishes before we may want to start loading the default + * homepage. Should this be called before the session file has been read it + * will just return false. + */ + readonly attribute bool willOverrideHomepage; + + /** + * What type of session we're restoring. + * NO_SESSION There is no data available from the previous session + * RECOVER_SESSION The last session crashed. It will either be restored or + * about:sessionrestore will be shown. + * RESUME_SESSION The previous session should be restored at startup + * DEFER_SESSION The previous session is fine, but it shouldn't be restored + * without explicit action (with the exception of pinned tabs) + */ + const unsigned long NO_SESSION = 0; + const unsigned long RECOVER_SESSION = 1; + const unsigned long RESUME_SESSION = 2; + const unsigned long DEFER_SESSION = 3; + + readonly attribute unsigned long sessionType; + readonly attribute bool previousSessionCrashed; +}; diff --git a/application/basilisk/components/sessionstore/nsISessionStore.idl b/application/basilisk/components/sessionstore/nsISessionStore.idl new file mode 100644 index 000000000..0d2500ef7 --- /dev/null +++ b/application/basilisk/components/sessionstore/nsISessionStore.idl @@ -0,0 +1,220 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIDOMWindow; +interface nsIDOMNode; + +/** + * nsISessionStore keeps track of the current browsing state - i.e. + * tab history, cookies, scroll state, form data, and window features + * - and allows to restore everything into one browser window. + * + * The nsISessionStore API operates mostly on browser windows and the tabbrowser + * tabs contained in them: + * + * * "Browser windows" are those DOM windows having loaded + * chrome://browser/content/browser.xul . From overlays you can just pass the + * global |window| object to the API, though (or |top| from a sidebar). + * From elsewhere you can get browser windows through the nsIWindowMediator + * by looking for "navigator:browser" windows. + * + * * "Tabbrowser tabs" are all the child nodes of a browser window's + * |gBrowser.tabContainer| such as e.g. |gBrowser.selectedTab|. + */ + +[scriptable, uuid(4580f5eb-693d-423d-b0ce-2cb20a962e4d)] +interface nsISessionStore : nsISupports +{ + /** + * Is it possible to restore the previous session. Will always be false when + * in Private Browsing mode. + */ + attribute boolean canRestoreLastSession; + + /** + * Restore the previous session if possible. This will not overwrite the + * current session. Instead the previous session will be merged into the + * current session. Current windows will be reused if they were windows that + * pinned tabs were previously restored into. New windows will be opened as + * needed. + * + * Note: This will throw if there is no previous state to restore. Check with + * canRestoreLastSession first to avoid thrown errors. + */ + void restoreLastSession(); + + /** + * Get the current browsing state. + * @returns a JSON string representing the session state. + */ + AString getBrowserState(); + + /** + * Set the browsing state. + * This will immediately restore the state of the whole application to the state + * passed in, *replacing* the current session. + * + * @param aState is a JSON string representing the session state. + */ + void setBrowserState(in AString aState); + + /** + * @param aWindow is the browser window whose state is to be returned. + * + * @returns a JSON string representing a session state with only one window. + */ + AString getWindowState(in nsIDOMWindow aWindow); + + /** + * @param aWindow is the browser window whose state is to be set. + * @param aState is a JSON string representing a session state. + * @param aOverwrite boolean overwrite existing tabs + */ + void setWindowState(in nsIDOMWindow aWindow, in AString aState, in boolean aOverwrite); + + /** + * @param aTab is the tabbrowser tab whose state is to be returned. + * + * @returns a JSON string representing the state of the tab + * (note: doesn't contain cookies - if you need them, use getWindowState instead). + */ + AString getTabState(in nsIDOMNode aTab); + + /** + * @param aTab is the tabbrowser tab whose state is to be set. + * @param aState is a JSON string representing a session state. + */ + void setTabState(in nsIDOMNode aTab, in AString aState); + + /** + * Duplicates a given tab as thoroughly as possible. + * + * @param aWindow is the browser window into which the tab will be duplicated. + * @param aTab is the tabbrowser tab to duplicate (can be from a different window). + * @param aDelta is the offset to the history entry to load in the duplicated tab. + * @returns a reference to the newly created tab. + */ + nsIDOMNode duplicateTab(in nsIDOMWindow aWindow, in nsIDOMNode aTab, + [optional] in long aDelta); + + /** + * Get the number of restore-able tabs for a browser window + */ + unsigned long getClosedTabCount(in nsIDOMWindow aWindow); + + /** + * Get closed tab data + * + * @param aWindow is the browser window for which to get closed tab data + * @returns a JSON string representing the list of closed tabs. + */ + AString getClosedTabData(in nsIDOMWindow aWindow); + + /** + * @param aWindow is the browser window to reopen a closed tab in. + * @param aIndex is the index of the tab to be restored (FIFO ordered). + * @returns a reference to the reopened tab. + */ + nsIDOMNode undoCloseTab(in nsIDOMWindow aWindow, in unsigned long aIndex); + + /** + * @param aWindow is the browser window associated with the closed tab. + * @param aIndex is the index of the closed tab to be removed (FIFO ordered). + */ + nsIDOMNode forgetClosedTab(in nsIDOMWindow aWindow, in unsigned long aIndex); + + /** + * Get the number of restore-able windows + */ + unsigned long getClosedWindowCount(); + + /** + * Get closed windows data + * + * @returns a JSON string representing the list of closed windows. + */ + AString getClosedWindowData(); + + /** + * @param aIndex is the index of the windows to be restored (FIFO ordered). + * @returns the nsIDOMWindow object of the reopened window + */ + nsIDOMWindow undoCloseWindow(in unsigned long aIndex); + + /** + * @param aIndex is the index of the closed window to be removed (FIFO ordered). + * + * @throws NS_ERROR_INVALID_ARG + * when aIndex does not map to a closed window + */ + nsIDOMNode forgetClosedWindow(in unsigned long aIndex); + + /** + * @param aWindow is the window to get the value for. + * @param aKey is the value's name. + * + * @returns A string value or an empty string if none is set. + */ + AString getWindowValue(in nsIDOMWindow aWindow, in AString aKey); + + /** + * @param aWindow is the browser window to set the value for. + * @param aKey is the value's name. + * @param aStringValue is the value itself (use JSON.stringify/parse before setting JS objects). + */ + void setWindowValue(in nsIDOMWindow aWindow, in AString aKey, in jsval aStringValue); + + /** + * @param aWindow is the browser window to get the value for. + * @param aKey is the value's name. + */ + void deleteWindowValue(in nsIDOMWindow aWindow, in AString aKey); + + /** + * @param aTab is the tabbrowser tab to get the value for. + * @param aKey is the value's name. + * + * @returns A string value or an empty string if none is set. + */ + AString getTabValue(in nsIDOMNode aTab, in AString aKey); + + /** + * @param aTab is the tabbrowser tab to set the value for. + * @param aKey is the value's name. + * @param aStringValue is the value itself (use JSON.stringify/parse before setting JS objects). + */ + void setTabValue(in nsIDOMNode aTab, in AString aKey, in jsval aStringValue); + + /** + * @param aTab is the tabbrowser tab to get the value for. + * @param aKey is the value's name. + */ + void deleteTabValue(in nsIDOMNode aTab, in AString aKey); + + /** + * @param aKey is the value's name. + * + * @returns A string value or an empty string if none is set. + */ + AString getGlobalValue(in AString aKey); + + /** + * @param aKey is the value's name. + * @param aStringValue is the value itself (use JSON.stringify/parse before setting JS objects). + */ + void setGlobalValue(in AString aKey, in jsval aStringValue); + + /** + * @param aTab is the browser tab to get the value for. + * @param aKey is the value's name. + */ + void deleteGlobalValue(in AString aKey); + + /** + * @param aName is the name of the attribute to save/restore for all tabbrowser tabs. + */ + void persistTabAttribute(in AString aName); +}; diff --git a/application/basilisk/components/sessionstore/nsSessionStartup.js b/application/basilisk/components/sessionstore/nsSessionStartup.js new file mode 100644 index 000000000..7593c48ec --- /dev/null +++ b/application/basilisk/components/sessionstore/nsSessionStartup.js @@ -0,0 +1,353 @@ +/* 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"; + +/** + * Session Storage and Restoration + * + * Overview + * This service reads user's session file at startup, and makes a determination + * as to whether the session should be restored. It will restore the session + * under the circumstances described below. If the auto-start Private Browsing + * mode is active, however, the session is never restored. + * + * Crash Detection + * The CrashMonitor is used to check if the final session state was successfully + * written at shutdown of the last session. If we did not reach + * 'sessionstore-final-state-write-complete', then it's assumed that the browser + * has previously crashed and we should restore the session. + * + * Forced Restarts + * In the event that a restart is required due to application update or extension + * installation, set the browser.sessionstore.resume_session_once pref to true, + * and the session will be restored the next time the browser starts. + * + * Always Resume + * This service will always resume the session if the integer pref + * browser.startup.page is set to 3. + */ + +/* :::::::: Constants and Helpers ::::::::::::::: */ + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/TelemetryStopwatch.jsm"); +Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionFile", + "resource:///modules/sessionstore/SessionFile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "StartupPerformance", + "resource:///modules/sessionstore/StartupPerformance.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "CrashMonitor", + "resource://gre/modules/CrashMonitor.jsm"); + +const STATE_RUNNING_STR = "running"; + +// 'browser.startup.page' preference value to resume the previous session. +const BROWSER_STARTUP_RESUME_SESSION = 3; + +function debug(aMsg) { + aMsg = ("SessionStartup: " + aMsg).replace(/\S{80}/g, "$&\n"); + Services.console.logStringMessage(aMsg); +} +function warning(aMsg, aException) { + let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance(Ci.nsIScriptError); +consoleMsg.init(aMsg, aException.fileName, null, aException.lineNumber, 0, Ci.nsIScriptError.warningFlag, "component javascript"); + Services.console.logMessage(consoleMsg); +} + +var gOnceInitializedDeferred = (function () { + let deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + return deferred; +})(); + +/* :::::::: The Service ::::::::::::::: */ + +function SessionStartup() { +} + +SessionStartup.prototype = { + + // the state to restore at startup + _initialState: null, + _sessionType: Ci.nsISessionStartup.NO_SESSION, + _initialized: false, + + // Stores whether the previous session crashed. + _previousSessionCrashed: null, + +/* ........ Global Event Handlers .............. */ + + /** + * Initialize the component + */ + init: function sss_init() { + Services.obs.notifyObservers(null, "sessionstore-init-started", null); + StartupPerformance.init(); + + // do not need to initialize anything in auto-started private browsing sessions + if (PrivateBrowsingUtils.permanentPrivateBrowsing) { + this._initialized = true; + gOnceInitializedDeferred.resolve(); + return; + } + + SessionFile.read().then( + this._onSessionFileRead.bind(this), + console.error + ); + }, + + // Wrap a string as a nsISupports + _createSupportsString: function ssfi_createSupportsString(aData) { + let string = Cc["@mozilla.org/supports-string;1"] + .createInstance(Ci.nsISupportsString); + string.data = aData; + return string; + }, + + /** + * Complete initialization once the Session File has been read + * + * @param source The Session State string read from disk. + * @param parsed The object obtained by parsing |source| as JSON. + */ + _onSessionFileRead: function ({source, parsed, noFilesFound}) { + this._initialized = true; + + // Let observers modify the state before it is used + let supportsStateString = this._createSupportsString(source); + Services.obs.notifyObservers(supportsStateString, "sessionstore-state-read", ""); + let stateString = supportsStateString.data; + + if (stateString != source) { + // The session has been modified by an add-on, reparse. + try { + this._initialState = JSON.parse(stateString); + } catch (ex) { + // That's not very good, an add-on has rewritten the initial + // state to something that won't parse. + warning("Observer rewrote the state to something that won't parse", ex); + } + } else { + // No need to reparse + this._initialState = parsed; + } + + if (this._initialState == null) { + // No valid session found. + this._sessionType = Ci.nsISessionStartup.NO_SESSION; + Services.obs.notifyObservers(null, "sessionstore-state-finalized", ""); + gOnceInitializedDeferred.resolve(); + return; + } + + let shouldResumeSessionOnce = Services.prefs.getBoolPref("browser.sessionstore.resume_session_once"); + let shouldResumeSession = shouldResumeSessionOnce || + Services.prefs.getIntPref("browser.startup.page") == BROWSER_STARTUP_RESUME_SESSION; + + // If this is a normal restore then throw away any previous session + if (!shouldResumeSessionOnce && this._initialState) { + delete this._initialState.lastSessionState; + } + + let resumeFromCrash = Services.prefs.getBoolPref("browser.sessionstore.resume_from_crash"); + + CrashMonitor.previousCheckpoints.then(checkpoints => { + if (checkpoints) { + // If the previous session finished writing the final state, we'll + // assume there was no crash. + this._previousSessionCrashed = !checkpoints["sessionstore-final-state-write-complete"]; + + } else { + // If the Crash Monitor could not load a checkpoints file it will + // provide null. This could occur on the first run after updating to + // a version including the Crash Monitor, or if the checkpoints file + // was removed, or on first startup with this profile, or after Firefox Reset. + + if (noFilesFound) { + // There was no checkpoints file and no sessionstore.js or its backups + // so we will assume that this was a fresh profile. + this._previousSessionCrashed = false; + + } else { + // If this is the first run after an update, sessionstore.js should + // still contain the session.state flag to indicate if the session + // crashed. If it is not present, we will assume this was not the first + // run after update and the checkpoints file was somehow corrupted or + // removed by a crash. + // + // If the session.state flag is present, we will fallback to using it + // for crash detection - If the last write of sessionstore.js had it + // set to "running", we crashed. + let stateFlagPresent = (this._initialState.session && + this._initialState.session.state); + + + this._previousSessionCrashed = !stateFlagPresent || + (this._initialState.session.state == STATE_RUNNING_STR); + } + } + + // Report shutdown success via telemetry. Shortcoming here are + // being-killed-by-OS-shutdown-logic, shutdown freezing after + // session restore was written, etc. + Services.telemetry.getHistogramById("SHUTDOWN_OK").add(!this._previousSessionCrashed); + + // set the startup type + if (this._previousSessionCrashed && resumeFromCrash) + this._sessionType = Ci.nsISessionStartup.RECOVER_SESSION; + else if (!this._previousSessionCrashed && shouldResumeSession) + this._sessionType = Ci.nsISessionStartup.RESUME_SESSION; + else if (this._initialState) + this._sessionType = Ci.nsISessionStartup.DEFER_SESSION; + else + this._initialState = null; // reset the state + + Services.obs.addObserver(this, "sessionstore-windows-restored", true); + + if (this._sessionType != Ci.nsISessionStartup.NO_SESSION) + Services.obs.addObserver(this, "browser:purge-session-history", true); + + // We're ready. Notify everyone else. + Services.obs.notifyObservers(null, "sessionstore-state-finalized", ""); + gOnceInitializedDeferred.resolve(); + }); + }, + + /** + * Handle notifications + */ + observe: function sss_observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "app-startup": + Services.obs.addObserver(this, "final-ui-startup", true); + Services.obs.addObserver(this, "quit-application", true); + break; + case "final-ui-startup": + Services.obs.removeObserver(this, "final-ui-startup"); + Services.obs.removeObserver(this, "quit-application"); + this.init(); + break; + case "quit-application": + // no reason for initializing at this point (cf. bug 409115) + Services.obs.removeObserver(this, "final-ui-startup"); + Services.obs.removeObserver(this, "quit-application"); + if (this._sessionType != Ci.nsISessionStartup.NO_SESSION) + Services.obs.removeObserver(this, "browser:purge-session-history"); + break; + case "sessionstore-windows-restored": + Services.obs.removeObserver(this, "sessionstore-windows-restored"); + // free _initialState after nsSessionStore is done with it + this._initialState = null; + break; + case "browser:purge-session-history": + Services.obs.removeObserver(this, "browser:purge-session-history"); + // reset all state on sanitization + this._sessionType = Ci.nsISessionStartup.NO_SESSION; + break; + } + }, + +/* ........ Public API ................*/ + + get onceInitialized() { + return gOnceInitializedDeferred.promise; + }, + + /** + * Get the session state as a jsval + */ + get state() { + return this._initialState; + }, + + /** + * Determines whether there is a pending session restore. Should only be + * called after initialization has completed. + * @returns bool + */ + doRestore: function sss_doRestore() { + return this._willRestore(); + }, + + /** + * Determines whether automatic session restoration is enabled for this + * launch of the browser. This does not include crash restoration. In + * particular, if session restore is configured to restore only in case of + * crash, this method returns false. + * @returns bool + */ + isAutomaticRestoreEnabled: function () { + return Services.prefs.getBoolPref("browser.sessionstore.resume_session_once") || + Services.prefs.getIntPref("browser.startup.page") == BROWSER_STARTUP_RESUME_SESSION; + }, + + /** + * Determines whether there is a pending session restore. + * @returns bool + */ + _willRestore: function () { + return this._sessionType == Ci.nsISessionStartup.RECOVER_SESSION || + this._sessionType == Ci.nsISessionStartup.RESUME_SESSION; + }, + + /** + * Returns whether we will restore a session that ends up replacing the + * homepage. The browser uses this to not start loading the homepage if + * we're going to stop its load anyway shortly after. + * + * This is meant to be an optimization for the average case that loading the + * session file finishes before we may want to start loading the default + * homepage. Should this be called before the session file has been read it + * will just return false. + * + * @returns bool + */ + get willOverrideHomepage() { + if (this._initialState && this._willRestore()) { + let windows = this._initialState.windows || null; + // If there are valid windows with not only pinned tabs, signal that we + // will override the default homepage by restoring a session. + return windows && windows.some(w => w.tabs.some(t => !t.pinned)); + } + return false; + }, + + /** + * Get the type of pending session store, if any. + */ + get sessionType() { + return this._sessionType; + }, + + /** + * Get whether the previous session crashed. + */ + get previousSessionCrashed() { + return this._previousSessionCrashed; + }, + + /* ........ QueryInterface .............. */ + QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver, + Ci.nsISupportsWeakReference, + Ci.nsISessionStartup]), + classID: Components.ID("{ec7a6c20-e081-11da-8ad9-0800200c9a66}") +}; + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([SessionStartup]); diff --git a/application/basilisk/components/sessionstore/nsSessionStore.js b/application/basilisk/components/sessionstore/nsSessionStore.js new file mode 100644 index 000000000..8d96178ce --- /dev/null +++ b/application/basilisk/components/sessionstore/nsSessionStore.js @@ -0,0 +1,39 @@ +/* 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"; + +/** + * Session Storage and Restoration + * + * Overview + * This service keeps track of a user's session, storing the various bits + * required to return the browser to its current state. The relevant data is + * stored in memory, and is periodically saved to disk in a file in the + * profile directory. The service is started at first window load, in + * delayedStartup, and will restore the session from the data received from + * the nsSessionStartup service. + */ + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource:///modules/sessionstore/SessionStore.jsm"); + +function SessionStoreService() {} + +// The SessionStore module's object is frozen. We need to modify our prototype +// and add some properties so let's just copy the SessionStore object. +Object.keys(SessionStore).forEach(function (aName) { + let desc = Object.getOwnPropertyDescriptor(SessionStore, aName); + Object.defineProperty(SessionStoreService.prototype, aName, desc); +}); + +SessionStoreService.prototype.classID = + Components.ID("{5280606b-2510-4fe0-97ef-9b5a22eafe6b}"); +SessionStoreService.prototype.QueryInterface = + XPCOMUtils.generateQI([Ci.nsISessionStore]); + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([SessionStoreService]); diff --git a/application/basilisk/components/sessionstore/nsSessionStore.manifest b/application/basilisk/components/sessionstore/nsSessionStore.manifest new file mode 100644 index 000000000..9b5819c6a --- /dev/null +++ b/application/basilisk/components/sessionstore/nsSessionStore.manifest @@ -0,0 +1,15 @@ +# This component must restrict its registration for the app-startup category +# to the specific list of apps that use it so it doesn't get loaded in xpcshell. +# Thus we restrict it to these apps: +# +# b2g: {3c2e2abc-06d4-11e1-ac3b-374f68613e61} +# browser: {ec8030f7-c20a-464f-9b0e-13a3a9e97384} +# mobile/android: {aa3c5121-dab2-40e2-81ca-7ea25febc110} +# mobile/xul: {a23983c0-fd0e-11dc-95ff-0800200c9a66} +# graphene: {d1bfe7d9-c01e-4237-998b-7b5f960a4314} + +component {5280606b-2510-4fe0-97ef-9b5a22eafe6b} nsSessionStore.js +contract @mozilla.org/browser/sessionstore;1 {5280606b-2510-4fe0-97ef-9b5a22eafe6b} +component {ec7a6c20-e081-11da-8ad9-0800200c9a66} nsSessionStartup.js +contract @mozilla.org/browser/sessionstartup;1 {ec7a6c20-e081-11da-8ad9-0800200c9a66} +category app-startup nsSessionStartup service,@mozilla.org/browser/sessionstartup;1 application={3c2e2abc-06d4-11e1-ac3b-374f68613e61} application={ec8030f7-c20a-464f-9b0e-13a3a9e97384} application={aa3c5121-dab2-40e2-81ca-7ea25febc110} application={a23983c0-fd0e-11dc-95ff-0800200c9a66} application={d1bfe7d9-c01e-4237-998b-7b5f960a4314} |