diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index 1a6112e94cf..0c63deb2e29 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -860,6 +860,8 @@ pref("browser.sessionstore.restore_pinned_tabs_on_demand", false); pref("browser.sessionstore.upgradeBackup.latestBuildID", ""); // End-users should not run sessionstore in debug mode pref("browser.sessionstore.debug", false); +// Enable asynchronous data collection by default. +pref("browser.sessionstore.async", true); // allow META refresh by default pref("accessibility.blockautorefresh", false); diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js index 8c0560ab633..d6bf325c342 100644 --- a/browser/base/content/browser.js +++ b/browser/base/content/browser.js @@ -1435,7 +1435,6 @@ var gBrowserInit = { } // Final window teardown, do this last. - window.XULBrowserWindow.destroy(); window.XULBrowserWindow = null; window.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebNavigation) @@ -3690,14 +3689,6 @@ var XULBrowserWindow = { this.onSecurityChange(null, null, securityUI.state); }, - destroy: function () { - // XXXjag to avoid leaks :-/, see bug 60729 - delete this.throbberElement; - delete this.stopCommand; - delete this.reloadCommand; - delete this.statusText; - }, - setJSStatus: function () { // unsupported }, diff --git a/browser/components/sessionstore/content/content-sessionStore.js b/browser/components/sessionstore/content/content-sessionStore.js index f1db92e5745..cb77583be36 100644 --- a/browser/components/sessionstore/content/content-sessionStore.js +++ b/browser/components/sessionstore/content/content-sessionStore.js @@ -2,12 +2,19 @@ * 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); } Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); +XPCOMUtils.defineLazyModuleGetter(this, "SessionHistory", + "resource:///modules/sessionstore/SessionHistory.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionStorage", + "resource:///modules/sessionstore/SessionStorage.jsm"); + /** * Listens for and handles content events that we need for the * session store service to be notified of state changes in content. @@ -55,7 +62,37 @@ let EventListener = { } } }; -EventListener.init(); + +/** + * Listens for and handles messages sent by the session store service. + */ +let MessageListener = { + + MESSAGES: [ + "SessionStore:collectSessionHistory", + "SessionStore:collectSessionStorage" + ], + + init: function () { + this.MESSAGES.forEach(m => addMessageListener(m, this)); + }, + + receiveMessage: function ({name, data: {id}}) { + switch (name) { + case "SessionStore:collectSessionHistory": + let history = SessionHistory.read(docShell); + sendAsyncMessage(name, {id: id, data: history}); + break; + case "SessionStore:collectSessionStorage": + let storage = SessionStorage.serialize(docShell); + sendAsyncMessage(name, {id: id, data: storage}); + break; + default: + debug("received unknown message '" + name + "'"); + break; + } + } +}; let ProgressListener = { init: function() { @@ -74,4 +111,7 @@ let ProgressListener = { QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener, Ci.nsISupportsWeakReference]) }; + +EventListener.init(); +MessageListener.init(); ProgressListener.init(); diff --git a/browser/components/sessionstore/src/Messenger.jsm b/browser/components/sessionstore/src/Messenger.jsm new file mode 100644 index 00000000000..f0af898f882 --- /dev/null +++ b/browser/components/sessionstore/src/Messenger.jsm @@ -0,0 +1,71 @@ +/* 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 = ["Messenger"]; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/Promise.jsm", this); +Cu.import("resource://gre/modules/Timer.jsm", this); + +/** + * The external API exported by this module. + */ +this.Messenger = Object.freeze({ + send: function (tab, type, options = {}) { + return MessengerInternal.send(tab, type, options); + } +}); + +/** + * A module that handles communication between the main and content processes. + */ +let MessengerInternal = { + // The id of the last message we sent. This is used to assign a unique ID to + // every message we send to handle multiple responses from the same browser. + _latestMessageID: 0, + + /** + * Sends a message to the given tab and waits for a response. + * + * @param tab + * tabbrowser tab + * @param type + * {string} the type of the message + * @param options (optional) + * {timeout: int} to set the timeout in milliseconds + * @return {Promise} A promise that will resolve to the response message or + * be reject when timing out. + */ + send: function (tab, type, options = {}) { + let browser = tab.linkedBrowser; + let mm = browser.messageManager; + let deferred = Promise.defer(); + let id = ++this._latestMessageID; + let timeout; + + function onMessage({data: {id: mid, data}}) { + if (mid == id) { + mm.removeMessageListener(type, onMessage); + clearTimeout(timeout); + deferred.resolve(data); + } + } + + mm.addMessageListener(type, onMessage); + mm.sendAsyncMessage(type, {id: id}); + + function onTimeout() { + mm.removeMessageListener(type, onMessage); + deferred.reject(new Error("Timed out while waiting for a " + type + " " + + "response message.")); + } + + let delay = (options && options.timeout) || 5000; + timeout = setTimeout(onTimeout, delay); + return deferred.promise; + } +}; diff --git a/browser/components/sessionstore/src/PrivacyLevel.jsm b/browser/components/sessionstore/src/PrivacyLevel.jsm new file mode 100644 index 00000000000..1efd7595fdd --- /dev/null +++ b/browser/components/sessionstore/src/PrivacyLevel.jsm @@ -0,0 +1,82 @@ +/* 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_NORMAL = "browser.sessionstore.privacy_level"; +const PREF_DEFERRED = "browser.sessionstore.privacy_level_deferred"; + +// 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, cookies, and POSTDATA. +// +// 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; + +/** + * Returns whether we will resume the session automatically on next startup. + */ +function willResumeAutomatically() { + return Services.prefs.getIntPref("browser.startup.page") == 3 || + Services.prefs.getBoolPref("browser.sessionstore.resume_session_once"); +} + +/** + * Determines the current privacy level as set by the user. + * + * @param isPinned + * Whether to return the privacy level for pinned tabs. + * @return {int} The privacy level as read from the user's preferences. + */ +function getCurrentLevel(isPinned) { + let pref = PREF_NORMAL; + + // If we're in the process of quitting and we're not autoresuming the session + // then we will use the deferred privacy level for non-pinned tabs. + if (!isPinned && Services.startup.shuttingDown && !willResumeAutomatically()) { + pref = PREF_DEFERRED; + } + + return Services.prefs.getIntPref(pref); +} + +/** + * The external API as exposed by this module. + */ +let PrivacyLevel = Object.freeze({ + /** + * Checks whether we're allowed to save data for a specific site. + * + * @param {isHttps: boolean, isPinned: boolean} + * An object that must have two properties: 'isHttps' and 'isPinned'. + * 'isHttps' tells whether the site us secure communication (HTTPS). + * 'isPinned' tells whether the site is loaded in a pinned tab. + * @return {bool} Whether we can save data for the specified site. + */ + canSave: function ({isHttps, isPinned}) { + let level = getCurrentLevel(isPinned); + + // 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/browser/components/sessionstore/src/SessionCookies.jsm b/browser/components/sessionstore/src/SessionCookies.jsm index 33006dd7f81..037da33865d 100644 --- a/browser/components/sessionstore/src/SessionCookies.jsm +++ b/browser/components/sessionstore/src/SessionCookies.jsm @@ -12,8 +12,8 @@ const Ci = Components.interfaces; Cu.import("resource://gre/modules/Services.jsm", this); Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); -XPCOMUtils.defineLazyModuleGetter(this, "SessionStore", - "resource:///modules/sessionstore/SessionStore.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); @@ -67,8 +67,8 @@ let SessionCookiesInternal = { 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 checkPrivacyLevel(). - if (SessionStore.checkPrivacyLevel(cookie.secure, isPinned)) { + // to PrivacyLevel.canSave(). + if (PrivacyLevel.canSave({isHttps: cookie.secure, isPinned: isPinned})) { cookies.push(cookie); } } @@ -209,7 +209,7 @@ let SessionCookiesInternal = { // case testing scheme will be sufficient. if (/https?/.test(scheme) && !hosts[host] && (!checkPrivacy || - SessionStore.checkPrivacyLevel(scheme == "https", isPinned))) { + 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; diff --git a/browser/components/sessionstore/src/SessionHistory.jsm b/browser/components/sessionstore/src/SessionHistory.jsm new file mode 100644 index 00000000000..65e8a890d91 --- /dev/null +++ b/browser/components/sessionstore/src/SessionHistory.jsm @@ -0,0 +1,272 @@ +/* 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, "PrivacyLevel", + "resource:///modules/sessionstore/PrivacyLevel.jsm"); + +function debug(msg) { + Services.console.logStringMessage("SessionHistory: " + msg); +} + +// The preference value that determines how much post data to save. +XPCOMUtils.defineLazyGetter(this, "gPostData", function () { + const PREF = "browser.sessionstore.postdata"; + + // Observer that updates the cached value when the preference changes. + Services.prefs.addObserver(PREF, () => { + this.gPostData = Services.prefs.getIntPref(PREF); + }, false); + + return Services.prefs.getIntPref(PREF); +}); + +/** + * The external API exported by this module. + */ +this.SessionHistory = Object.freeze({ + read: function (docShell, includePrivateData) { + return SessionHistoryInternal.read(docShell, includePrivateData); + } +}); + +/** + * The internal API for the SessionHistory module. + */ +let SessionHistoryInternal = { + /** + * Collects session history data for a given docShell. + * + * @param docShell + * The docShell that owns the session history. + * @param includePrivateData (optional) + * True to always include private data and skip any privacy checks. + */ + read: function (docShell, includePrivateData = false) { + let data = {entries: []}; + let isPinned = docShell.isAppTab; + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + + if (history && history.count > 0) { + try { + for (let i = 0; i < history.count; i++) { + let shEntry = history.getEntryAtIndex(i, false); + let entry = this._serializeEntry(shEntry, includePrivateData, isPinned); + data.entries.push(entry); + } + } catch (ex) { + // In some cases, getEntryAtIndex will throw. This seems to be due to + // history.count being higher than it should be. By doing this in a + // try-catch, we'll update history to where it breaks, print an error + // message, and still save sessionstore.js. + debug("SessionStore failed gathering complete history " + + "for the focused window/tab. See bug 669196."); + } + data.index = history.index + 1; + } else { + let uri = webNavigation.currentURI.spec; + // 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" || webNavigation.document.body.hasChildNodes()) { + data.entries.push({ url: uri }); + data.index = 1; + } + } + + return data; + }, + + /** + * Get an object that is a serialized representation of a History entry. + * + * @param shEntry + * nsISHEntry instance + * @param includePrivateData + * Always return privacy sensitive data (use with care). + * @param isPinned + * The tab is pinned and should be treated differently for privacy. + * @return object + */ + _serializeEntry: function (shEntry, includePrivateData, isPinned) { + 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; + } + + 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; + + if (shEntry.srcdocData) + entry.srcdocData = shEntry.srcdocData; + + if (shEntry.isSrcdocEntry) + entry.isSrcdocEntry = shEntry.isSrcdocEntry; + + if (shEntry.contentType) + entry.contentType = shEntry.contentType; + + let x = {}, y = {}; + shEntry.getScrollPosition(x, y); + if (x.value != 0 || y.value != 0) + entry.scroll = x.value + "," + y.value; + + // Collect post data for the current history entry. + try { + let postdata = this._serializePostData(shEntry, isPinned); + if (postdata) { + entry.postdata_b64 = postdata; + } + } catch (ex) { + // POSTDATA is tricky - especially since some extensions don't get it right + debug("Failed serializing post data: " + ex); + } + + // Collect owner data for the current history entry. + try { + let owner = this._serializeOwner(shEntry); + if (owner) { + entry.owner_b64 = owner; + } + } catch (ex) { + // Not catching anything specific here, just possible errors + // from writeCompoundObject() and the like. + debug("Failed serializing owner data: " + ex); + } + + 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) { + 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, includePrivateData, isPinned)); + } + } + + if (children.length) { + entry.children = children; + } + } + + return entry; + }, + + /** + * Serialize post data contained in the given session history entry. + * + * @param shEntry + * The session history entry. + * @param isPinned + * Whether the docShell is owned by a pinned tab. + * @return The base64 encoded post data. + */ + _serializePostData: function (shEntry, isPinned) { + let isHttps = shEntry.URI.schemeIs("https"); + if (!shEntry.postData || !gPostData || + !PrivacyLevel.canSave({isHttps: isHttps, isPinned: isPinned})) { + return null; + } + + shEntry.postData.QueryInterface(Ci.nsISeekableStream) + .seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); + let stream = Cc["@mozilla.org/binaryinputstream;1"] + .createInstance(Ci.nsIBinaryInputStream); + stream.setInputStream(shEntry.postData); + let postBytes = stream.readByteArray(stream.available()); + let postdata = String.fromCharCode.apply(null, postBytes); + if (gPostData != -1 && + postdata.replace(/^(Content-.*\r\n)+(\r\n)*/, "").length > gPostData) { + return null; + } + + // We can stop doing base64 encoding once our serialization into JSON + // is guaranteed to handle all chars in strings, including embedded + // nulls. + return btoa(postdata); + }, + + /** + * Serialize owner data contained in the given session history entry. + * + * @param shEntry + * The session history entry. + * @return The base64 encoded owner data. + */ + _serializeOwner: function (shEntry) { + if (!shEntry.owner) { + return null; + } + + let binaryStream = Cc["@mozilla.org/binaryoutputstream;1"]. + createInstance(Ci.nsIObjectOutputStream); + let pipe = Cc["@mozilla.org/pipe;1"].createInstance(Ci.nsIPipe); + pipe.init(false, false, 0, 0xffffffff, null); + binaryStream.setOutputStream(pipe.outputStream); + binaryStream.writeCompoundObject(shEntry.owner, Ci.nsISupports, true); + binaryStream.close(); + + // Now we want to read the data from the pipe's input end and encode it. + let scriptableStream = Cc["@mozilla.org/binaryinputstream;1"]. + createInstance(Ci.nsIBinaryInputStream); + scriptableStream.setInputStream(pipe.inputStream); + let ownerBytes = + scriptableStream.readByteArray(scriptableStream.available()); + + // We can stop doing base64 encoding once our serialization into JSON + // is guaranteed to handle all chars in strings, including embedded + // nulls. + return btoa(String.fromCharCode.apply(null, ownerBytes)); + } +}; diff --git a/browser/components/sessionstore/src/SessionSaver.jsm b/browser/components/sessionstore/src/SessionSaver.jsm index b1977d9727c..c8682303e42 100644 --- a/browser/components/sessionstore/src/SessionSaver.jsm +++ b/browser/components/sessionstore/src/SessionSaver.jsm @@ -151,7 +151,7 @@ let SessionSaverInternal = { delay = Math.max(this._lastSaveTime + gInterval - Date.now(), delay, 0); // Schedule a state save. - this._timeoutID = setTimeout(() => this._saveState(), delay); + this._timeoutID = setTimeout(() => this._saveStateAsync(), delay); }, /** @@ -186,8 +186,7 @@ let SessionSaverInternal = { * update the corresponding caches. */ _saveState: function (forceUpdateAllWindows = false) { - // Cancel any pending timeouts or just clear - // the timeout if this is why we've been called. + // Cancel any pending timeouts. this.cancel(); stopWatchStart("COLLECT_DATA_MS", "COLLECT_DATA_LONGEST_OP_MS"); @@ -246,6 +245,33 @@ let SessionSaverInternal = { 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; + + // Check whether asynchronous data collection is disabled. + if (!Services.prefs.getBoolPref("browser.sessionstore.async")) { + this._saveState(); + return; + } + + // Update the last save time to make sure we wait at least another interval + // length until we call _saveStateAsync() again. + this.updateLastSaveTime(); + + // Save state synchronously after all tab caches have been filled. The data + // for the tab caches is collected asynchronously. We will reuse this + // cached data if the tab hasn't been invalidated in the meantime. In that + // case we will just fall back to synchronous data collection for single + // tabs. + SessionStore.fillTabCachesAsynchronously().then(() => this._saveState()); + }, + /** * Write the given state object to disk. */ diff --git a/browser/components/sessionstore/src/SessionStorage.jsm b/browser/components/sessionstore/src/SessionStorage.jsm index 5f72925bd09..119b4a1003f 100644 --- a/browser/components/sessionstore/src/SessionStorage.jsm +++ b/browser/components/sessionstore/src/SessionStorage.jsm @@ -7,12 +7,13 @@ 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, "SessionStore", - "resource:///modules/sessionstore/SessionStore.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyLevel", + "resource:///modules/sessionstore/PrivacyLevel.jsm"); this.SessionStorage = { /** @@ -51,16 +52,17 @@ let DomStorage = { read: function DomStorage_read(aDocShell, aFullData) { let data = {}; let isPinned = aDocShell.isAppTab; - let shistory = aDocShell.sessionHistory; + let webNavigation = aDocShell.QueryInterface(Ci.nsIWebNavigation); + let shistory = webNavigation.sessionHistory; - for (let i = 0; i < shistory.count; i++) { + for (let i = 0; shistory && i < shistory.count; i++) { let principal = History.getPrincipalForEntry(shistory, i, aDocShell); if (!principal) continue; // Check if we're allowed to store sessionStorage data. - let isHTTPS = principal.URI && principal.URI.schemeIs("https"); - if (aFullData || SessionStore.checkPrivacyLevel(isHTTPS, isPinned)) { + let isHttps = principal.URI && principal.URI.schemeIs("https"); + if (aFullData || PrivacyLevel.canSave({isHttps: isHttps, isPinned: isPinned})) { let origin = principal.jarPrefix + principal.origin; // Don't read a host twice. @@ -90,8 +92,8 @@ let DomStorage = { let storageManager = aDocShell.QueryInterface(Components.interfaces.nsIDOMStorageManager); // 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. + // 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(principal, "", aDocShell.usePrivateBrowsing); for (let [key, value] in Iterator(data)) { diff --git a/browser/components/sessionstore/src/SessionStore.jsm b/browser/components/sessionstore/src/SessionStore.jsm index c57952162a9..b98df60252f 100644 --- a/browser/components/sessionstore/src/SessionStore.jsm +++ b/browser/components/sessionstore/src/SessionStore.jsm @@ -21,10 +21,6 @@ const STATE_RUNNING_STR = "running"; const TAB_STATE_NEEDS_RESTORE = 1; const TAB_STATE_RESTORING = 2; -const PRIVACY_NONE = 0; -const PRIVACY_ENCRYPTED = 1; -const PRIVACY_FULL = 2; - const NOTIFY_WINDOWS_RESTORED = "sessionstore-windows-restored"; const NOTIFY_BROWSER_STATE_RESTORED = "sessionstore-browser-state-restored"; @@ -86,8 +82,6 @@ const MS_PER_DAY = 1000.0 * 60.0 * 60.0 * 24.0; Cu.import("resource://gre/modules/Services.jsm", this); Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); -// debug.js adds NS_ASSERT. cf. bug 669196 -Cu.import("resource://gre/modules/debug.js", this); Cu.import("resource://gre/modules/TelemetryTimestamps.jsm", this); Cu.import("resource://gre/modules/TelemetryStopwatch.jsm", this); Cu.import("resource://gre/modules/osfile.jsm", this); @@ -117,16 +111,31 @@ let gDocShellCapabilities = (function () { }; })(); +/** + * Get nsIURI from string + * @param string + * @returns nsIURI + */ +function makeURI(aString) { + return Services.io.newURI(aString, null, null); +} + XPCOMUtils.defineLazyModuleGetter(this, "ScratchpadManager", "resource:///modules/devtools/scratchpad-manager.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "DocumentUtils", "resource:///modules/sessionstore/DocumentUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Messenger", + "resource:///modules/sessionstore/Messenger.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyLevel", + "resource:///modules/sessionstore/PrivacyLevel.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "SessionSaver", "resource:///modules/sessionstore/SessionSaver.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "SessionStorage", "resource:///modules/sessionstore/SessionStorage.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "SessionCookies", "resource:///modules/sessionstore/SessionCookies.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionHistory", + "resource:///modules/sessionstore/SessionHistory.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "_SessionFile", "resource:///modules/sessionstore/_SessionFile.jsm"); @@ -264,14 +273,14 @@ this.SessionStore = { SessionStoreInternal.restoreLastSession(); }, - checkPrivacyLevel: function ss_checkPrivacyLevel(aIsHTTPS, aUseDefaultPref) { - return SessionStoreInternal.checkPrivacyLevel(aIsHTTPS, aUseDefaultPref); - }, - getCurrentState: function (aUpdateAll) { return SessionStoreInternal.getCurrentState(aUpdateAll); }, + fillTabCachesAsynchronously: function () { + return SessionStoreInternal.fillTabCachesAsynchronously(); + }, + /** * Backstage pass to implementation details, used for testing purpose. * Controlled by preference "browser.sessionstore.testmode". @@ -392,10 +401,6 @@ let SessionStoreInternal = { this._initPrefs(); this._initialized = true; - // this pref is only read at startup, so no need to observe it - this._sessionhistory_max_entries = - this._prefBranch.getIntPref("sessionhistory.max_entries"); - // Wait until nsISessionStartup has finished reading the session data. gSessionStartup.onceInitialized.then(() => { // Parse session data and start restoring. @@ -1044,7 +1049,6 @@ let SessionStoreInternal = { TabStateCache.delete(aTab); delete aTab.linkedBrowser.__SS_data; delete aTab.linkedBrowser.__SS_tabStillLoading; - delete aTab.linkedBrowser.__SS_formDataSaved; if (aTab.linkedBrowser.__SS_restoreState) this._resetTabRestoringState(aTab); }, this); @@ -1080,8 +1084,9 @@ let SessionStoreInternal = { // does a session history entry contain a url for the given domain? function containsDomain(aEntry) { try { - if (this._getURIFromString(aEntry.url).host.hasRootDomain(aData)) + if (makeURI(aEntry.url).host.hasRootDomain(aData)) { return true; + } } catch (ex) { /* url had no host at all */ } return aEntry.children && aEntry.children.some(containsDomain, this); @@ -1194,7 +1199,6 @@ let SessionStoreInternal = { delete browser.__SS_data; delete browser.__SS_tabStillLoading; - delete browser.__SS_formDataSaved; // 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 @@ -1231,7 +1235,7 @@ let SessionStoreInternal = { } // Get the latest data for this tab (generally, from the cache) - let tabState = this._collectTabData(aTab); + let tabState = TabState.collectSync(aTab); // store closed-tab data for undo if (this._shouldSaveTabState(tabState)) { @@ -1273,7 +1277,6 @@ let SessionStoreInternal = { delete aBrowser.__SS_data; delete aBrowser.__SS_tabStillLoading; - delete aBrowser.__SS_formDataSaved; this.saveStateDelayed(aWindow); // attempt to update the current URL we send in a crash report @@ -1288,11 +1291,7 @@ let SessionStoreInternal = { * Browser reference */ onTabInput: function ssi_onTabInput(aWindow, aBrowser) { - // deleting __SS_formDataSaved will cause us to recollect form data - delete aBrowser.__SS_formDataSaved; - TabStateCache.delete(aBrowser); - this.saveStateDelayed(aWindow); }, @@ -1427,7 +1426,7 @@ let SessionStoreInternal = { if (!aTab.ownerDocument || !aTab.ownerDocument.defaultView.__SSi) throw (Components.returnCode = Cr.NS_ERROR_INVALID_ARG); - let tabState = this._collectTabData(aTab); + let tabState = TabState.collectSync(aTab); return this._toJSONString(tabState); }, @@ -1472,7 +1471,7 @@ let SessionStoreInternal = { throw (Components.returnCode = Cr.NS_ERROR_INVALID_ARG); // Duplicate the tab state - let tabState = this._cloneFullTabData(aTab); + let tabState = TabState.clone(aTab); tabState.index += aDelta; tabState.index = Math.max(1, Math.min(tabState.index, tabState.entries.length)); @@ -1888,453 +1887,70 @@ let SessionStoreInternal = { return [true, canOverwriteTabs]; }, + /* ........ Async Data Collection .............. */ + + /** + * Kicks off asynchronous data collection for all tabs that do not have any + * cached data. The returned promise will only notify that the tab collection + * has been finished without resolving to any data. The tab collection for a + * a few or all tabs might have failed or timed out. By calling + * fillTabCachesAsynchronously() and waiting for the promise to be resolved + * before calling getCurrentState(), callers ensure that most of the data + * should have been collected asynchronously, without blocking the main + * thread. + * + * @return {Promise} the promise that is fulfilled when the tab data is ready + */ + fillTabCachesAsynchronously: function () { + let countdown = 0; + let deferred = Promise.defer(); + let activeWindow = this._getMostRecentBrowserWindow(); + + // The callback that will be called when a promise has been resolved + // successfully, i.e. the tab data has been collected. + function done() { + if (--countdown === 0) { + deferred.resolve(); + } + } + + // The callback that will be called when a promise is rejected, i.e. we + // we couldn't collect the tab data because of a script error or a timeout. + function fail(reason) { + debug("Failed collecting tab data asynchronously: " + reason); + done(); + } + + this._forEachBrowserWindow(win => { + if (!this._isWindowLoaded(win)) { + // Bail out if the window hasn't even loaded, yet. + return; + } + + if (!DirtyWindows.has(win) && win != activeWindow) { + // Bail out if the window is not dirty and inactive. + return; + } + + for (let tab of win.gBrowser.tabs) { + if (!TabStateCache.has(tab)) { + countdown++; + TabState.collect(tab).then(done, fail); + } + } + }); + + // If no dirty tabs were found, return a resolved + // promise because there is nothing to do here. + if (countdown == 0) { + return Promise.resolve(); + } + + return deferred.promise; + }, + /* ........ Saving Functionality .............. */ - /** - * Collect data related to a single tab - * - * @param aTab - * tabbrowser tab - * - * @returns {TabData} An object with the data for this tab. If the - * tab has not been invalidated since the last call to - * _collectTabData(aTab), the same object is returned. - */ - _collectTabData: function ssi_collectTabData(aTab) { - if (!aTab) { - throw new TypeError("Expecting a tab"); - } - let tabData; - if ((tabData = TabStateCache.get(aTab))) { - return tabData; - } - tabData = new TabData(this._collectBaseTabData(aTab)); - if (this._updateTextAndScrollDataForTab(aTab, tabData)) { - TabStateCache.set(aTab, tabData); - } - return tabData; - }, - - /** - * Collect data related to a single tab, including private data. - * Use with caution. - * - * @param aTab - * tabbrowser tab - * - * @returns {object} An object with the data for this tab. This object - * is recomputed at every call. - */ - _cloneFullTabData: function ssi_cloneFullTabData(aTab) { - let options = { includePrivateData: true }; - let tabData = this._collectBaseTabData(aTab, options); - this._updateTextAndScrollDataForTab(aTab, tabData, options); - return tabData; - }, - - _collectBaseTabData: function ssi_collectBaseTabData(aTab, aOptions = null) { - let includePrivateData = aOptions && aOptions.includePrivateData; - let tabData = {entries: [], lastAccessed: aTab.lastAccessed }; - let browser = aTab.linkedBrowser; - if (!browser || !browser.currentURI) { - // can happen when calling this function right after .addTab() - return tabData; - } - if (browser.__SS_data && browser.__SS_tabStillLoading) { - // use the data to be restored when the tab hasn't been completely loaded - tabData = browser.__SS_data; - if (aTab.pinned) - tabData.pinned = true; - else - delete tabData.pinned; - tabData.hidden = aTab.hidden; - - // If __SS_extdata is set then we'll use that since it might be newer. - if (aTab.__SS_extdata) - tabData.extData = aTab.__SS_extdata; - // If it exists but is empty then a key was likely deleted. In that case just - // delete extData. - if (tabData.extData && !Object.keys(tabData.extData).length) - delete tabData.extData; - return tabData; - } - - var history = null; - try { - history = browser.sessionHistory; - } - catch (ex) { } // this could happen if we catch a tab during (de)initialization - - // XXXzeniko anchor navigation doesn't reset __SS_data, so we could reuse - // data even when we shouldn't (e.g. Back, different anchor) - if (history && browser.__SS_data && - browser.__SS_data.entries[history.index] && - browser.__SS_data.entries[history.index].url == browser.currentURI.spec && - history.index < this._sessionhistory_max_entries - 1 && !includePrivateData) { - tabData = browser.__SS_data; - tabData.index = history.index + 1; - } - else if (history && history.count > 0) { - try { - for (var j = 0; j < history.count; j++) { - let entry = this._serializeHistoryEntry(history.getEntryAtIndex(j, false), - includePrivateData, aTab.pinned); - tabData.entries.push(entry); - } - // If we make it through the for loop, then we're ok and we should clear - // any indicator of brokenness. - delete aTab.__SS_broken_history; - } - catch (ex) { - // In some cases, getEntryAtIndex will throw. This seems to be due to - // history.count being higher than it should be. By doing this in a - // try-catch, we'll update history to where it breaks, assert for - // non-release builds, and still save sessionstore.js. We'll track if - // we've shown the assert for this tab so we only show it once. - // cf. bug 669196. - if (!aTab.__SS_broken_history) { - // First Focus the window & tab we're having trouble with. - aTab.ownerDocument.defaultView.focus(); - aTab.ownerDocument.defaultView.gBrowser.selectedTab = aTab; - NS_ASSERT(false, "SessionStore failed gathering complete history " + - "for the focused window/tab. See bug 669196."); - aTab.__SS_broken_history = true; - } - } - tabData.index = history.index + 1; - - // make sure not to cache privacy sensitive data which shouldn't get out - if (!includePrivateData) - browser.__SS_data = tabData; - } - else if (browser.currentURI.spec != "about:blank" || - browser.contentDocument.body.hasChildNodes()) { - tabData.entries[0] = { url: browser.currentURI.spec }; - tabData.index = 1; - } - - // 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. userTypedClear - // is used to indicate whether the tab was in some sort of loading state with - // userTypedValue. - if (browser.userTypedValue) { - tabData.userTypedValue = browser.userTypedValue; - tabData.userTypedClear = browser.userTypedClear; - } else { - delete tabData.userTypedValue; - delete tabData.userTypedClear; - } - - if (aTab.pinned) - tabData.pinned = true; - else - delete tabData.pinned; - tabData.hidden = aTab.hidden; - - var disallow = []; - for (let cap of gDocShellCapabilities(browser.docShell)) - if (!browser.docShell["allow" + cap]) - disallow.push(cap); - if (disallow.length > 0) - tabData.disallow = disallow.join(","); - else if (tabData.disallow) - delete tabData.disallow; - - // Save tab attributes. - tabData.attributes = TabAttributes.get(aTab); - - // Store the tab icon. - let tabbrowser = aTab.ownerDocument.defaultView.gBrowser; - tabData.image = tabbrowser.getIcon(aTab); - - if (aTab.__SS_extdata) - tabData.extData = aTab.__SS_extdata; - else if (tabData.extData) - delete tabData.extData; - - if (history && browser.docShell instanceof Ci.nsIDocShell) { - let storageData = SessionStorage.serialize(browser.docShell, includePrivateData) - if (Object.keys(storageData).length) - tabData.storage = storageData; - } - - return tabData; - }, - - /** - * Get an object that is a serialized representation of a History entry - * Used for data storage - * @param aEntry - * nsISHEntry instance - * @param aIncludePrivateData - * always return privacy sensitive data (use with care) - * @param aIsPinned - * the tab is pinned and should be treated differently for privacy - * @returns object - */ - _serializeHistoryEntry: - function ssi_serializeHistoryEntry(aEntry, aIncludePrivateData, aIsPinned) { - var entry = { url: aEntry.URI.spec }; - - if (aEntry.title && aEntry.title != entry.url) { - entry.title = aEntry.title; - } - if (aEntry.isSubFrame) { - entry.subframe = true; - } - if (!(aEntry instanceof Ci.nsISHEntry)) { - return entry; - } - - var cacheKey = aEntry.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 = aEntry.ID; - entry.docshellID = aEntry.docshellID; - - if (aEntry.referrerURI) - entry.referrer = aEntry.referrerURI.spec; - - if (aEntry.srcdocData) - entry.srcdocData = aEntry.srcdocData; - - if (aEntry.isSrcdocEntry) - entry.isSrcdocEntry = aEntry.isSrcdocEntry; - - if (aEntry.contentType) - entry.contentType = aEntry.contentType; - - var x = {}, y = {}; - aEntry.getScrollPosition(x, y); - if (x.value != 0 || y.value != 0) - entry.scroll = x.value + "," + y.value; - - try { - var prefPostdata = this._prefBranch.getIntPref("sessionstore.postdata"); - if (aEntry.postData && (aIncludePrivateData || prefPostdata && - this.checkPrivacyLevel(aEntry.URI.schemeIs("https"), aIsPinned))) { - aEntry.postData.QueryInterface(Ci.nsISeekableStream). - seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); - var stream = Cc["@mozilla.org/binaryinputstream;1"]. - createInstance(Ci.nsIBinaryInputStream); - stream.setInputStream(aEntry.postData); - var postBytes = stream.readByteArray(stream.available()); - var postdata = String.fromCharCode.apply(null, postBytes); - if (aIncludePrivateData || prefPostdata == -1 || - postdata.replace(/^(Content-.*\r\n)+(\r\n)*/, "").length <= - prefPostdata) { - // We can stop doing base64 encoding once our serialization into JSON - // is guaranteed to handle all chars in strings, including embedded - // nulls. - entry.postdata_b64 = btoa(postdata); - } - } - } - catch (ex) { debug(ex); } // POSTDATA is tricky - especially since some extensions don't get it right - - if (aEntry.owner) { - // Not catching anything specific here, just possible errors - // from writeCompoundObject and the like. - try { - var binaryStream = Cc["@mozilla.org/binaryoutputstream;1"]. - createInstance(Ci.nsIObjectOutputStream); - var pipe = Cc["@mozilla.org/pipe;1"].createInstance(Ci.nsIPipe); - pipe.init(false, false, 0, 0xffffffff, null); - binaryStream.setOutputStream(pipe.outputStream); - binaryStream.writeCompoundObject(aEntry.owner, Ci.nsISupports, true); - binaryStream.close(); - - // Now we want to read the data from the pipe's input end and encode it. - var scriptableStream = Cc["@mozilla.org/binaryinputstream;1"]. - createInstance(Ci.nsIBinaryInputStream); - scriptableStream.setInputStream(pipe.inputStream); - var ownerBytes = - scriptableStream.readByteArray(scriptableStream.available()); - // We can stop doing base64 encoding once our serialization into JSON - // is guaranteed to handle all chars in strings, including embedded - // nulls. - entry.owner_b64 = btoa(String.fromCharCode.apply(null, ownerBytes)); - } - catch (ex) { debug(ex); } - } - - entry.docIdentifier = aEntry.BFCacheEntry.ID; - - if (aEntry.stateData != null) { - entry.structuredCloneState = aEntry.stateData.getDataAsBase64(); - entry.structuredCloneVersion = aEntry.stateData.formatVersion; - } - - if (!(aEntry instanceof Ci.nsISHContainer)) { - return entry; - } - - if (aEntry.childCount > 0) { - let children = []; - for (var i = 0; i < aEntry.childCount; i++) { - var child = aEntry.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 = []; - break; - } - - children.push(this._serializeHistoryEntry(child, aIncludePrivateData, - aIsPinned)); - } - } - - if (children.length) - entry.children = children; - } - - return entry; - }, - - /** - * Go through all frames and store the current scroll positions - * and innerHTML content of WYSIWYG editors - * - * @param aTab - * tabbrowser tab - * @param aTabData - * tabData object to add the information to - * @param options - * An optional object that may contain the following field: - * - includePrivateData: always return privacy sensitive data - * (use with care) - * @return false if data should not be cached because the tab - * has not been fully initialized yet. - */ - _updateTextAndScrollDataForTab: - function ssi_updateTextAndScrollDataForTab(aTab, aTabData, aOptions = null) { - let includePrivateData = aOptions && aOptions.includePrivateData; - let window = aTab.ownerDocument.defaultView; - let browser = aTab.linkedBrowser; - // we shouldn't update data for incompletely initialized tabs - if (!browser.currentURI - || (browser.__SS_data && browser.__SS_tabStillLoading)) { - return false; - } - - let tabIndex = (aTabData.index || aTabData.entries.length) - 1; - // entry data needn't exist for tabs just initialized with an incomplete session state - if (!aTabData.entries[tabIndex]) { - return false; - } - - let selectedPageStyle = browser.markupDocumentViewer.authorStyleDisabled ? "_nostyle" : - this._getSelectedPageStyle(browser.contentWindow); - if (selectedPageStyle) - aTabData.pageStyle = selectedPageStyle; - else if (aTabData.pageStyle) - delete aTabData.pageStyle; - - this._updateTextAndScrollDataForFrame(window, browser.contentWindow, - aTabData.entries[tabIndex], - !browser.__SS_formDataSaved, includePrivateData, - !!aTabData.pinned); - browser.__SS_formDataSaved = true; - if (browser.currentURI.spec == "about:config") - aTabData.entries[tabIndex].formdata = { - id: { - "textbox": browser.contentDocument.getElementById("textbox").value - }, - xpath: {} - }; - return true; - }, - - /** - * go through all subframes and store all form data, the current - * scroll positions and innerHTML content of WYSIWYG editors - * @param aWindow - * Window reference - * @param aContent - * frame reference - * @param aData - * part of a tabData object to add the information to - * @param aUpdateFormData - * update all form data for this tab - * @param aIncludePrivateData - * always return privacy sensitive data (use with care) - * @param aIsPinned - * the tab is pinned and should be treated differently for privacy - */ - _updateTextAndScrollDataForFrame: - function ssi_updateTextAndScrollDataForFrame(aWindow, aContent, aData, - aUpdateFormData, aIncludePrivateData, aIsPinned) { - for (var i = 0; i < aContent.frames.length; i++) { - if (aData.children && aData.children[i]) - this._updateTextAndScrollDataForFrame(aWindow, aContent.frames[i], - aData.children[i], aUpdateFormData, - aIncludePrivateData, aIsPinned); - } - var isHTTPS = this._getURIFromString((aContent.parent || aContent). - document.location.href).schemeIs("https"); - let topURL = aContent.top.document.location.href; - let isAboutSR = topURL == "about:sessionrestore" || topURL == "about:welcomeback"; - if (aIncludePrivateData || this.checkPrivacyLevel(isHTTPS, aIsPinned) || isAboutSR) { - if (aIncludePrivateData || aUpdateFormData) { - let formData = DocumentUtils.getFormData(aContent.document); - - // We want to avoid saving data for about:sessionrestore as a string. - // Since it's stored in the form as stringified JSON, stringifying further - // causes an explosion of escape characters. cf. bug 467409 - if (formData && isAboutSR) { - formData.id["sessionData"] = JSON.parse(formData.id["sessionData"]); - } - - if (Object.keys(formData.id).length || - Object.keys(formData.xpath).length) { - aData.formdata = formData; - } else if (aData.formdata) { - delete aData.formdata; - } - } - - // designMode is undefined e.g. for XUL documents (as about:config) - if ((aContent.document.designMode || "") == "on" && aContent.document.body) - aData.innerHTML = aContent.document.body.innerHTML; - } - - // get scroll position from nsIDOMWindowUtils, since it allows avoiding a - // flush of layout - let domWindowUtils = aContent.QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsIDOMWindowUtils); - let scrollX = {}, scrollY = {}; - domWindowUtils.getScrollXY(false, scrollX, scrollY); - aData.scroll = scrollX.value + "," + scrollY.value; - }, - - /** - * determine the title of the currently enabled style sheet (if any) - * and recurse through the frameset if necessary - * @param aContent is a frame reference - * @returns the title style sheet determined to be enabled (empty string if none) - */ - _getSelectedPageStyle: function ssi_getSelectedPageStyle(aContent) { - const forScreen = /(?:^|,)\s*(?:all|screen)\s*(?:,|$)/i; - for (let i = 0; i < aContent.document.styleSheets.length; i++) { - let ss = aContent.document.styleSheets[i]; - let media = ss.media.mediaText; - if (!ss.disabled && ss.title && (!media || forScreen.test(media))) - return ss.title - } - for (let i = 0; i < aContent.frames.length; i++) { - let selectedPageStyle = this._getSelectedPageStyle(aContent.frames[i]); - if (selectedPageStyle) - return selectedPageStyle; - } - return ""; - }, - /** * Store window dimensions, visibility, sidebar * @param aWindow @@ -2501,7 +2117,7 @@ let SessionStoreInternal = { // update the internal state data for this window for (let tab of tabs) { - tabsData.push(this._collectTabData(tab)); + tabsData.push(TabState.collectSync(tab)); } winData.selected = tabbrowser.mTabBox.selectedIndex + 1; @@ -2917,8 +2533,9 @@ let SessionStoreInternal = { // Also 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. - if (uri) - browser.docShell.setCurrentURI(this._getURIFromString(uri)); + if (uri) { + browser.docShell.setCurrentURI(makeURI(uri)); + } // If the page has a title, set it. if (activePageData) { @@ -3092,7 +2709,7 @@ let SessionStoreInternal = { // Reset currentURI. This creates a new session history entry with a new // doc identifier, so we need to explicitly save and restore the old doc // identifier (corresponding to the SHEntry at activeIndex) below. - browser.webNavigation.setCurrentURI(this._getURIFromString("about:blank")); + browser.webNavigation.setCurrentURI(makeURI("about:blank")); // Attach data that will be restored on "load" event, after tab is restored. if (activeIndex > -1) { // restore those aspects of the currently active documents which are not @@ -3185,7 +2802,7 @@ let SessionStoreInternal = { var shEntry = Cc["@mozilla.org/browser/session-history-entry;1"]. createInstance(Ci.nsISHEntry); - shEntry.setURI(this._getURIFromString(aEntry.url)); + shEntry.setURI(makeURI(aEntry.url)); shEntry.setTitle(aEntry.title || aEntry.url); if (aEntry.subframe) shEntry.setIsSubFrame(aEntry.subframe || false); @@ -3193,7 +2810,7 @@ let SessionStoreInternal = { if (aEntry.contentType) shEntry.contentType = aEntry.contentType; if (aEntry.referrer) - shEntry.referrerURI = this._getURIFromString(aEntry.referrer); + shEntry.referrerURI = makeURI(aEntry.referrer); if (aEntry.isSrcdocEntry) shEntry.srcdocData = aEntry.srcdocData; @@ -3720,25 +3337,6 @@ let SessionStoreInternal = { return !hasFirstArgument; }, - /** - * don't save sensitive data if the user doesn't want to - * (distinguishes between encrypted and non-encrypted sites) - * @param aIsHTTPS - * Bool is encrypted - * @param aUseDefaultPref - * don't do normal check for deferred - * @returns bool - */ - checkPrivacyLevel: function ssi_checkPrivacyLevel(aIsHTTPS, aUseDefaultPref) { - let pref = "sessionstore.privacy_level"; - // If we're in the process of quitting and we're not autoresuming the session - // then we should treat it as a deferred session. We have a different privacy - // pref for that case. - if (!aUseDefaultPref && this._loadState == STATE_QUITTING && !this._doResumeSession()) - pref = "sessionstore.privacy_level_deferred"; - return this._prefBranch.getIntPref(pref) < (aIsHTTPS ? PRIVACY_ENCRYPTED : PRIVACY_FULL); - }, - /** * on popup windows, the XULWindow's attributes seem not to be set correctly * we use thus JSDOMWindow attributes for sizemode and normal window attributes @@ -4593,6 +4191,17 @@ function TabData(obj = null) { let TabStateCache = { _data: new WeakMap(), + /** + * Tells whether an entry is in the cache. + * + * @param {XULElement} aKey The tab or the associated browser. + * @return {bool} Whether there's a cached entry for the given tab. + */ + has: function (aTab) { + let key = this._normalizeToBrowser(aTab); + return this._data.has(key); + }, + /** * Add or replace an entry in the cache. * @@ -4689,6 +4298,402 @@ let TabStateCache = { } }; +/** + * Module that contains tab state collection methods. + */ +let TabState = { + // A map (xul:tab -> promise) that keeps track of tabs and + // their promises when collecting tab data asynchronously. + _pendingCollections: new WeakMap(), + + /** + * Collect data related to a single tab, asynchronously. + * + * @param tab + * tabbrowser tab + * + * @returns {Promise} A promise that will resolve to a TabData instance. + */ + collect: function (tab) { + if (!tab) { + throw new TypeError("Expecting a tab"); + } + + // Don't collect if we don't need to. + if (TabStateCache.has(tab)) { + return Promise.resolve(TabStateCache.get(tab)); + } + + let promise = Task.spawn(function task() { + // Collect basic tab data, without session history and storage. + let options = {omitSessionHistory: true, omitSessionStorage: true}; + let tabData = new TabData(this._collectBaseTabData(tab, options)); + + // Collected session history data asynchronously. + let history = yield Messenger.send(tab, "SessionStore:collectSessionHistory"); + tabData.entries = history.entries; + tabData.index = history.index; + + // Collected session storage data asynchronously. + let storage = yield Messenger.send(tab, "SessionStore:collectSessionStorage"); + if (Object.keys(storage).length) { + tabData.storage = storage; + } + + // Put tabData into cache when reading scroll and text data succeeds. + if (this._updateTextAndScrollDataForTab(tab, tabData)) { + // If we're still the latest async collection for the given tab and + // the cache hasn't been filled by collect() in the meantime, let's + // fill the cache with the data we received. + if (this._pendingCollections.get(tab) == promise) { + TabStateCache.set(tab, tabData); + this._pendingCollections.delete(tab); + } + } + + throw new Task.Result(tabData); + }.bind(this)); + + // Save the current promise as the latest asynchronous collection that is + // running. This will be used to check whether the collected data is still + // valid and will be used to fill the tab state cache. + this._pendingCollections.set(tab, promise); + + return promise; + }, + + /** + * 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 + * collectSync(aTab), the same object is returned. + */ + collectSync: function (tab) { + if (!tab) { + throw new TypeError("Expecting a tab"); + } + if (TabStateCache.has(tab)) { + return TabStateCache.get(tab); + } + + let tabData = new TabData(this._collectBaseTabData(tab)); + if (this._updateTextAndScrollDataForTab(tab, tabData)) { + TabStateCache.set(tab, tabData); + } + + // Prevent all running asynchronous collections from filling the cache. + // Every asynchronous data collection started before a collectSync() call + // can't expect to retrieve different data than the sync call. That's why + // we just fill the cache with the data collected from the sync call and + // discard any data collected asynchronously. + this._pendingCollections.delete(tab); + + return tabData; + }, + + /** + * 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) { + let options = { includePrivateData: true }; + let tabData = this._collectBaseTabData(tab, options); + this._updateTextAndScrollDataForTab(tab, tabData, options); + return tabData; + }, + + /** + * Collects basic tab data for a given tab. + * + * @param tab + * tabbrowser tab + * @param options + * An object that will be passed to session history and session + * storage data collection methods. + * {omitSessionHistory: true} to skip collecting session history data + * {omitSessionStorage: true} to skip collecting session storage data + * + * The omit* options have been introduced to enable us collecting + * those parts of the tab data asynchronously. We will request basic + * tabData without the parts to omit and fill those holes later when + * the content script has responded. + * + * @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 (!browser || !browser.currentURI) { + // can happen when calling this function right after .addTab() + return tabData; + } + if (browser.__SS_data && browser.__SS_tabStillLoading) { + // use the data to be restored when the tab hasn't been completely loaded + tabData = browser.__SS_data; + if (tab.pinned) + tabData.pinned = true; + else + delete tabData.pinned; + tabData.hidden = tab.hidden; + + // If __SS_extdata is set then we'll use that since it might be newer. + if (tab.__SS_extdata) + tabData.extData = tab.__SS_extdata; + // If it exists but is empty then a key was likely deleted. In that case just + // delete extData. + if (tabData.extData && !Object.keys(tabData.extData).length) + delete tabData.extData; + return tabData; + } + + // Collection session history data. + if (!options || !options.omitSessionHistory) { + this._collectTabHistory(tab, tabData, options); + } + + // 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. userTypedClear + // is used to indicate whether the tab was in some sort of loading state with + // userTypedValue. + if (browser.userTypedValue) { + tabData.userTypedValue = browser.userTypedValue; + tabData.userTypedClear = browser.userTypedClear; + } else { + delete tabData.userTypedValue; + delete tabData.userTypedClear; + } + + if (tab.pinned) + tabData.pinned = true; + else + delete tabData.pinned; + tabData.hidden = tab.hidden; + + let disallow = []; + for (let cap of gDocShellCapabilities(browser.docShell)) + if (!browser.docShell["allow" + cap]) + disallow.push(cap); + if (disallow.length > 0) + tabData.disallow = disallow.join(","); + else if (tabData.disallow) + delete tabData.disallow; + + // Save tab attributes. + tabData.attributes = TabAttributes.get(tab); + + // Store the tab icon. + let tabbrowser = tab.ownerDocument.defaultView.gBrowser; + tabData.image = tabbrowser.getIcon(tab); + + if (tab.__SS_extdata) + tabData.extData = tab.__SS_extdata; + else if (tabData.extData) + delete tabData.extData; + + // Collect DOMSessionStorage data. + if (!options || !options.omitSessionStorage) { + this._collectTabSessionStorage(tab, tabData, options); + } + + return tabData; + }, + + /** + * Collects session history data for a given tab. + * + * @param tab + * tabbrowser tab + * @param tabData + * An object that the session history data will be added to. + * @param options + * {includePrivateData: true} to always include private data + */ + _collectTabHistory: function (tab, tabData, options = {}) { + let includePrivateData = options && options.includePrivateData; + let docShell = tab.linkedBrowser.docShell; + + if (docShell instanceof Ci.nsIDocShell) { + let history = SessionHistory.read(docShell, includePrivateData); + tabData.entries = history.entries; + + // For blank tabs without any history entries, + // there will not be an 'index' property. + if ("index" in history) { + tabData.index = history.index; + } + } + }, + + /** + * Collects session history data for a given tab. + * + * @param tab + * tabbrowser tab + * @param tabData + * An object that the session storage data will be added to. + * @param options + * {includePrivateData: true} to always include private data + */ + _collectTabSessionStorage: function (tab, tabData, options = {}) { + let includePrivateData = options && options.includePrivateData; + let docShell = tab.linkedBrowser.docShell; + + if (docShell instanceof Ci.nsIDocShell) { + let storageData = SessionStorage.serialize(docShell, includePrivateData) + if (Object.keys(storageData).length) { + tabData.storage = storageData; + } + } + }, + + /** + * Go through all frames and store the current scroll positions + * and innerHTML content of WYSIWYG editors + * + * @param tab + * tabbrowser tab + * @param tabData + * tabData object to add the information to + * @param options + * An optional object that may contain the following field: + * - includePrivateData: always return privacy sensitive data + * (use with care) + * @return false if data should not be cached because the tab + * has not been fully initialized yet. + */ + _updateTextAndScrollDataForTab: function (tab, tabData, options = null) { + let includePrivateData = options && options.includePrivateData; + let window = tab.ownerDocument.defaultView; + let browser = tab.linkedBrowser; + // we shouldn't update data for incompletely initialized tabs + if (!browser.currentURI + || (browser.__SS_data && browser.__SS_tabStillLoading)) { + return false; + } + + let tabIndex = (tabData.index || tabData.entries.length) - 1; + // entry data needn't exist for tabs just initialized with an incomplete session state + if (!tabData.entries[tabIndex]) { + return false; + } + + let selectedPageStyle = browser.markupDocumentViewer.authorStyleDisabled ? "_nostyle" : + this._getSelectedPageStyle(browser.contentWindow); + if (selectedPageStyle) + tabData.pageStyle = selectedPageStyle; + else if (tabData.pageStyle) + delete tabData.pageStyle; + + this._updateTextAndScrollDataForFrame(window, browser.contentWindow, + tabData.entries[tabIndex], + includePrivateData, + !!tabData.pinned); + if (browser.currentURI.spec == "about:config") { + tabData.entries[tabIndex].formdata = { + id: { + "textbox": browser.contentDocument.getElementById("textbox").value + }, + xpath: {} + }; + } + + return true; + }, + + /** + * Go through all subframes and store all form data, the current + * scroll positions and innerHTML content of WYSIWYG editors. + * + * @param window + * Window reference + * @param content + * frame reference + * @param data + * part of a tabData object to add the information to + * @param includePrivateData + * always return privacy sensitive data (use with care) + * @param isPinned + * the tab is pinned and should be treated differently for privacy + */ + _updateTextAndScrollDataForFrame: + function (window, content, data, includePrivateData, isPinned) { + + for (let i = 0; i < content.frames.length; i++) { + if (data.children && data.children[i]) + this._updateTextAndScrollDataForFrame(window, content.frames[i], + data.children[i], + includePrivateData, isPinned); + } + let href = (content.parent || content).document.location.href; + let isHttps = makeURI(href).schemeIs("https"); + let topURL = content.top.document.location.href; + let isAboutSR = topURL == "about:sessionrestore" || topURL == "about:welcomeback"; + if (includePrivateData || isAboutSR || + PrivacyLevel.canSave({isHttps: isHttps, isPinned: isPinned})) { + let formData = DocumentUtils.getFormData(content.document); + + // We want to avoid saving data for about:sessionrestore as a string. + // Since it's stored in the form as stringified JSON, stringifying further + // causes an explosion of escape characters. cf. bug 467409 + if (formData && isAboutSR) { + formData.id["sessionData"] = JSON.parse(formData.id["sessionData"]); + } + + if (Object.keys(formData.id).length || + Object.keys(formData.xpath).length) { + data.formdata = formData; + } else if (data.formdata) { + delete data.formdata; + } + + // designMode is undefined e.g. for XUL documents (as about:config) + if ((content.document.designMode || "") == "on" && content.document.body) + data.innerHTML = content.document.body.innerHTML; + } + + // get scroll position from nsIDOMWindowUtils, since it allows avoiding a + // flush of layout + let domWindowUtils = content.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + let scrollX = {}, scrollY = {}; + domWindowUtils.getScrollXY(false, scrollX, scrollY); + data.scroll = scrollX.value + "," + scrollY.value; + }, + + /** + * determine the title of the currently enabled style sheet (if any) + * and recurse through the frameset if necessary + * @param content is a frame reference + * @returns the title style sheet determined to be enabled (empty string if none) + */ + _getSelectedPageStyle: function (content) { + const forScreen = /(?:^|,)\s*(?:all|screen)\s*(?:,|$)/i; + for (let i = 0; i < content.document.styleSheets.length; i++) { + let ss = content.document.styleSheets[i]; + let media = ss.media.mediaText; + if (!ss.disabled && ss.title && (!media || forScreen.test(media))) + return ss.title + } + for (let i = 0; i < content.frames.length; i++) { + let selectedPageStyle = this._getSelectedPageStyle(content.frames[i]); + if (selectedPageStyle) + return selectedPageStyle; + } + return ""; + } +}; + let TabStateCacheTelemetry = { // Total number of hits during the session _hits: 0, diff --git a/browser/components/sessionstore/src/_SessionFile.jsm b/browser/components/sessionstore/src/_SessionFile.jsm index 74e55af0da3..3405fc22d05 100644 --- a/browser/components/sessionstore/src/_SessionFile.jsm +++ b/browser/components/sessionstore/src/_SessionFile.jsm @@ -34,6 +34,7 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/osfile.jsm"); Cu.import("resource://gre/modules/osfile/_PromiseWorker.jsm", this); Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/AsyncShutdown.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch", "resource://gre/modules/TelemetryStopwatch.jsm"); @@ -148,6 +149,18 @@ let SessionFileInternal = { */ backupPath: OS.Path.join(OS.Constants.Path.profileDir, "sessionstore.bak"), + /** + * The promise returned by the latest call to |write|. + * We use it to ensure that AsyncShutdown.profileBeforeChange cannot + * interrupt a call to |write|. + */ + _latestWrite: null, + + /** + * |true| once we have decided to stop receiving write instructiosn + */ + _isClosed: false, + /** * Utility function to safely read a file synchronously. * @param aPath @@ -210,8 +223,11 @@ let SessionFileInternal = { }, write: function (aData) { + if (this._isClosed) { + return Promise.reject(new Error("_SessionFile is closed")); + } let refObj = {}; - return TaskUtils.spawn(function task() { + return this._latestWrite = TaskUtils.spawn(function task() { TelemetryStopwatch.start("FX_SESSION_RESTORE_WRITE_FILE_LONGEST_OP_MS", refObj); try { @@ -227,6 +243,15 @@ let SessionFileInternal = { Cu.reportError("Could not write session state file " + this.path + ": " + ex); } + // At this stage, we are done writing. If shutdown has started, + // we will want to stop receiving write instructions. + if (Services.startup.shuttingDown) { + this._isClosed = true; + } + // In rare cases, we may already have other writes pending, + // which we need to flush before shutdown proceeds. AsyncShutdown + // uses _latestWrite to determine what needs to be flushed during + // shutdown. }.bind(this)); }, @@ -277,3 +302,11 @@ let SessionWorker = (function () { } }; })(); + +// Ensure that we can write sessionstore.js cleanly before the profile +// becomes unaccessible. +AsyncShutdown.profileBeforeChange.addBlocker( + "SessionFile: Finish writing the latest sessionstore.js", + function() { + return _SessionFile._latestWrite; + }); diff --git a/browser/components/sessionstore/src/moz.build b/browser/components/sessionstore/src/moz.build index c99282262cd..4c5fe85ef08 100644 --- a/browser/components/sessionstore/src/moz.build +++ b/browser/components/sessionstore/src/moz.build @@ -14,7 +14,10 @@ JS_MODULES_PATH = 'modules/sessionstore' EXTRA_JS_MODULES = [ 'DocumentUtils.jsm', + 'Messenger.jsm', + 'PrivacyLevel.jsm', 'SessionCookies.jsm', + 'SessionHistory.jsm', 'SessionMigration.jsm', 'SessionStorage.jsm', 'SessionWorker.js', diff --git a/browser/components/sessionstore/test/Makefile.in b/browser/components/sessionstore/test/Makefile.in index cb32c8bc38f..6faaab8a6fb 100644 --- a/browser/components/sessionstore/test/Makefile.in +++ b/browser/components/sessionstore/test/Makefile.in @@ -20,7 +20,7 @@ MOCHITEST_BROWSER_FILES = \ browser_input_sample.html \ browser_pageshow.js \ browser_sessionStorage.js \ - browser_upgrade_backup.js \ + browser_upgrade_backup.js \ browser_windowRestore_perwindowpb.js \ browser_248970_b_perwindowpb.js \ browser_248970_b_sample.html \ @@ -135,6 +135,8 @@ MOCHITEST_BROWSER_FILES = \ browser_739805.js \ browser_819510_perwindowpb.js \ browser_833286_atomic_backup.js \ + browser_916390_form_data_loss.js \ + browser_916390_sample.html \ $(filter disabled-for-intermittent-failures--bug-766044, browser_459906_empty.html) \ $(filter disabled-for-intermittent-failures--bug-766044, browser_459906_sample.html) \ $(filter disabled-for-intermittent-failures--bug-765389, browser_461743_sample.html) \ diff --git a/browser/components/sessionstore/test/browser_624727.js b/browser/components/sessionstore/test/browser_624727.js index 9884058f23b..84ae092fa90 100644 --- a/browser/components/sessionstore/test/browser_624727.js +++ b/browser/components/sessionstore/test/browser_624727.js @@ -2,13 +2,15 @@ http://creativecommons.org/publicdomain/zero/1.0/ */ function test() { - waitForExplicitFinish(); + TestRunner.run(); +} - let assertNumberOfTabs = function (num, msg) { +function runTests() { + function assertNumberOfTabs(num, msg) { is(gBrowser.tabs.length, num, msg); } - let assertNumberOfPinnedTabs = function (num, msg) { + function assertNumberOfPinnedTabs(num, msg) { is(gBrowser._numPinnedTabs, num, msg); } @@ -27,13 +29,13 @@ function test() { assertNumberOfPinnedTabs(2, "both tabs are now pinned"); // run the test - waitForBrowserState( + yield waitForBrowserState( { windows: [{ tabs: [{ url: "about:blank" }] }] }, function () { assertNumberOfTabs(1, "one tab left after setBrowserState()"); assertNumberOfPinnedTabs(0, "there are no pinned tabs"); is(gBrowser.tabs[0].linkedBrowser, linkedBrowser, "first tab's browser got re-used"); - finish(); + next(); } ); } diff --git a/browser/components/sessionstore/test/browser_625257.js b/browser/components/sessionstore/test/browser_625257.js index 394fbd7cd38..91c4772fd0b 100644 --- a/browser/components/sessionstore/test/browser_625257.js +++ b/browser/components/sessionstore/test/browser_625257.js @@ -58,7 +58,6 @@ function test() { ss.getBrowserState(); is(gBrowser.tabs[1], tab, "newly created tab should exist by now"); - ok(tab.linkedBrowser.__SS_data, "newly created tab should be in save state"); // Start a load and interrupt it by closing the tab tab.linkedBrowser.loadURI(URI_TO_LOAD); diff --git a/browser/components/sessionstore/test/browser_916390_form_data_loss.js b/browser/components/sessionstore/test/browser_916390_form_data_loss.js new file mode 100644 index 00000000000..98cbdb7f05a --- /dev/null +++ b/browser/components/sessionstore/test/browser_916390_form_data_loss.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const URL = "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_916390_sample.html"; + +function test() { + TestRunner.run(); +} + +function runTests() { + // Create a tab with some form fields. + let tab = gBrowser.selectedTab = gBrowser.addTab(URL); + let browser = gBrowser.selectedBrowser; + yield waitForLoad(browser); + + // Modify the text input field's state. + browser.contentDocument.getElementById("txt").focus(); + EventUtils.synthesizeKey("m", {}); + yield waitForInput(); + + // Check that we'll save the form data state correctly. + let state = JSON.parse(ss.getBrowserState()); + let {formdata} = state.windows[0].tabs[1].entries[0]; + is(formdata.id.txt, "m", "txt's value is correct"); + + // Change the number of session history entries and modify + // DOMSessionStorage data to invalidate the TabStateCache. + browser.loadURI(URL + "#"); + browser.contentWindow.sessionStorage.foo = "bar"; + yield waitForStorageChange(); + + // Check that we'll save the form data state correctly. + let state = JSON.parse(ss.getBrowserState()); + let {formdata} = state.windows[0].tabs[1].entries[1]; + is(formdata.id.txt, "m", "txt's value is correct"); + + // Clean up. + gBrowser.removeTab(tab); +} + +function waitForLoad(aElement) { + aElement.addEventListener("load", function onLoad() { + aElement.removeEventListener("load", onLoad, true); + executeSoon(next); + }, true); +} + +function waitForInput() { + let mm = gBrowser.selectedBrowser.messageManager; + + mm.addMessageListener("SessionStore:input", function onInput() { + mm.removeMessageListener("SessionStore:input", onInput); + executeSoon(next); + }); +} + +function waitForStorageChange() { + let mm = gBrowser.selectedBrowser.messageManager; + + mm.addMessageListener("SessionStore:MozStorageChanged", function onChanged() { + mm.removeMessageListener("SessionStore:MozStorageChanged", onChanged); + executeSoon(next); + }); +} diff --git a/browser/components/sessionstore/test/browser_916390_sample.html b/browser/components/sessionstore/test/browser_916390_sample.html new file mode 100644 index 00000000000..1484488b243 --- /dev/null +++ b/browser/components/sessionstore/test/browser_916390_sample.html @@ -0,0 +1,10 @@ + + +
+ +