Source code

Revision control

Copy as Markdown

Other Tools

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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/. */
/* eslint-env mozilla/frame-script */
"use strict";
const { GeckoViewChildModule } = ChromeUtils.importESModule(
"resource://gre/modules/GeckoViewChildModule.sys.mjs"
);
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
ChromeUtils.defineESModuleGetters(this, {
SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs",
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
setTimeoutWithTarget: "resource://gre/modules/Timer.sys.mjs",
});
const NO_INDEX = Number.MAX_SAFE_INTEGER;
const LAST_INDEX = Number.MAX_SAFE_INTEGER - 1;
const DEFAULT_INTERVAL_MS = 1500;
// 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 PREF_INTERVAL = "browser.sessionstore.interval";
class Handler {
constructor(store) {
this.store = store;
}
get mm() {
return this.store.mm;
}
get eventDispatcher() {
return this.store.eventDispatcher;
}
get messageQueue() {
return this.store.messageQueue;
}
get stateChangeNotifier() {
return this.store.stateChangeNotifier;
}
}
/**
* Listens for state change notifcations from webProgress and notifies each
* registered observer for either the start of a page load, or its completion.
*/
class StateChangeNotifier extends Handler {
constructor(store) {
super(store);
this._observers = new Set();
const ifreq = this.mm.docShell.QueryInterface(Ci.nsIInterfaceRequestor);
const webProgress = ifreq.getInterface(Ci.nsIWebProgress);
webProgress.addProgressListener(
this,
Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
);
}
/**
* Adds a given observer |obs| to the set of observers that will be notified
* when when a new document starts or finishes loading.
*
* @param obs (object)
*/
addObserver(obs) {
this._observers.add(obs);
}
/**
* Notifies all observers that implement the given |method|.
*
* @param method (string)
*/
notifyObservers(method) {
for (const obs of this._observers) {
if (typeof obs[method] == "function") {
obs[method]();
}
}
}
/**
* @see nsIWebProgressListener.onStateChange
*/
onStateChange(webProgress, request, stateFlags) {
// Ignore state changes for subframes because we're only interested in the
// top-document starting or stopping its load.
if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.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.mm.docShell.hasLoadedNonBlankURI) {
return;
}
if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
this.notifyObservers("onPageLoadStarted");
} else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
this.notifyObservers("onPageLoadCompleted");
}
}
}
StateChangeNotifier.prototype.QueryInterface = ChromeUtils.generateQI([
"nsIWebProgressListener",
"nsISupportsWeakReference",
]);
/**
* 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}
*/
class SessionHistoryListener extends Handler {
constructor(store) {
super(store);
this._fromIdx = NO_INDEX;
// The state change observer is needed to handle initial subframe loads.
// It will redundantly invalidate with the SHistoryListener in some cases
// but these invalidations are very cheap.
this.stateChangeNotifier.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.
this.mm.docShell
.QueryInterface(Ci.nsIWebNavigation)
.sessionHistory.legacySHistory.addSHistoryListener(this);
// Listen for page title changes.
this.mm.addEventListener("DOMTitleChanged", this);
}
uninit() {
const sessionHistory = this.mm.docShell.QueryInterface(
Ci.nsIWebNavigation
).sessionHistory;
if (sessionHistory) {
sessionHistory.legacySHistory.removeSHistoryListener(this);
}
}
collect() {
// We want to send down a historychange even for full collects in case our
// session history is a partial session history, in which case we don't have
// enough information for a full update. collectFrom(-1) tells the collect
// function to collect all data avaliable in this process.
if (this.mm.docShell) {
this.collectFrom(-1);
}
}
// 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 LAST_INDEX
// if nothing else changed send an empty array and the additonal info like the selected index)
collectFrom(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 NO_INDEX 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 LAST_INDEX which ignores
// only the subsequent navigations, but not any new elements added.
return;
}
this._fromIdx = idx;
this.messageQueue.push("historychange", () => {
if (this._fromIdx === NO_INDEX) {
return null;
}
const history = SessionHistory.collect(this.mm.docShell, this._fromIdx);
this._fromIdx = NO_INDEX;
return history;
});
}
handleEvent() {
this.collect();
}
onPageLoadCompleted() {
this.collect();
}
onPageLoadStarted() {
this.collect();
}
OnHistoryNewEntry() {
// We ought to collect the previously current entry as well, see bug 1350567.
// TODO: Reenable partial history collection for performance
// this.collectFrom(oldIndex);
this.collect();
}
OnHistoryGotoIndex() {
// We ought to collect the previously current entry as well, see bug 1350567.
// TODO: Reenable partial history collection for performance
// this.collectFrom(LAST_INDEX);
this.collect();
}
OnHistoryPurge() {
this.collect();
}
OnHistoryReload() {
this.collect();
return true;
}
OnHistoryReplaceEntry() {
this.collect();
}
}
SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([
"nsISHistoryListener",
"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", zoom: {resolution: "1.5", displaySize:
* {height: "1600", width: "1000"}}, children:
* [null, null, {scroll: "200,200"}]}
*/
class ScrollPositionListener extends Handler {
constructor(store) {
super(store);
SessionStoreUtils.addDynamicFrameFilteredListener(
this.mm,
"mozvisualscroll",
this,
/* capture */ false,
/* system group */ true
);
SessionStoreUtils.addDynamicFrameFilteredListener(
this.mm,
"mozvisualresize",
this,
/* capture */ false,
/* system group */ true
);
this.stateChangeNotifier.addObserver(this);
}
handleEvent() {
this.messageQueue.push("scroll", () => this.collect());
}
onPageLoadCompleted() {
this.messageQueue.push("scroll", () => this.collect());
}
onPageLoadStarted() {
this.messageQueue.push("scroll", () => null);
}
collect() {
// TODO: Keep an eye on bug 1525259; we may not have to manually store zoom
// Save the current document resolution.
let zoom = 1;
const scrolldata =
SessionStoreUtils.collectScrollPosition(this.mm.content) || {};
const domWindowUtils = this.mm.content.windowUtils;
zoom = domWindowUtils.getResolution();
scrolldata.zoom = {};
scrolldata.zoom.resolution = zoom;
// Save some data that'll help in adjusting the zoom level
// when restoring in a different screen orientation.
const displaySize = {};
const width = {},
height = {};
domWindowUtils.getDocumentViewerSize(width, height);
displaySize.width = width.value;
displaySize.height = height.value;
scrolldata.zoom.displaySize = displaySize;
return scrolldata;
}
}
/**
* 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"}}
* ]
* }
*/
class FormDataListener extends Handler {
constructor(store) {
super(store);
SessionStoreUtils.addDynamicFrameFilteredListener(
this.mm,
"input",
this,
true
);
this.stateChangeNotifier.addObserver(this);
}
handleEvent() {
this.messageQueue.push("formdata", () => this.collect());
}
onPageLoadStarted() {
this.messageQueue.push("formdata", () => null);
}
collect() {
return SessionStoreUtils.collectFormData(this.mm.content);
}
}
/**
* 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.
*/
class MessageQueue extends Handler {
constructor(store) {
super(store);
/**
* A map (string -> lazy fn) holding lazy closures of all queued data
* collection routines. These functions will return data collected from the
* docShell.
*/
this._data = new Map();
/**
* The delay (in ms) used to delay sending changes after data has been
* invalidated.
*/
this.BATCH_DELAY_MS = 1000;
/**
* The minimum idle period (in ms) we need for sending data to chrome process.
*/
this.NEEDED_IDLE_PERIOD_MS = 5;
/**
* Timeout for waiting an idle period to send data. We will set this from
* the pref "browser.sessionstore.interval".
*/
this._timeoutWaitIdlePeriodMs = null;
/**
* 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.
*/
this._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.
*/
this._timeoutDisabled = false;
/**
* True if there is already a send pending idle dispatch, set to prevent
* scheduling more than one. If false there may or may not be one scheduled.
*/
this._idleScheduled = false;
this.timeoutDisabled = Services.prefs.getBoolPref(
TIMEOUT_DISABLED_PREF,
false
);
this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(
PREF_INTERVAL,
DEFAULT_INTERVAL_MS
);
Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this);
Services.prefs.addObserver(PREF_INTERVAL, this);
}
/**
* 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;
}
}
uninit() {
this.cleanupTimers();
}
/**
* Cleanup pending idle callback and timer.
*/
cleanupTimers() {
this._idleScheduled = false;
if (this._timeout) {
clearTimeout(this._timeout);
this._timeout = null;
}
}
observe(subject, topic, data) {
if (topic == "nsPref:changed") {
switch (data) {
case TIMEOUT_DISABLED_PREF:
this.timeoutDisabled = Services.prefs.getBoolPref(
TIMEOUT_DISABLED_PREF,
false
);
break;
case PREF_INTERVAL:
this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(
PREF_INTERVAL,
DEFAULT_INTERVAL_MS
);
break;
default:
debug`Received unknown message: ${data}`;
break;
}
}
}
/**
* 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(key, fn) {
this._data.set(key, fn);
if (!this._timeout && !this._timeoutDisabled) {
// Wait a little before sending the message to batch multiple changes.
this._timeout = setTimeoutWithTarget(
() => this.sendWhenIdle(),
this.BATCH_DELAY_MS,
this.mm.tabEventTarget
);
}
}
/**
* Sends queued data when the remaining idle time is enough or waiting too
* long; otherwise, request an idle time again. If the |deadline| is not
* given, this function is going to schedule the first request.
*
* @param deadline (object)
* An IdleDeadline object passed by idleDispatch().
*/
sendWhenIdle(deadline) {
if (!this.mm.content) {
// The frameloader is being torn down. Nothing more to do.
return;
}
if (deadline) {
if (
deadline.didTimeout ||
deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS
) {
this.send();
return;
}
} else if (this._idleScheduled) {
// Bail out if there's a pending run.
return;
}
ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), {
timeout: this._timeoutWaitIdlePeriodMs,
});
this._idleScheduled = true;
}
/**
* Sends queued data to the chrome process.
*
* @param options (object)
* {isFinal: true} to signal this is the final message sent on unload
*/
send(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 (!this.mm.docShell) {
return;
}
this.cleanupTimers();
const data = {};
for (const [key, func] of this._data) {
const value = func();
if (value || (key != "storagechange" && key != "historychange")) {
data[key] = value;
}
}
this._data.clear();
try {
// Send all data to the parent process.
this.eventDispatcher.sendRequest({
type: "GeckoView:StateUpdated",
data,
isFinal: options.isFinal || false,
epoch: this.store.epoch,
});
} catch (ex) {
if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) {
warn`Failed to save session state`;
}
}
}
}
class SessionStateAggregator extends GeckoViewChildModule {
constructor(aModuleName, aMessageManager) {
super(aModuleName, aMessageManager);
this.mm = aMessageManager;
this.messageQueue = new MessageQueue(this);
this.stateChangeNotifier = new StateChangeNotifier(this);
this.handlers = [
new SessionHistoryListener(this),
this.stateChangeNotifier,
this.messageQueue,
];
if (!Services.appinfo.sessionStorePlatformCollection) {
this.handlers.push(
new FormDataListener(this),
new ScrollPositionListener(this)
);
}
this.messageManager.addMessageListener("GeckoView:FlushSessionState", this);
}
receiveMessage(aMsg) {
debug`receiveMessage: ${aMsg.name}`;
switch (aMsg.name) {
case "GeckoView:FlushSessionState":
this.flush();
break;
}
}
flush() {
// Flush the message queue, send the latest updates.
this.messageQueue.send();
}
onUnload() {
// Upon frameLoader destruction, send a final update message to
// the parent and flush all data currently held in the child.
this.messageQueue.send({ isFinal: true });
for (const handler of this.handlers) {
if (handler.uninit) {
handler.uninit();
}
}
// We don't need to take care of any StateChangeNotifier observers as they
// will die with the content script.
}
}
// TODO: Bug 1648158 Move SessionAggregator to the parent process
class DummySessionStateAggregator extends GeckoViewChildModule {
constructor(aModuleName, aMessageManager) {
super(aModuleName, aMessageManager);
this.messageManager.addMessageListener("GeckoView:FlushSessionState", this);
}
receiveMessage(aMsg) {
debug`receiveMessage: ${aMsg.name}`;
switch (aMsg.name) {
case "GeckoView:FlushSessionState":
// Do nothing
break;
}
}
}
const { debug, warn } = SessionStateAggregator.initLogging(
"SessionStateAggregator"
);
const module = Services.appinfo.sessionHistoryInParent
? // If history is handled in the parent we don't need a session aggregator
// TODO: Bug 1648158 remove this and do everything in the parent
DummySessionStateAggregator.create(this)
: SessionStateAggregator.create(this);