/**
* ## Overview
* This module addresses several challenges for studying user engagement with web content.
* * __Syncing Measurements and Interventions.__ A study that uses `WebScience` will
* often involve multiple measurements or interventions on a webpage. The
* `pageManager` module enables studies to sync these measurements and interventions
* by assigning a random unique identifier to each webpage.
* * __Generating Page Lifecycle Events.__ Measurements and interventions are often
* linked to specific events in the webpage lifecyle. The `pageManager` module
* standardizes a set of webpage lifecycle events.
* * __Tracking User Attention.__ Measurements and interventions often depend on user
* attention to web content. The `pageManager` module provides a standardized
* attention model that incorporates tab switching, window switching, application
* switching, locked screens, and user mouse and keyboard input.
* * __Generating Audio Events.__ This module provides events for webpage audio,
* enabling measurements and interventions based on media playback.
* * __Bridging the Background and Content Script Environments.__ WebExtensions
* includes two distinct execution environments: background scripts and content
* scripts. These execution environments are, unfortunately, only loosely bound
* together by tab IDs. As a result, there can be race conditions---the background
* and content environments can have mismatched states, such that messages arrive
* at the wrong webpage or are attributed to the wrong webpage. This module
* provides provides page lifecycle, user attention, and audio events that are
* bound to specific webpages.
*
* ## Pages
* This module creates an abstraction over webpages as perceived by users (i.e., when
* content loads with a new HTTP(S) URL in the browser bar or the page visibly reloads).
* Note that the History API enables web content to modify the URL without loading a new
* HTML document via HTTP(S) or creating a new Document object. This module treats
* a URL change via the History API as equivalent to traditional webpage navigation,
* because (by design) it appears identical to the user. Accounting for the History
* API is important, because it is used on some exceptionally popular websites (e.g.,
* YouTube).
*
* ## Page IDs
* Each page ID is a random (v4) UUID, consistent with RFC4122.
*
* ## Page Lifecycle
* Each webpage has the following lifecycle events, which fire in both the background
* page and content script environments.
* * Page Visit Start - The browser has started to load a webpage in a tab. This
* event is fired early in context script execution (i.e., soon after
* `document_start`). For a webpage with a new Document, the event is
* timestamped with the time the `window` object was created (the time origin
* from the High Resolution Time Level 2 API, in ms). For a webpage that does not
* have a new Document (i.e., resulting from the History API), the event is
* timestamped with the URL change in the WebNavigation API.
* * Page Visit Stop - The browser is unloading the webpage. Ordinarily this
* event fires and is timestamped with the `window` unload event. When the page
* changes via the History API, this event fires and is timestamped with the URL
* change in the WebNavigation API.
*
* ## Attention Tracking
* Attention to a page is defined as satisfying all of the following conditions.
* * The tab is the active tab in its browser window.
* * The window containing the tab is the current browser window.
* * The current browser window has focus in the operating system.
* * The operating system is not displaying a lock screen or screen saver.
* * Optional: The user has provided mouse or keyboard input within a specified time
* interval.
*
* In the content script environment, each page has an attention status, and an event
* fires when that status changes. Attention update events are timestamped with events
* from the WebExtensions `tabs`, `windows`, and `idle` APIs.
*
* ## Audio Events
* In the content script environment, each page has an audio status, and an event fires
* when that status changes. Audio update events fire and are timestamped with events
* from the WebExtensions `tabs` API.
*
* ## Event Ordering
* This module guarantees the ordering of page lifecycle, attention, and audio events.
* * Page visit start and page visit stop only fire once for each page, in that order.
* * Page attention and audio update events will only occur between page visit start
* and stop events.
*
* ## Additional Implementation Notes
* This module depends on the `idle` API, which has a couple quirks in Firefox:
* * There is a five-second interval when polling idle status from the operating
* system.
* * Depending on the platform, the idle API reports either time since user input to
* the browser or time since user input to the operating system.
*
* The polling interval coarsens the timing of page attention events related to idle state.
* As long as the polling interval is relatively short in comparison to the idle threshold,
* that should not be an issue.
*
* The platform-specific meaning of idle state should also not be an issue. There is only a
* difference between the two meanings of idle state when the user is providing input to
* another application; if the user is providing input to the browser, or is not providing
* input at all, the two meanings are identical. In the scenario where the user is providing
* input to another application, the browser will lose focus in the operating system; this
* module will detect that with the windows API and fire a page attention event (if needed).
*
* Some implementation quirks to be aware of for future development on this module:
* * Non-browser windows do not appear in the results of `windows.getAll()`, and calling
* `windows.get()` on a non-browser window throws an error. Switching focus to a non-
* browser window will, however, fire the `windows.onFocusChanged` event. The module
* assumes that if `windows.onFocusChanged` fires with an unknown window, that window
* is a non-browser window.
* * The module assumes that valid tab IDs and window IDs are always >= 0.
*
* ## Known Issues
* * The background script sends update messages to tabs regardless of whether they
* are ordinary tabs or have the pageManager content script running, because the
* background script does not track window types or tab content. The errors
* generated by this issue are caught in `messaging.sendMessageToTab`, and the
* issue should not cause any problems for studies.
*
* ## Possible Improvements
* * Rebuild a page attention update event in the background page environment.
* * Rebuild the capability to fire events for pages that are already open when the module
* loads.
* * Add logic to handle the situation where the content script execution environment crashes,
* so the page visit stop message doesn't fire from the associated content script.
* * Add an event in the content script for detecting when content has lazily loaded into the
* DOM after the various DOM loading events (e.g., on Twitter).
*
* @module pageManager
*/
import * as events from "./events.js";
import * as idle from "./idle.js";
import * as messaging from "./messaging.js";
import * as permissions from "./permissions.js";
import * as timing from "./timing.js";
import pageManagerContentScript from "include:./content-scripts/pageManager.content.js";
/**
* The threshold (in seconds) for determining whether the browser has the user's attention,
* based on mouse and keyboard input.
* @private
* @constant {number}
* @default
*/
const idleThreshold = 15;
/**
* Whether to consider user input in determining attention state.
* @private
* @constant {boolean}
* @default
*/
const considerUserInputForAttention = true;
/**
* A listener for the `onPageVisitStart` event.
* @callback pageVisitStartListener
* @memberof module:pageManager.onPageVisitStart
* @param {Object} details - Additional information about the page visit start event.
* @param {string} details.pageId - The ID for the page, unique across browsing sessions.
* @param {number} details.tabId - The ID for the tab containing the page, unique to the browsing session. Note that if
* you send a message to the content script in the tab, there is a possible race condition where the page in
* the tab changes before your message arrives. You should specify a page ID (e.g., `pageId`) in your message to
* the content script, and the content script should check that page ID against its current page ID to ensure that
* the message was received by the intended page.
* @param {number} details.windowId - The ID for the window containing the page, unique to the browsing session.
* Note that tabs can subsequently move between windows.
* @param {string} details.url - The URL of the page loading in the tab, without any hash.
* @param {string} details.referrer - The referrer URL for the page loading in the tab, or `""` if
* there is no referrer.
* @param {number} details.pageVisitStartTime - The time when the underlying event fired.
* @param {boolean} details.privateWindow - Whether the page is in a private window.
* @param {boolean} details.isHistoryChange - Whether the page visit was caused by a change via the History API.
*/
/**
* Add a listener for the `onPageVisitStart` event.
* @function addListener
* @memberof module:pageManager.onPageVisitStart
* @param {pageVisitStartListener} listener - The listener to add.
* @param {Object} options - Options for the listener.
* @param {boolean} [options.privateWindows=false] - Whether to notify the listener for events in private windows.
*/
/**
* Remove a listener for the `onPageVisitStart` event.
* @function removeListener
* @memberof module:pageManager.onPageVisitStart
* @param {pageVisitStartListener} listener - The listener to remove.
*/
/**
* Whether a specified listener has been added for the `onPageVisitStart` event.
* @function hasListener
* @memberof module:pageManager.onPageVisitStart
* @param {pageVisitStartListener} listener - The listener to check.
* @returns {boolean} Whether the listener has been added for the event.
*/
/**
* Whether the `onPageVisitStart` event has any listeners.
* @function hasAnyListeners
* @memberof module:pageManager.onPageVisitStart
* @returns {boolean} Whether the event has any listeners.
*/
/**
* An event that is fired in the background script environment when a page visit starts
* in the content script environment.
* @namespace
*/
export const onPageVisitStart = events.createEvent({
name: "webScience.pageManager.onPageVisitStart",
// Make sure the module is initialized when a listener is added
addListenerCallback: listener => initialize(),
// Filter notifications for events in private windows
notifyListenersCallback: (listener, [ details ], options) => {
if(!details.privateWindow || (("privateWindows" in options) && options.privateWindows))
return true;
return false;
}
});
/**
* A listener for the `onPageVisitStop` event.
* @callback pageVisitStopListener
* @memberof module:pageManager.onPageVisitStop
* @param {Object} details - Additional information about the page visit stop event.
* @param {string} details.pageId - The ID for the page, unique across browsing sessions.
* @param {string} details.url - The URL of the page loading in the tab, without any hash.
* @param {string} details.referrer - The referrer URL for the page loading in the tab, or `""` if
* there is no referrer.
* @param {number} details.pageVisitStartTime - The time when the page visit started.
* @param {number} details.pageVisitStopTime - The time when the underlying event fired.
* @param {boolean} details.privateWindow - Whether the page is in a private window.
*/
/**
* Add a listener for the `onPageVisitStop` event.
* @function addListener
* @memberof module:pageManager.onPageVisitStop
* @param {pageVisitStopListener} listener - The listener to add.
* @param {Object} options - Options for the listener.
* @param {boolean} privateWindows - Whether to notify the listener for events in private windows.
*/
/**
* Remove a listener for the `onPageVisitStop` event.
* @function removeListener
* @memberof module:pageManager.onPageVisitStop
* @param {pageVisitStopListener} listener - The listener to remove.
*/
/**
* Whether a specified listener has been added for the `onPageVisitStop` event.
* @function hasListener
* @memberof module:pageManager.onPageVisitStop
* @param {pageVisitStopListener} listener - The listener to check.
* @returns {boolean} Whether the listener has been added for the event.
*/
/**
* Whether the `onPageVisitStop` event has any listeners.
* @function hasAnyListeners
* @memberof module:pageManager.onPageVisitStop
* @returns {boolean} Whether the event has any listeners.
*/
/**
* An event that is fired in the background script environment when a page visit stops
* in the content script environment.
* @namespace
*/
export const onPageVisitStop = events.createEvent({
name: "webScience.pageManager.onPageVisitStop",
// Make sure the module is initialized when a listener is added
addListenerCallback: listener => initialize(),
// Filter notifications for events in private windows
notifyListenersCallback: (listener, [ details ], options) => {
if(!details.privateWindow || (("privateWindows" in options) && options.privateWindows))
return true;
return false;
}
});
/**
* Notify a page that its attention state may have changed.
* @private
* @param {number} tabId - The tab containing the page, unique to the browsing session.
* @param {boolean} pageHasAttention - Whether the tab containing the page has the user's
* attention.
* @param {number} [timeStamp=timing.now()] - The time when the underlying browser event fired.
*/
function sendPageAttentionUpdate(tabId, pageHasAttention, timeStamp = timing.now()) {
messaging.sendMessageToTab(tabId, {
type: "webScience.pageManager.pageAttentionUpdate",
pageHasAttention,
timeStamp
});
}
/**
* The currently active tab in the currently focused browsing window. Has the value -1
* if there is no such tab.
* @private
* @type {number}
* @default
*/
let currentActiveTab = -1;
/**
* The currently focused browsing window. Has the value -1 if there is no such window.
* @private
* @type {number}
* @default
*/
let currentFocusedWindow = -1;
/**
* Checks for the following conditions:
* * The tab is the currently active tab in the currently focused window.
* * The window is the currently focused window.
* * The browser is active (i.e., not idle), if the module is configured to
* consider user input in determining the attention state.
* @private
* @param {number} tabId - The tab to check.
* @param {number} windowId - The window to check.
*/
function checkForAttention(tabId, windowId) {
return ((currentActiveTab === tabId) && (currentFocusedWindow === windowId) && (considerUserInputForAttention ? browserIsActive : true));
}
/**
* @typedef {Object} WindowDetails
* @property {number} activeTab - The ID of the active tab in the window,
* or -1 if there is no active tab.
* @private
*/
/**
* A Map that tracks the current state of browser windows. We need this cached
* state to avoid asynchronous queries when the focused window changes. The
* keys are window IDs and the values are WindowDetails objects.
* @private
* @constant {Map<number,WindowDetails>}
* @default
*/
const windowState = new Map();
/**
* Update the window state cache with new information about a window.
* @private
* @param {number} windowId - The window ID.
* @param {WindowDetails} windowDetails - The new information about the
* window.
*/
function updateWindowState(windowId, { activeTab }) {
let windowDetails = windowState.get(windowId);
if(windowDetails === undefined) {
windowDetails = { activeTab: -1 };
windowState.set(windowId, windowDetails);
}
if(activeTab !== undefined) {
windowDetails.activeTab = activeTab;
}
}
/**
* Whether the browser is active or idle. Ignored if the module is configured to
* not consider user input when determining the attention state.
* @private
* @type {boolean}
* @default
*/
let browserIsActive = false;
/**
* Whether the module is in the process of configuring browser event handlers
* and caching initial state.
* @private
* @type {boolean}
*/
let initializing = false;
/**
* Whether the module has started configuring browser event handlers and caching
* initial state.
* @private
* @type {boolean}
*/
let initialized = false;
/**
* Initialize `pageManager` in the background and content script environments. If you are using
* `pageManager` events in content scripts but not background scripts, you must call this function.
* If you are using `pageManager` events in background scripts, this function is automatically called
* when adding a listener for an event. This function configures message passing between the
* `pageManager` background script and content script, registers browser event handlers, caches
* initial state, and registers the `pageManager` content script. It runs only once.
*/
export async function initialize() {
if(initialized || initializing) {
return;
}
initializing = true;
permissions.check({
module: "webScience.pageManager",
requiredPermissions: [ "webNavigation" ],
suggestedOrigins: [ "<all_urls>" ]
});
// Register message listeners and schemas for communicating with the content script
// The content script sends a webScience.pageManger.pageVisitStart message when
// there is a page visit start event
messaging.onMessage.addListener((pageVisitStartInfo, sender) => {
// Notify the content script if it has attention
// We can't send this message earlier (e.g., when the tab URL changes) because we need to know the content
// script is ready to receive the message
if(checkForAttention(sender.tab.id, sender.tab.windowId)) {
sendPageAttentionUpdate(sender.tab.id, true, timing.now());
}
onPageVisitStart.notifyListeners([{
pageId: pageVisitStartInfo.pageId,
tabId: sender.tab.id,
windowId: sender.tab.windowId,
url: pageVisitStartInfo.url,
referrer: pageVisitStartInfo.referrer,
pageVisitStartTime: pageVisitStartInfo.timeStamp,
privateWindow: pageVisitStartInfo.privateWindow,
isHistoryChange: pageVisitStartInfo.isHistoryChange
}]);
}, {
type: "webScience.pageManager.pageVisitStart",
schema: {
pageId: "string",
url: "string",
referrer: "string",
timeStamp: "number",
privateWindow: "boolean",
isHistoryChange: "boolean"
}
});
// The content script sends a webScience.pageManger.pageVisitStop message when
// there is a page visit stop event
// We don't currently include tab or window information with the page visit stop event
// because the sender object doesn't include that information when the tab is closing
messaging.onMessage.addListener((pageVisitStopInfo) => {
onPageVisitStop.notifyListeners([{
pageId: pageVisitStopInfo.pageId,
url: pageVisitStopInfo.url,
referrer: pageVisitStopInfo.referrer,
pageVisitStartTime: pageVisitStopInfo.timeStamp,
pageVisitStopTime: pageVisitStopInfo.timeStamp,
privateWindow: pageVisitStopInfo.privateWindow
}]);
}, {
type: "webScience.pageManager.pageVisitStop",
schema: {
pageId: "string",
url: "string",
referrer: "string",
timeStamp: "number",
pageVisitStartTime: "number",
privateWindow: "boolean"
}
});
// The background script sends a webScience.pageManager.pageAttentionUpdate message
// when the attention state of the page may have changed
messaging.registerSchema("webScience.pageManager.pageAttentionUpdate", {
timeStamp: "number",
pageHasAttention: "boolean"
});
// The background script sends a webScience.pageManager.urlChanged message when
// the URL changes for a tab, indicating a possible page load with the History API
messaging.registerSchema("webScience.pageManager.urlChanged", {
url: "string",
timeStamp: "number",
webNavigationTimeStamp: "number"
});
// The background script sends a webScience.pageManager.pageAudioUpdate message
// when the audio state of the page may have changed
messaging.registerSchema("webScience.pageManager.pageAudioUpdate", {
pageHasAudio: "boolean",
timeStamp: "number"
});
// Register background script event handlers
// If a tab's audible state changed, send webScience.pageManager.pageAudioUpdate
browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
if(!initialized) {
return;
}
messaging.sendMessageToTab(tabId, {
type: "webScience.pageManager.pageAudioUpdate",
pageHasAudio: Boolean(changeInfo.audible),
timeStamp: timing.now()
});
});
// If a tab's URL changed because of the History API, send webScience.pageManager.urlChanged
browser.webNavigation.onHistoryStateUpdated.addListener((details) => {
if(!initialized) {
return;
}
if(details.frameId !== 0) {
return;
}
messaging.sendMessageToTab(details.tabId, {
type: "webScience.pageManager.urlChanged",
url: details.url,
timeStamp: timing.now(),
// We can use details.timeStamp because, contrary to the MDN and Chrome documentation,
// the timestamp is for the history API change rather than when the navigation was
// committed. See: https://github.com/mdn/content/issues/4469
webNavigationTimeStamp: details.timeStamp
});
});
browser.tabs.onRemoved.addListener((tabId, removeInfo) => {
if(!initialized) {
return;
}
// We don't have to update the window state here, because either there is
// another tab in the window that will become active (and tabs.onActivated
// will fire), or there is no other tab in the window so the window closes
// (and windows.onRemoved will fire)
// If this is the active tab, forget it
if(currentActiveTab === tabId) {
currentActiveTab = -1;
}
});
// Handle when the active tab in a window changes
browser.tabs.onActivated.addListener(activeInfo => {
if(!initialized) {
return;
}
const timeStamp = timing.now();
// If this is a non-browser tab, ignore it
if((activeInfo.tabId === browser.tabs.TAB_ID_NONE) ||
(activeInfo.tabId < 0) ||
(activeInfo.windowId < 0)) {
return;
}
// Update the window state cache with the new
// active tab ID
updateWindowState(activeInfo.windowId, {
activeTab: activeInfo.tabId
});
// If there isn't a focused window, or the tab update is not in the focused window, ignore it
if((currentFocusedWindow < 0) || (activeInfo.windowId != currentFocusedWindow)) {
return;
}
// If the browser is active or (optionally) we are not considering user input,
// notify the current page with attention that it no longer has attention, and notify
// the new page with attention that is has attention
if((browserIsActive || !considerUserInputForAttention)) {
if((currentActiveTab >= 0) && (currentFocusedWindow >= 0)) {
sendPageAttentionUpdate(currentActiveTab, false, timeStamp);
}
sendPageAttentionUpdate(activeInfo.tabId, true, timeStamp);
}
// Remember the new active tab
currentActiveTab = activeInfo.tabId;
});
browser.windows.onRemoved.addListener(windowId => {
if(!initialized) {
return;
}
// If we have cached state for this window, drop it
windowState.delete(windowId);
});
browser.windows.onFocusChanged.addListener(windowId => {
if(!initialized) {
return;
}
const timeStamp = timing.now();
// If the browser is active or (optionally) we are not considering user input, and if
// if there is an active tab in a focused window, notify the current page with attention
// that it no longer has attention
if((browserIsActive || !considerUserInputForAttention) && ((currentActiveTab >= 0) && (currentFocusedWindow >= 0))) {
sendPageAttentionUpdate(currentActiveTab, false, timeStamp);
}
// If the browser has lost focus in the operating system, remember
// tab ID = -1 and window ID = -1, and do not notify any page that it has attention
// Note that this check should happen before the browser.windows.get await below,
// because quick sequential events can cause the browser.windows.onFocusChanged
// listener to run again before the await resolves and trigger errors if currentActiveTab
// and currentFocusedWindow are not set properly
if (windowId === browser.windows.WINDOW_ID_NONE) {
currentActiveTab = -1;
currentFocusedWindow = -1;
return;
}
// Get information about the focused window from the cached window state
const focusedWindowDetails = windowState.get(windowId);
// If we haven't seen this window before, that means it's not a browser window,
// so remember tab ID = -1 and window ID -1, and do not notify any page that it has attention
if(focusedWindowDetails === undefined) {
currentActiveTab = -1;
currentFocusedWindow = -1;
return;
}
// Otherwise, remember the new active tab and focused window, and if the browser is active
// or (optionally) we are not considering user input, notify the page in the tab that it
// has attention
currentActiveTab = focusedWindowDetails.activeTab;
currentFocusedWindow = windowId;
if(browserIsActive || !considerUserInputForAttention) {
sendPageAttentionUpdate(currentActiveTab, true, timeStamp);
}
});
// Handle when the browser activity state changes
// This listener abstracts the browser activity state into two categories: active and inactive
// Active means the user has recently provided input to the browser, inactive means any other
// state (regardless of whether a screensaver or lock screen is enabled)
// Note that we have to call idle.onStateChanged.addListener before we call
// idle.queryState, so this comes before caching the initial state
if(considerUserInputForAttention) {
idle.onStateChanged.addListener(newState => {
if(!initialized) {
return;
}
const timeStamp = timing.now();
// If the browser is not transitioning between active and inactive states, ignore the event
if((browserIsActive) === (newState === "active")) {
return;
}
// Remember the flipped browser activity state
browserIsActive = !browserIsActive;
// If there isn't an active tab in a focused window, we don't need to send attention events
if((currentActiveTab < 0) || (currentFocusedWindow < 0)) {
return;
}
// Send an attention state change event to the current active tab, reflecting the browser activity state
sendPageAttentionUpdate(currentActiveTab, browserIsActive, timeStamp);
}, idleThreshold);
}
// Cache the initial idle, window, and tab state
if(considerUserInputForAttention)
browserIsActive = (idle.queryState(idleThreshold) === "active");
const openWindows = await browser.windows.getAll({
populate: true
});
for(const openWindow of openWindows) {
// If the window doesn't have a window ID, ignore it
// This shouldn't happen, but checking anyway since
// the id property is optional in the windows.Window
// type
if(!("id" in openWindow))
continue;
// Iterate the tabs in the window to cache tab state
// and find the active tab in the window
let activeTabInOpenWindow = -1;
if("tabs" in openWindow)
for(const tab of openWindow.tabs) {
if(tab.active)
activeTabInOpenWindow = tab.id;
}
updateWindowState(openWindow.id, {
activeTab: activeTabInOpenWindow
});
// If this is the focused window and it is a normal or popup
// window, remember the window ID and active tab ID (if any)
// If there is no focused window, or the focused window isn't
// a normal or popup window, this block will not run and we
// will retain the default values of tab ID = -1 and window
// ID = -1
if(openWindow.focused) {
currentFocusedWindow = openWindow.id;
currentActiveTab = activeTabInOpenWindow;
}
}
// Register the pageManager content script for all URLs permitted by the extension manifest.
const matchPatterns = permissions.getManifestOriginMatchPatterns();
// Firefox only supports this as of version 105, remove this check when that version of Firefox ships.
let persistAcrossSessions = true;
const browserInfo = browser.runtime && browser.runtime.getBrowserInfo && await browser.runtime.getBrowserInfo();
if (browserInfo && browserInfo.name === "Firefox") {
persistAcrossSessions = false;
}
const contentScriptId = "pageManager";
let scripts = await browser.scripting.getRegisteredContentScripts({
ids: [contentScriptId],
});
if (scripts.length === 0) {
await browser.scripting.registerContentScripts([{
id: contentScriptId,
js: ["dist/browser-polyfill.min.js", pageManagerContentScript],
matches: matchPatterns,
persistAcrossSessions,
runAt: "document_start"
}]);
}
initializing = false;
initialized = true;
}