Source: idle.js

/**
 * 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);
            } 
        }
    }
}