/**
* This module enables adding browser idle state listeners with
* varying detection intervals. The module is needed because the
* WebExtensions `idle` API currently only supports one detection
* interval per extension.
*
* The implementation of this module combines the WebExtensions `idle`
* API and `setTimeout`. It configures the `idle` API to use the
* minimum idle detection interval with `idle.setDetectionInterval()`,
* adds a listener for the `idle.onStateChanged` event, and then uses
* `setTimeout` after the browser goes idle to notify idle state
* listeners with detection intervals greater than the minimum. If there
* are any pending idle notification timeouts when the browser goes
* active, those timeouts are cleared.
*
* Some implementation quirks to be aware of for use and future
* development:
*
* * This module depends on configuring the detection interval
* for the `idle` API to its minimum value. Any subsequent changes to
* the idle state detection interval in the `idle` API will result in
* unpredictable behavior.
*
* * Idle state events generated by this module are not guaranteed to
* reflect idle state transitions (e.g., a listener might receive
* `"active"` followed by `"active"`). We might want to implement this
* guarantee eventually.
*
* * Because the browser idle state resets with each browser session,
* it is not a problem that timeouts do not persist between browser
* sessions.
*
* * The module does not directly interact with the Firefox
* [`nsIdleService`](https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIIdleService),
* even though it supports varying idle state detection intervals, in
* order to minimize privileged extension code.
*
* @module idle
*/
import * as events from "./events.js";
import * as permissions from "./permissions.js";
import * as timing from "./timing.js";
/**
* The minimum idle state detection interval (in seconds) supported by
* the `idle` API.
* @private
* @constant {number}
*/
const minimumIdleStateDetectionIntervalInSeconds = 15;
/**
* Whether we have configured configured the idle state detection
* interval, cached the idle state, and added the idle state
* listener.
* @private
* @type {boolean}
*/
let initialized = false;
/**
* An estimate of the time (in milliseconds since the epoch) when the
* browser last entered an idle state. Generated by subtracting the
* idle state detection interval (converted to milliseconds) from the
* time the browser last fired an idle state notification.
* @private
* @type {number}
*/
let lastIdleTime = -1;
/**
* A cached copy of the browser's current idle state. This caching enables
* synchronous checking of the current idle state.
* @private
* @type {string}
*/
let currentIdleState = "active";
/**
* A Map that stores browser idle state listeners. The keys are
* detection intervals in seconds and the values are Sets of
* browser idle state listeners.
* @private
* @constant {Map<number,Set<function>>}
*/
const idleStateListeners = new Map();
/**
* A Map that stores browser idle state timeouts. The keys are
* detection intervals in seconds and the values are `timeoutID`
* values from `setTimeout()`.
* @private
* @constant {Map<number,number>}
*/
const idleStateTimeouts = new Map();
/**
* Configure the idle state detection interval, cache the idle state,
* and add the idle state listener.
* @private
*/
async function initialize() {
if(initialized) {
return;
}
initialized = true;
permissions.check({
module: "webScience.idle",
requiredPermissions: [ "idle" ]
});
browser.idle.setDetectionInterval(minimumIdleStateDetectionIntervalInSeconds);
currentIdleState = await browser.idle.queryState(minimumIdleStateDetectionIntervalInSeconds);
if(currentIdleState === "idle") {
lastIdleTime = timing.now() - (minimumIdleStateDetectionIntervalInSeconds * 1000);
}
browser.idle.onStateChanged.addListener(idleOnStateChangedListener);
}
/**
* Determine whether the browser has been idle for a specified time.
* This function is synchronous, unlike `idle.queryState`. Note that,
* if an idle state listener has not been added, this function
* will always return the default value of active state.
* @param {number} detectionIntervalInSeconds - The detection interval
* to use.
* @returns {string} - The idle state, either "idle" or "active".
*/
export function queryState(detectionIntervalInSeconds) {
if(currentIdleState !== "idle") {
return currentIdleState;
}
if(timing.now() >= (lastIdleTime + (detectionIntervalInSeconds * 1000))) {
return "idle";
}
return "active";
}
/**
* A listener for `idle.onStateChanged` that supports notifying
* idle state listeners with varying detection intervals.
* @param {browser.idle.IdleState} - The new browser idle state.
* @private
*/
function idleOnStateChangedListener(newState) {
currentIdleState = newState;
// If the browser idle state transitions to non-idle...
if(newState !== "idle") {
// Cancel any pending notification timeouts and forget the timeout IDs
for(const idleStateTimeoutID of idleStateTimeouts.values()) {
clearTimeout(idleStateTimeoutID);
}
idleStateTimeouts.clear();
// Notify all the idle state listeners
for(const idleStateListenerSet of idleStateListeners.values()) {
for(const idleStateListener of idleStateListenerSet) {
idleStateListener(newState.repeat(1));
}
}
return;
}
// If the browser idle state transitions to idle...
// Remember an estimate of when the browser last went into idle state
lastIdleTime = timing.now() - (minimumIdleStateDetectionIntervalInSeconds * 1000);
// Set timeouts for all the idle state listeners
for(const [detectionIntervalInSeconds, idleStateListenersWithDetectionInterval] of idleStateListeners) {
scheduleIdleStateTimeout(idleStateListenersWithDetectionInterval, detectionIntervalInSeconds);
}
}
/**
* Schedule a timeout for a set of idle state listeners.
* @param {Set<idleStateChangeListener>} idleStateListenersWithDetectionInterval - The set of idle state listeners.
* @param {number} detectionIntervalInSeconds - The idle state detection interval (in seconds) for this set of listeners.
* @returns {number} The timeout ID for the scheduled timeout.
* @private
*/
function scheduleIdleStateTimeout(idleStateListenersWithDetectionInterval, detectionIntervalInSeconds) {
// Determine how long to delay before firing the listeners
// If the delay is negative, set it to 0 (i.e., fire as soon as possible)
const delayTime = Math.max(lastIdleTime + (detectionIntervalInSeconds * 1000) - timing.now(), 0);
const timeoutId = setTimeout(function() {
for(const idleStateListener of idleStateListenersWithDetectionInterval) {
idleStateListener("idle");
}
}, delayTime);
idleStateTimeouts.set(detectionIntervalInSeconds, timeoutId);
}
/**
* A listener for the `onStateChanged` event.
* @callback idleStateChangeListener
* @memberof module:idle.onStateChanged
* @param {string} idleState - The current idle state: "idle" or "active".
*/
/**
* Add a listener for the `onStateChanged` event.
* @function addListener
* @memberof module:idle.onStateChanged
* @param {idleStateChangeListener} listener - The listener to add.
* @param {Object} options - Options for the listener.
* @param {number} options.detectionInterval - The idle state detection interval
* for the listener, in seconds.
*/
/**
* Remove a listener for the `onStateChanged` event.
* @function removeListener
* @memberof module:idle.onStateChanged
* @param {idleStateChangeListener} listener - The listener to remove.
*/
/**
* Whether a specified listener for the `onStateChanged` event has been added.
* @function hasListener
* @memberof module:idle.onStateChanged
* @param {idleStateChangeListener} listener - The listener to check.
* @returns {boolean} Whether the listener has been added for the event.
*/
/**
* Whether the `onStateChanged` event has any listeners.
* @function hasAnyListeners
* @memberof module:idle.onStateChanged
* @returns {boolean} Whether the event has any listeners.
*/
/**
* An event that fires when the browser's idle state changes. This event supports multiple idle
* detection intervals, unlike the WebExtensions `idle.onStateChanged` event.
* @namespace
*/
export const onStateChanged = events.createEvent({
name: "webScience.idle.onStateChanged",
addListenerCallback: (listener, options) => {
addListener(listener, options.detectionInterval);
},
removeListenerCallback: (listener, options) => {
removeListener(listener, options.detectionInterval);
},
notifyListenersCallback: () => { return false; }
});
/**
* Add a listener for browser idle state.
* @param {idleStateChangeListener} idleStateListener - The listener.
* The function will receive the same `browser.idle.IdleState` parameter
* as if it had subscribed to idle state events with
* `browser.idle.onStateChanged.addListener`.
* @param {number} detectionIntervalInSeconds - The detection
* interval for firing the idle state listener. Note that this
* time in measured in seconds because that is how the `idle`
* API is implemented, even though most times in the library
* are measured in milliseconds.
* @private
*/
async function addListener(idleStateListener, detectionIntervalInSeconds) {
await initialize();
// If we already have at least one idle state listener with this
// detection interval, add the new listener to the Set of listeners
// and we're done
let idleStateListenersWithDetectionInterval = idleStateListeners.get(detectionIntervalInSeconds);
if(idleStateListenersWithDetectionInterval !== undefined) {
idleStateListenersWithDetectionInterval.add(idleStateListener);
return;
}
// Create a Set for listeners with this detection interval, including
// this idle state listener
idleStateListenersWithDetectionInterval = idleStateListeners.set(detectionIntervalInSeconds, (new Set()).add(idleStateListener));
// If we're in idle state, and we have been in the state for less time
// than the detection interval for this listener (i.e., the listener
// should still receive a state change notification), schedule a
// notification
if((currentIdleState === "idle") && (timing.now() < (lastIdleTime + detectionIntervalInSeconds * 1000))) {
scheduleIdleStateTimeout(idleStateListenersWithDetectionInterval, detectionIntervalInSeconds);
}
}
/**
* Remove a listener for browser idle state.
* @param {idleStateChangeListener} idleStateListener - The listener.
* @param {number} detectionIntervalInSeconds - The detection
* interval for firing the idle state listener.
* @private
*/
async function removeListener(idleStateListener, detectionIntervalInSeconds) {
const idleStateListenersWithDetectionInterval = idleStateListeners.get(detectionIntervalInSeconds);
if(idleStateListenersWithDetectionInterval !== undefined) {
// Remove the listener
idleStateListenersWithDetectionInterval.delete(idleStateListener);
// If there are no other listeners with the same detection interval, remove the set of listeners
// for the detection interval and clear the timeout (if there is one) for the interval
if(idleStateListenersWithDetectionInterval.size === 0) {
idleStateListeners.delete(detectionIntervalInSeconds);
const idleStateTimeoutID = idleStateTimeouts.get(detectionIntervalInSeconds);
if(idleStateTimeoutID !== undefined) {
clearTimeout(idleStateTimeoutID);
idleStateTimeouts.delete(detectionIntervalInSeconds);
}
}
}
}