/* 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", }); /** * The external API exported by this module. */ export var SessionHistory = Object.freeze({ isEmpty(docShell) { return SessionHistoryInternal.isEmpty(docShell); }, collect(docShell, aFromIdx = -1) { if (Services.appinfo.sessionHistoryInParent) { throw new Error("Use SessionHistory.collectFromParent instead"); } return SessionHistoryInternal.collect(docShell, aFromIdx); }, collectFromParent(uri, documentHasChildNodes, history, aFromIdx = -1) { return SessionHistoryInternal.collectCommon( uri, documentHasChildNodes, history, aFromIdx ); }, collectNonWebControlledBlankLoadingSession(browsingContext) { return SessionHistoryInternal.collectNonWebControlledBlankLoadingSession( browsingContext ); }, restore(docShell, tabData) { if (Services.appinfo.sessionHistoryInParent) { throw new Error("Use SessionHistory.restoreFromParent instead"); } return SessionHistoryInternal.restore( docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory .legacySHistory, tabData ); }, restoreFromParent(history, tabData) { return SessionHistoryInternal.restore(history, tabData); }, }); /** * The internal API for the SessionHistory module. */ var SessionHistoryInternal = { /** * Mapping from legacy docshellIDs to docshellUUIDs. */ _docshellUUIDMap: new Map(), /** * Returns whether the given docShell's session history is empty. * * @param docShell * The docShell that owns the session history. */ isEmpty(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. * @param aFromIdx * The starting local index to collect the history from. * @return An object reprereseting a partial global history update. */ collect(docShell, aFromIdx = -1) { let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); let uri = webNavigation.currentURI.displaySpec; let body = webNavigation.document.body; let history = webNavigation.sessionHistory; return this.collectCommon( uri, body && body.hasChildNodes(), history.legacySHistory, aFromIdx ); }, collectCommon(uri, documentHasChildNodes, shistory, aFromIdx) { let data = { entries: [], requestedIndex: shistory.requestedIndex + 1, }; // We want to keep track how many entries we *could* have collected and // how many we skipped, so we can sanitiy-check the current history index // and also determine whether we need to get any fallback data or not. let skippedCount = 0, entryCount = 0; if (shistory && shistory.count > 0) { let count = shistory.count; for (; entryCount < count; entryCount++) { let shEntry = shistory.getEntryAtIndex(entryCount); if (entryCount <= aFromIdx) { skippedCount++; continue; } let entry = this.serializeEntry(shEntry); data.entries.push(entry); } // Ensure the index isn't out of bounds if an exception was thrown above. data.index = Math.min(shistory.index + 1, entryCount); } // 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, // unless of course we just skipped all entries because aFromIdx was big enough. if (!data.entries.length && (skippedCount != entryCount || aFromIdx < 0)) { // 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" || documentHasChildNodes) { data.entries.push({ url: uri, triggeringPrincipal_base64: lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL, }); data.index = 1; } } data.fromIdx = aFromIdx; return data; }, collectNonWebControlledBlankLoadingSession(browsingContext) { if ( browsingContext.sessionHistory?.count === 0 && browsingContext.nonWebControlledBlankURI && browsingContext.mostRecentLoadingSessionHistoryEntry ) { return { entries: [ this.serializeEntry( browsingContext.mostRecentLoadingSessionHistoryEntry ), ], // Set 1 to the index, as the array of session entries is 1-based. index: 1, fromIdx: -1, requestedIndex: browsingContext.sessionHistory.requestedIndex + 1, }; } return null; }, /** * Get an object that is a serialized representation of a History entry. * * @param shEntry * nsISHEntry instance * @return object */ serializeEntry(shEntry) { let entry = { url: shEntry.URI.displaySpec, title: shEntry.title }; if (shEntry.isSubFrame) { entry.subframe = true; } entry.cacheKey = shEntry.cacheKey; entry.ID = shEntry.ID; entry.docshellUUID = shEntry.docshellID.toString(); // 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.referrerInfo) { entry.referrerInfo = lazy.E10SUtils.serializeReferrerInfo( shEntry.referrerInfo ); } if (shEntry.originalURI) { entry.originalURI = shEntry.originalURI.spec; } if (shEntry.resultPrincipalURI) { entry.resultPrincipalURI = shEntry.resultPrincipalURI.spec; // For downgrade compatibility we store the loadReplace property as it // would be stored before result principal URI introduction so that // the old code can still create URL based principals for channels // correctly. When resultPrincipalURI is non-null and not equal to // channel's orignalURI in the new code, it's equal to setting // LOAD_REPLACE in the old code. Note that we only do 'the best we can' // here to derivate the 'old' loadReplace flag value. entry.loadReplace = entry.resultPrincipalURI != entry.originalURI; } else { // We want to store the property to let the backward compatibility code, // when reading the stored session, work. When this property is undefined // that code will derive the result principal URI from the load replace // flag. entry.resultPrincipalURI = null; } if (shEntry.loadReplace) { // Storing under a new property name, since it has changed its meaning // with the result principal URI introduction. entry.loadReplace2 = shEntry.loadReplace; } if (shEntry.isSrcdocEntry) { entry.srcdocData = shEntry.srcdocData; 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; } let layoutHistoryState = shEntry.layoutHistoryState; if (layoutHistoryState && layoutHistoryState.hasStates) { let presStates = layoutHistoryState .getKeys() .map(key => this._getSerializablePresState(layoutHistoryState, key)) .filter( presState => // Only keep presState entries that contain more than the key itself. Object.getOwnPropertyNames(presState).length > 1 ); if (presStates.length) { entry.presState = presStates; } } } // Collect triggeringPrincipal data for the current history entry. if (shEntry.principalToInherit) { entry.principalToInherit_base64 = lazy.E10SUtils.serializePrincipal( shEntry.principalToInherit ); } if (shEntry.partitionedPrincipalToInherit) { entry.partitionedPrincipalToInherit_base64 = lazy.E10SUtils.serializePrincipal( shEntry.partitionedPrincipalToInherit ); } entry.hasUserInteraction = shEntry.hasUserInteraction; if (shEntry.triggeringPrincipal) { entry.triggeringPrincipal_base64 = lazy.E10SUtils.serializePrincipal( shEntry.triggeringPrincipal ); } if (shEntry.csp) { entry.csp = lazy.E10SUtils.serializeCSP(shEntry.csp); } entry.docIdentifier = shEntry.bfcacheID; if (shEntry.stateData != null) { let stateData = shEntry.stateData; entry.structuredCloneState = stateData.getDataAsBase64(); entry.structuredCloneVersion = stateData.formatVersion; } if (shEntry.wireframe != null) { entry.wireframe = shEntry.wireframe; } if (shEntry.childCount > 0 && !shEntry.hasDynamicallyAddedChild()) { let children = []; for (let i = 0; i < shEntry.childCount; i++) { let child = shEntry.GetChildAt(i); if (child) { children.push(this.serializeEntry(child)); } } if (children.length) { entry.children = children; } } entry.persist = shEntry.persist; return entry; }, /** * Get an object that is a serializable representation of a PresState. * * @param layoutHistoryState * nsILayoutHistoryState instance * @param stateKey * The state key of the presState to be retrieved. * @return object */ _getSerializablePresState(layoutHistoryState, stateKey) { let presState = { stateKey }; let x = {}, y = {}, scrollOriginDowngrade = {}, res = {}; layoutHistoryState.getPresState(stateKey, x, y, scrollOriginDowngrade, res); if (x.value !== 0 || y.value !== 0) { presState.scroll = x.value + "," + y.value; } if (scrollOriginDowngrade.value === false) { presState.scrollOriginDowngrade = scrollOriginDowngrade.value; } if (res.value != 1.0) { presState.res = res.value; } return presState; }, /** * Restores session history data for a given docShell. * * @param history * The session history object. * @param tabData * The tabdata including all history entries. * @return A reference to the docShell's nsISHistory interface. */ restore(history, tabData) { if (history.count > 0) { history.purgeHistory(history.count); } 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; let shEntry = this.deserializeEntry(entry, idMap, docIdentMap, history); // To enable a smooth migration, we treat values of null/undefined as having // user interaction (because we don't want to hide all session history that was // added before we started recording user interaction). // // This attribute is only set on top-level SH history entries, so we set it // outside of deserializeEntry since that is called recursively. if (entry.hasUserInteraction == undefined) { shEntry.hasUserInteraction = true; } else { shEntry.hasUserInteraction = entry.hasUserInteraction; } history.addEntry(shEntry, persist); } // Select the right history entry. let index = tabData.index - 1; if (index < history.count && history.index != index) { history.index = index; } return history; }, /** * 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(entry, idMap, docIdentMap, shistory) { var shEntry = shistory.createEntry(); shEntry.URI = Services.io.newURI(entry.url); shEntry.title = entry.title || entry.url; if (entry.subframe) { shEntry.isSubFrame = entry.subframe || false; } shEntry.setLoadTypeAsHistory(); if (entry.contentType) { shEntry.contentType = entry.contentType; } // Referrer information is now stored as a referrerInfo property. We should // also cope with the old format of passing `referrer` and `referrerPolicy` // separately. if (entry.referrerInfo) { shEntry.referrerInfo = lazy.E10SUtils.deserializeReferrerInfo( entry.referrerInfo ); } else if (entry.referrer) { let ReferrerInfo = Components.Constructor( "@mozilla.org/referrer-info;1", "nsIReferrerInfo", "init" ); shEntry.referrerInfo = new ReferrerInfo( entry.referrerPolicy, true, Services.io.newURI(entry.referrer) ); } if (entry.originalURI) { shEntry.originalURI = Services.io.newURI(entry.originalURI); } if (typeof entry.resultPrincipalURI === "undefined" && entry.loadReplace) { // This is backward compatibility code for stored sessions saved prior to // introduction of the resultPrincipalURI property. The equivalent of this // property non-null value used to be the URL while the LOAD_REPLACE flag // was set. shEntry.resultPrincipalURI = shEntry.URI; } else if (entry.resultPrincipalURI) { shEntry.resultPrincipalURI = Services.io.newURI(entry.resultPrincipalURI); } if (entry.loadReplace2) { shEntry.loadReplace = entry.loadReplace2; } if (entry.isSrcdocEntry) { shEntry.srcdocData = entry.srcdocData; } if (entry.baseURI) { shEntry.baseURI = Services.io.newURI(entry.baseURI); } if (entry.cacheKey) { shEntry.cacheKey = entry.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) { // eslint-disable-next-line no-empty for (id = Date.now(); id in idMap.used; id++) {} idMap[entry.ID] = id; idMap.used[id] = true; } shEntry.ID = id; } // If we have the legacy docshellID on our entry, upgrade it to a // docshellUUID by going through the mapping. if (entry.docshellID) { if (!this._docshellUUIDMap.has(entry.docshellID)) { // Convert the nsID to a string so that the docshellUUID property // is correctly stored as a string. this._docshellUUIDMap.set( entry.docshellID, Services.uuid.generateUUID().toString() ); } entry.docshellUUID = this._docshellUUIDMap.get(entry.docshellID); delete entry.docshellID; } if (entry.docshellUUID) { shEntry.docshellID = Components.ID(entry.docshellUUID); } if (entry.structuredCloneState && entry.structuredCloneVersion) { var stateData = Cc[ "@mozilla.org/docshell/structured-clone-container;1" ].createInstance(Ci.nsIStructuredCloneContainer); stateData.initFromBase64( entry.structuredCloneState, entry.structuredCloneVersion ); shEntry.stateData = stateData; } if (entry.scrollRestorationIsManual) { shEntry.scrollRestorationIsManual = true; } else { if (entry.scroll) { shEntry.setScrollPosition( ...this._deserializeScrollPosition(entry.scroll) ); } if (entry.presState) { let layoutHistoryState = shEntry.initLayoutHistoryState(); for (let presState of entry.presState) { this._deserializePresState(layoutHistoryState, presState); } } } 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, childDocIdents }; docIdentMap[entry.docIdentifier] = matchingEntry; } else { shEntry.adoptBFCacheEntry(matchingEntry.shEntry); childDocIdents = matchingEntry.childDocIdents; } } // Every load must have a triggeringPrincipal to load otherwise we prevent it, // this code *must* always return a valid principal: shEntry.triggeringPrincipal = lazy.E10SUtils.deserializePrincipal( entry.triggeringPrincipal_base64, () => { // This callback fires when we failed to deserialize the principal (or we don't have one) // and this ensures we always have a principal returned from this function. // We must always have a triggering principal for a load to work. // A null principal won't always work however is safe to use. console.warn( "Couldn't deserialize the triggeringPrincipal, falling back to NullPrincipal" ); return Services.scriptSecurityManager.createNullPrincipal({}); } ); // As both partitionedPrincipal and principalToInherit are both not required to load // it's ok to keep these undefined when we don't have a previously defined principal. if (entry.partitionedPrincipalToInherit_base64) { shEntry.partitionedPrincipalToInherit = lazy.E10SUtils.deserializePrincipal( entry.partitionedPrincipalToInherit_base64 ); } if (entry.principalToInherit_base64) { shEntry.principalToInherit = lazy.E10SUtils.deserializePrincipal( entry.principalToInherit_base64 ); } if (entry.csp) { shEntry.csp = lazy.E10SUtils.deserializeCSP(entry.csp); } if (entry.wireframe) { shEntry.wireframe = entry.wireframe; } if (entry.children) { 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, shistory ), i ); } } return shEntry; }, /** * Expands serialized PresState data and adds it to the given nsILayoutHistoryState. * * @param layoutHistoryState * nsILayoutHistoryState instance * @param presState * Object containing serialized PresState data. */ _deserializePresState(layoutHistoryState, presState) { let stateKey = presState.stateKey; let scrollOriginDowngrade = typeof presState.scrollOriginDowngrade == "boolean" ? presState.scrollOriginDowngrade : true; let res = presState.res || 1.0; layoutHistoryState.addNewPresState( stateKey, ...this._deserializeScrollPosition(presState.scroll), scrollOriginDowngrade, res ); }, /** * Expands serialized scroll position data into an array containing the x and y coordinates, * defaulting to 0,0 if no scroll position was found. * * @param scroll * Object containing serialized scroll position data. * @return An array containing the scroll position's x and y coordinates. */ _deserializeScrollPosition(scroll = "0,0") { return scroll.split(",").map(pos => parseInt(pos, 10) || 0); }, };