From 212e098da172b14e937cf41085515854c3296ac1 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Fri, 28 Jun 2024 16:46:17 +0100 Subject: [PATCH] timeLimitsManager: Add new state machine for screen time limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This implements wellbeing screen time limits in gnome-shell. It depends on a few changes in other modules: - New settings schemas in gsettings-desktop-schemas - A settings UI in gnome-control-center - User documentation in gnome-user-docs It implements the design from https://gitlab.gnome.org/Teams/Design/settings-mockups/-/blob/master/wellbeing/wellbeing.png. The core of the implementation is `TimeLimitsManager`, which is a state machine which uses the user’s session state from logind to track how long the user has been in an active session, in aggregate, during the day. If this total exceeds their limit for the day, the state machine changes state. The user’s session activity history (basically, when they logged in and out for the past 14 weeks) is kept in a state file in their home directory. This is used by gnome-shell to count usage across reboots in a single day, and in the future it will also be used to provide usage history in gnome-control-center, so the user can visualise their historic computer usage at a high level, for the past several weeks. The `TimeLimitsDispatcher` is based on top of this, and controls showing notifications and screen fades to make the user aware of whether they’ve used the computer for too long today, as per their preferences. Unit tests are included to check that `TimeLimitsManager` works, in particular with its loading and storing of the history file. The unit tests provide mock implementations of basic GLib clock functions, the logind D-Bus proxy and `Gio.Settings` in order to test the state machine in faster-than-real-time. Signed-off-by: Philip Withnall See: https://gitlab.gnome.org/Teams/Design/initiatives/-/issues/130 Part-of: --- .gitlab-ci.yml | 2 +- js/js-resources.gresource.xml | 1 + js/misc/timeLimitsManager.js | 1020 +++++++++++++++++++++++++++++++ js/ui/main.js | 19 + meson.build | 2 +- po/POTFILES.in | 1 + tests/meson.build | 1 + tests/unit/timeLimitsManager.js | 1020 +++++++++++++++++++++++++++++++ 8 files changed, 2064 insertions(+), 2 deletions(-) create mode 100644 js/misc/timeLimitsManager.js create mode 100644 tests/unit/timeLimitsManager.js diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 579afd0be..0fd66f853 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -44,7 +44,7 @@ default: - 'api_failure' variables: - MUTTER_CI_IMAGE: registry.gitlab.gnome.org/gnome/mutter/fedora/41:x86_64-2024-10-15.0 + MUTTER_CI_IMAGE: registry.gitlab.gnome.org/gnome/mutter/fedora/41:x86_64-2024-12-18.0 FDO_UPSTREAM_REPO: GNOME/gnome-shell BUNDLE: "extensions-git.flatpak" LINT_LOG: "eslint-report.xml" diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml index 898c61177..df23e5eb7 100644 --- a/js/js-resources.gresource.xml +++ b/js/js-resources.gresource.xml @@ -41,6 +41,7 @@ misc/signalTracker.js misc/smartcardManager.js misc/systemActions.js + misc/timeLimitsManager.js misc/util.js misc/weather.js diff --git a/js/misc/timeLimitsManager.js b/js/misc/timeLimitsManager.js new file mode 100644 index 000000000..c72dc0b88 --- /dev/null +++ b/js/misc/timeLimitsManager.js @@ -0,0 +1,1020 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +// +// Copyright 2024 GNOME Foundation, Inc. +// +// This is a GNOME Shell component to support screen time limits and statistics. +// +// Licensed under the GNU General Public License Version 2 +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import Clutter from 'gi://Clutter'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import Shell from 'gi://Shell'; + +import * as Gettext from 'gettext'; +import * as LoginManager from './loginManager.js'; +import * as Main from '../ui/main.js'; +import * as MessageTray from '../ui/messageTray.js'; + +export const HISTORY_THRESHOLD_SECONDS = 14 * 7 * 24 * 60 * 60; // maximum time history entries are kept +const LIMIT_UPCOMING_NOTIFICATION_TIME_SECONDS = 10 * 60; // notify the user 10min before their limit is reached +const GRAYSCALE_FADE_TIME_SECONDS = 3; +const GRAYSCALE_SATURATION = 0.0; // saturation ([0.0, 1.0]) when grayscale mode is activated, 1.0 means full color + +/** @enum {number} */ +export const TimeLimitsState = { + /* screen time limits are disabled */ + DISABLED: 0, + /* screen time limits are enabled, but the limit has not been hit yet */ + ACTIVE: 1, + /* limit has been reached */ + LIMIT_REACHED: 2, +}; + +/** + * Return a string form of `timeLimitsState`. + * + * @param {int} timeLimitsState The time limit state. + * @returns {string} A string form of `timeLimitsState`. + */ +export function timeLimitsStateToString(timeLimitsState) { + return Object.keys(TimeLimitsState).find(k => TimeLimitsState[k] === timeLimitsState); +} + +/** @enum {number} */ +/* This enum is used in the saved state file, so is value mapping cannot be + * changed. */ +export const UserState = { + INACTIVE: 0, + ACTIVE: 1, +}; + +/** + * Return a string form of `userState`. + * + * @param {int} userState The user state. + * @returns {string} A string form of `userState`. + */ +function userStateToString(userState) { + return Object.keys(UserState).find(k => UserState[k] === userState); +} + +/** + * A manager class which tracks total active/inactive time for a user, and + * signals when the user has reached their daily time limit for actively using + * the device. + * + * Active/Inactive time is based off the total time the user account has spent + * logged in to at least one active session, not idle (and not locked, but + * that’s a subset of idle time). + * This corresponds to the `active` state from sd_uid_get_state() + * (https://www.freedesktop.org/software/systemd/man/latest/sd_uid_get_state.html), + * plus `IdleHint` from + * https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html#User Objects. + * ‘Inactive’ time corresponds to all the other states from sd_uid_get_state(), + * or if `IdleHint` is true. + * + * All times within the class are handled in terms of wall/real clock time, + * rather than monotonic time. This is because it’s necessary to continue + * counting time while the process or device is suspended. If the real clock + * is adjusted (e.g. as a result of an NTP sync) then everything has to be + * recalculated. See `this._timeChangeId`. + */ +export const TimeLimitsManager = GObject.registerClass({ + Properties: { + 'state': GObject.ParamSpec.int( + 'state', null, null, + GObject.ParamFlags.READABLE, + TimeLimitsState.DISABLED, TimeLimitsState.LIMIT_REACHED, TimeLimitsState.DISABLED), + 'daily-limit-time': GObject.ParamSpec.uint64( + 'daily-limit-time', null, null, + GObject.ParamFlags.READABLE, + 0, GLib.MAX_UINT64, 0), + 'grayscale-enabled': GObject.ParamSpec.boolean( + 'grayscale-enabled', null, null, + GObject.ParamFlags.READABLE, + false), + }, + Signals: { + 'daily-limit-reached': {}, + }, +}, class TimeLimitsManager extends GObject.Object { + constructor(historyFile, clock, loginUserFactory, settingsFactory) { + super(); + + // Allow these few bits of global state to be overridden for unit testing + const defaultHistoryFilePath = `${global.userdatadir}/session-active-history.json`; + this._historyFile = historyFile ?? Gio.File.new_for_path(defaultHistoryFilePath); + + this._clock = clock ?? { + sourceRemove: GLib.source_remove, + getRealTimeSecs: () => { + return Math.floor(GLib.get_real_time() / GLib.USEC_PER_SEC); + }, + getMonotonicTimeSecs: () => { + return Math.floor(GLib.get_monotonic_time() / GLib.USEC_PER_SEC); + }, + timeoutAddSeconds: GLib.timeout_add_seconds, + timeChangeNotify: callback => { + const timeChangeSource = Shell.time_change_source_new(); + timeChangeSource.set_callback(callback); + return timeChangeSource.attach(null); + }, + }; + this._loginUserFactory = loginUserFactory ?? { + newAsync: () => { + const loginManager = LoginManager.getLoginManager(); + return loginManager.getCurrentUserProxy(); + }, + }; + this._settingsFactory = settingsFactory ?? { + new: Gio.Settings.new, + }; + this._screenTimeLimitSettings = this._settingsFactory.new('org.gnome.desktop.screen-time-limits'); + this._screenTimeLimitSettings.connectObject( + 'changed', () => this._updateSettings(), + 'changed::daily-limit-seconds', () => this.notify('daily-limit-time'), + 'changed::grayscale', () => this.notify('grayscale-enabled'), + this); + + this._state = TimeLimitsState.DISABLED; + this._stateTransitions = []; + this._cancellable = null; + this._loginUser = null; + this._lastStateChangeTimeSecs = 0; + this._timerId = 0; + this._timeChangeId = 0; + this._clockOffsetSecs = 0; + + // Start tracking timings + this._updateSettings(); + } + + _updateSettings() { + if (!this._screenTimeLimitSettings.get_boolean('enabled')) { + this._stopStateMachine().catch( + e => console.warn(`Failed to stop state machine: ${e.message}`)); + return false; + } + + // If this is the first time _updateSettings() has been called, start + // the state machine. + if (this._state === TimeLimitsState.DISABLED) { + this._startStateMachine().catch( + e => console.warn(`Failed to start state machine: ${e.message}`)); + } else { + this._updateState(); + } + + return true; + } + + async _startStateMachine() { + // Start off active because gnome-shell is started inside the user’s + // session, so by the time we get to this code, the user should be active. + this._userState = UserState.ACTIVE; + this._stateTransitions = []; + this._state = TimeLimitsState.ACTIVE; + this._cancellable = new Gio.Cancellable(); + + this.freeze_notify(); + + // Load the previously saved transitions. Discard any entries older than + // the threshold, since we don’t care about historical data. + try { + await this._loadTransitions(); + } catch (e) { + // Warn on failure, but carry on anyway. + console.warn(`Failed to load screen time limits data: ${e.message}`); + } + + // Add a fake transition to show the startup. + if (this._stateTransitions.length === 0 || + this._userState !== UserState.ACTIVE) { + const nowSecs = this.getCurrentTime(); + this._addTransition(UserState.INACTIVE, UserState.ACTIVE, nowSecs); + } + + // Start listening for clock change notifications. + this._clockOffsetSecs = this._getClockOffset(); + try { + this._timeChangeId = this._clock.timeChangeNotify(() => { + const newClockOffsetSecs = this._getClockOffset(); + const oldClockOffsetSecs = this._clockOffsetSecs; + + console.debug('TimeLimitsManager: System clock changed, ' + + `old offset ${oldClockOffsetSecs}s, new offset ${newClockOffsetSecs}s`); + + if (newClockOffsetSecs === oldClockOffsetSecs) + return GLib.SOURCE_CONTINUE; + + this._adjustAllTimes(newClockOffsetSecs - oldClockOffsetSecs); + this._clockOffsetSecs = newClockOffsetSecs; + + this._storeTransitions().catch( + e => console.warn(`Failed to store screen time limits data: ${e.message}`)); + + this.freeze_notify(); + this.notify('daily-limit-time'); + this._updateState(); + this.thaw_notify(); + + return GLib.SOURCE_CONTINUE; + }); + } catch (e) { + console.warn(`Failed to listen for system clock changes: ${e.message}`); + this._timeChangeId = 0; + } + + // Start listening for notifications to the user’s state. + this._loginUser = await this._loginUserFactory.newAsync(); + this._loginUser.connectObject( + 'g-properties-changed', + () => this._updateUserState(true).catch( + e => console.warn(`Failed to update user state: ${e.message}`)), + this); + await this._updateUserState(false); + this._updateState(); + + this.thaw_notify(); + } + + async _stopStateMachine() { + if (this._timeChangeId !== 0) + this._clock.sourceRemove(this._timeChangeId); + this._timeChangeId = 0; + this._clockOffsetSecs = 0; + + if (this._timerId !== 0) + this._clock.sourceRemove(this._timerId); + this._timerId = 0; + + this._loginUser?.disconnectObject(this); + this._loginUser = null; + + this._state = TimeLimitsState.DISABLED; + this._lastStateChangeTimeSecs = 0; + this.notify('state'); + + // Add a fake transition to show the shutdown. + if (this._userState !== UserState.INACTIVE) { + const nowSecs = this.getCurrentTime(); + this._addTransition(UserState.ACTIVE, UserState.INACTIVE, nowSecs); + } + + try { + await this._storeTransitions(); + } catch (e) { + console.warn(`Failed to store screen time limits data: ${e.message}`); + } + + // Make sure no async operations are still pending. + this._cancellable?.cancel(); + this._cancellable = null; + } + + /** Shut down the state machine and write out the state file. */ + async shutdown() { + await this._stopStateMachine(); + } + + /** + * Get the current real time, in seconds since the Unix epoch. + * + * @returns {number} + */ + getCurrentTime() { + return this._clock.getRealTimeSecs(); + } + + _getClockOffset() { + return this._clock.getRealTimeSecs() - this._clock.getMonotonicTimeSecs(); + } + + /** + * Adjust all the stored real/wall clock times by +`offsetSecs`. + * + * This is used when the system real clock changes with respect to the + * monotonic clock (for example, after an NTP synchronisation). At that + * point, all of the stored real/wall clock times have a constant non-zero + * offset to the new real/wall clock time, which leads to incorrect + * calculations of daily usage. Hence, they need to be adjusted. + * + * We can do this while the system is running. If the clock offset changes + * while offline (for example, while another user is logged in instead), we + * can’t do anything about it and will just have to skip erroneous old state + * until everything eventually gets updated to the new clock by the passage + * of time. + * + * It’s recommended to call `storeTransitions()` after this, as the + * in-memory list of transitions is adjusted. + * + * @param {number} offsetSecs The number of seconds to adjust times by; may be negative. + */ + _adjustAllTimes(offsetSecs) { + console.assert(this._state !== TimeLimitsState.DISABLED, + 'Time limits should not be disabled when adjusting times'); + + console.debug(`TimeLimitsManager: Adjusting all times by ${offsetSecs}s`); + + for (let i = 0; i < this._stateTransitions.length; i++) + this._stateTransitions[i].wallTimeSecs += offsetSecs; + + if (this._lastStateChangeTimeSecs !== 0) + this._lastStateChangeTimeSecs += offsetSecs; + + this._clockOffsetSecs += offsetSecs; + } + + _calculateUserStateFromLogind() { + const isActive = this._loginUser.State === 'active' && !this._loginUser.IdleHint; + return isActive ? UserState.ACTIVE : UserState.INACTIVE; + } + + async _updateUserState(storeUpdates) { + const oldState = this._userState; + const newState = this._calculateUserStateFromLogind(); + + if (oldState === newState) + return; + + const nowSecs = this.getCurrentTime(); + this._addTransition(oldState, newState, nowSecs); + if (storeUpdates) { + try { + await this._storeTransitions(); + } catch (e) { + console.warn(`Failed to store screen time limits data: ${e.message}`); + } + } + } + + _addTransition(oldState, newState, wallTimeSecs, recalculateState = true) { + this._stateTransitions.push({ + oldState, + newState, + wallTimeSecs, + }); + + this._userState = newState; + + console.debug('TimeLimitsManager: User state changed from ' + + `${userStateToString(oldState)} to ${userStateToString(newState)} at ${wallTimeSecs}s`); + + // This potentially changed the limit time and timeout calculations. + if (recalculateState && this._state !== TimeLimitsState.DISABLED) { + this.freeze_notify(); + this.notify('daily-limit-time'); + this._updateState(); + this.thaw_notify(); + } + } + + /** + * Load the transitions JSON file. + * + * The format is a top-level array. Each array element is an object + * containing exactly the following elements: + * - `oldState`: integer value from `UserState` + * - `newState`: integer value from `UserState` + * - `wallTimeSecs`: number of seconds since the UNIX epoch at which the + * state transition happened + * + * `oldState` and `newState` must not be equal. `wallTimeSecs` must + * monotonically increase from one array element to the next, and must be a + * [‘safe integer’](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isSafeInteger). + * + * The use of wall/real time in the file means its contents become invalid + * if the system real time clock changes (relative to the monotonic clock). + */ + async _loadTransitions() { + const file = this._historyFile; + + let contents; + try { + const [, encodedContents] = await file.load_contents(this._cancellable); + contents = new TextDecoder().decode(encodedContents); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) + throw e; + return; + } + + const history = JSON.parse(contents); + + if (!Array.isArray(history)) + throw new Error(`${file.get_path()} does not contain an array`); + + const nowSecs = this.getCurrentTime(); + const validStates = Object.values(UserState); + let previousWallTimeSecs = 0; + + this.freeze_notify(); + + for (let i = 0; i < history.length; i++) { + const entry = history[i]; + + if (typeof entry !== 'object' || + entry === null || + !('oldState' in entry) || + !('newState' in entry) || + !('wallTimeSecs' in entry) || + !validStates.includes(entry['oldState']) || + !validStates.includes(entry['newState']) || + entry['oldState'] === entry['newState'] || + typeof entry['wallTimeSecs'] !== 'number' || + !Number.isSafeInteger(entry['wallTimeSecs']) || + entry['wallTimeSecs'] < previousWallTimeSecs) { + this.thaw_notify(); + throw new Error(`Malformed entry (index ${i}) in ${file.get_path()}`); + } + + // Skip entries older than the threshold, but always keep at least one. + if (!this._filterOldTransition(entry, i, history, nowSecs)) + continue; + + this._addTransition( + entry['oldState'], + entry['newState'], + entry['wallTimeSecs'], + i === history.length - 1); + previousWallTimeSecs = entry['wallTimeSecs']; + } + + this.thaw_notify(); + } + + /** + * Callback for Array.filter() to remove old entries from the transition array, + * making sure to always keep the last one as a starting point for the future. + * + * Always drop entries which are in the future (even if that would remove + * all history) as they can only exist as a result of file corruption or the + * clock offset changing while we were offline. There’s nothing useful we + * can recover from them. + * + * @param {object} entry Transition entry. + * @param {number} idx Index of `entry` in `transitionsArray`. + * @param {Array} transitionsArray Transitions array. + * @param {number} nowSecs ‘Current’ time to calculate from. + * @returns {boolean} True to keep the entry, false to drop it. + */ + _filterOldTransition(entry, idx, transitionsArray, nowSecs) { + return entry['wallTimeSecs'] <= nowSecs && + (entry['wallTimeSecs'] >= nowSecs - HISTORY_THRESHOLD_SECONDS || + idx === transitionsArray.length - 1); + } + + async _storeTransitions() { + const file = this._historyFile; + const nowSecs = this.getCurrentTime(); + + console.debug(`TimeLimitsManager: Storing screen time limits data to ‘${file.peek_path()}’`); + + // Trim the transitions array to drop old history. + this._stateTransitions = this._stateTransitions.filter( + (e, i, a) => this._filterOldTransition(e, i, a, nowSecs)); + + // Filter the transitions pairwise to remove consecutive pairs where the + // wallTimeSecs are equal and the states cancel each other out. This + // can happen if the user rapidly toggles screen time support on and + // off. + var newTransitions = []; + for (var i = 0; i < this._stateTransitions.length; i++) { + const first = this._stateTransitions[i]; + const second = this._stateTransitions[i + 1] ?? null; + + if (second === null || + !(first['wallTimeSecs'] === second['wallTimeSecs'] && + first['oldState'] === second['newState'])) + newTransitions.push(first); + } + + this._stateTransitions = newTransitions; + + if (this._stateTransitions.length === 0) { + try { + await file.delete(this._cancellable); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) + throw e; + } + } else { + await file.replace_contents( + JSON.stringify(this._stateTransitions), null, false, + Gio.FileCreateFlags.PRIVATE, this._cancellable); + } + } + + /** + * Get the Unix timestamp (in real seconds since the epoch) for the start of + * today and the start of tomorrow. + * + * To avoid problems due to 24:00-01:00 not existing on leap days, + * arbitrarily say that the day starts at 03:00. + * + * @param {number} nowSecs ‘Current’ time to calculate from. + * @returns {number} + */ + _getStartOfTodaySecs(nowSecs) { + const nowDate = GLib.DateTime.new_from_unix_local(nowSecs); + let startOfTodayDate = GLib.DateTime.new_local( + nowDate.get_year(), + nowDate.get_month(), + nowDate.get_day_of_month(), + 3, 0, 0); + + // The downside of using 03:00 is that we need to adjust if nowSecs is + // in the range [24:00–03:00). + if (nowDate.compare(startOfTodayDate) < 0) + startOfTodayDate = startOfTodayDate.add_days(-1); + + const startOfTomorrowDate = startOfTodayDate.add_days(1); + const startOfTodaySecs = startOfTodayDate.to_unix(); + const startOfTomorrowSecs = startOfTomorrowDate.to_unix(); + + console.assert(startOfTodaySecs <= nowSecs, 'Start of today in the future'); + console.assert(startOfTomorrowSecs > nowSecs, 'Start of tomorrow not in the future'); + + return [startOfTodaySecs, startOfTomorrowSecs]; + } + + /** + * Work out how much time the user has spent at the screen today, in real + * clock seconds. + * + * This is probably an under-estimate, because it uses the systemd-logind + * ‘active’ user time, which is based on when the user’s logged in rather + * than necessarily when they’re actively moving the mouse. Tracking the + * latter is a lot more work, doesn’t take into account time spent in other + * login sessions (e.g. KDE) and could be considered more invasive. + * + * If the system clock changes, this will become inaccurate until the set of + * state transitions catches up. It will never return a negative number, + * though. + * + * @param {number} nowSecs ‘Current’ time to calculate from. + * @param {number} startOfTodaySecs Time for the start of today. + * @returns {number} + */ + _calculateActiveTimeTodaySecs(nowSecs, startOfTodaySecs) { + // NOTE: This might return -1. + const firstTransitionTodayIdx = this._stateTransitions.findIndex(e => e['wallTimeSecs'] >= startOfTodaySecs); + + let activeTimeTodaySecs = 0; + // In case the first transition is active → inactive, or is unset. + let activeStartTimeSecs = startOfTodaySecs; + + for (let i = firstTransitionTodayIdx; firstTransitionTodayIdx !== -1 && i < this._stateTransitions.length; i++) { + if (this._stateTransitions[i]['newState'] === UserState.ACTIVE) + activeStartTimeSecs = this._stateTransitions[i]['wallTimeSecs']; + else if (this._stateTransitions[i]['oldState'] === UserState.ACTIVE) + activeTimeTodaySecs += Math.max(this._stateTransitions[i]['wallTimeSecs'] - activeStartTimeSecs, 0); + } + + if (this._stateTransitions.length > 0 && + this._stateTransitions.at(-1)['newState'] === UserState.ACTIVE) + activeTimeTodaySecs += Math.max(nowSecs - activeStartTimeSecs, 0); + + console.assert(activeTimeTodaySecs >= 0, + 'Active time today should be non-negative even if system clock has changed'); + + return activeTimeTodaySecs; + } + + /** + * Work out the timestamp at which the daily limit was reached. + * + * If the user has not reached the daily limit yet today, this will return 0. + * + * @param {number} nowSecs ‘Current’ time to calculate from. + * @param {number} dailyLimitSecs Daily limit in seconds. + * @param {number} startOfTodaySecs Time for the start of today. + * @returns {number} + */ + _calculateDailyLimitReachedAtSecs(nowSecs, dailyLimitSecs, startOfTodaySecs) { + // NOTE: This might return -1. + const firstTransitionTodayIdx = this._stateTransitions.findIndex(e => e['wallTimeSecs'] >= startOfTodaySecs); + + let activeTimeTodaySecs = 0; + // In case the first transition is active → inactive, or is unset. + let activeStartTimeSecs = startOfTodaySecs; + + for (let i = firstTransitionTodayIdx; firstTransitionTodayIdx !== -1 && i < this._stateTransitions.length; i++) { + if (this._stateTransitions[i]['newState'] === UserState.ACTIVE) + activeStartTimeSecs = this._stateTransitions[i]['wallTimeSecs']; + else if (this._stateTransitions[i]['oldState'] === UserState.ACTIVE) + activeTimeTodaySecs += Math.max(this._stateTransitions[i]['wallTimeSecs'] - activeStartTimeSecs, 0); + + if (activeTimeTodaySecs >= dailyLimitSecs) + return this._stateTransitions[i]['wallTimeSecs'] - (activeTimeTodaySecs - dailyLimitSecs); + } + + if (this._stateTransitions.length > 0 && + this._stateTransitions.at(-1)['newState'] === UserState.ACTIVE) + activeTimeTodaySecs += Math.max(nowSecs - activeStartTimeSecs, 0); + + if (activeTimeTodaySecs >= dailyLimitSecs) + return nowSecs - (activeTimeTodaySecs - dailyLimitSecs); + + // Limit not reached yet. + return 0; + } + + _updateState() { + console.assert(this._state !== TimeLimitsState.DISABLED, + 'Time limits should not be disabled when updating timer'); + + const nowSecs = this.getCurrentTime(); + const [startOfTodaySecs, startOfTomorrowSecs] = this._getStartOfTodaySecs(nowSecs); + let newState = this._state; + + // Is it a new day since we last updated the state? If so, reset the + // time limit. + if (startOfTodaySecs > this._lastStateChangeTimeSecs) + newState = TimeLimitsState.ACTIVE; + + // Work out how much time the user has spent at the screen today. + const activeTimeTodaySecs = this._calculateActiveTimeTodaySecs(nowSecs, startOfTodaySecs); + const dailyLimitSecs = this._screenTimeLimitSettings.get_uint('daily-limit-seconds'); + + console.debug('TimeLimitsManager: Active time today: ' + + `${activeTimeTodaySecs}s, daily limit ${dailyLimitSecs}s`); + + if (activeTimeTodaySecs >= dailyLimitSecs) { + newState = TimeLimitsState.LIMIT_REACHED; + + // Schedule an update for when the limit will be reset again. + this._scheduleUpdateState(startOfTomorrowSecs - nowSecs); + } else if (this._userState === UserState.ACTIVE) { + // Schedule an update for when we expect the limit will be reached. + this._scheduleUpdateState(dailyLimitSecs - activeTimeTodaySecs); + } else { + // User is inactive, so no point scheduling anything until they become + // active again. + } + + // Update the saved state. + if (newState !== this._state) { + this._state = newState; + this._lastStateChangeTimeSecs = nowSecs; + this.notify('state'); + this.notify('daily-limit-time'); + + if (newState === TimeLimitsState.LIMIT_REACHED) + this.emit('daily-limit-reached'); + } + } + + _scheduleUpdateState(timeout) { + if (this._timerId !== 0) + this._clock.sourceRemove(this._timerId); + + // Round up to avoid spinning + const timeoutSeconds = Math.ceil(timeout); + + console.debug(`TimeLimitsManager: Scheduling state update in ${timeoutSeconds}s`); + + this._timerId = this._clock.timeoutAddSeconds(GLib.PRIORITY_DEFAULT, timeoutSeconds, () => { + this._timerId = 0; + console.debug('TimeLimitsManager: Scheduled state update'); + this._updateState(); + return GLib.SOURCE_REMOVE; + }); + } + + /** + * Current state machine state. + * + * @type {TimeLimitsState} + */ + get state() { + return this._state; + } + + /** + * The time when the daily limit will be reached. If the user is currently + * active, and has not reached the limit, this is a non-zero value in the + * future. If the user has already reached the limit, this is the time when + * the limit was reached. If the user is inactive, and has not reached the + * limit, or if time limits are disabled, this is zero. + * It’s measured in real time seconds. + * + * @type {number} + */ + get dailyLimitTime() { + switch (this._state) { + case TimeLimitsState.DISABLED: + return 0; + case TimeLimitsState.ACTIVE: { + const nowSecs = this.getCurrentTime(); + const [startOfTodaySecs] = this._getStartOfTodaySecs(nowSecs); + const activeTimeTodaySecs = this._calculateActiveTimeTodaySecs(nowSecs, startOfTodaySecs); + const dailyLimitSecs = this._screenTimeLimitSettings.get_uint('daily-limit-seconds'); + + console.assert(dailyLimitSecs >= activeTimeTodaySecs, 'Active time unexpectedly high'); + + if (this._userState === UserState.ACTIVE) + return nowSecs + (dailyLimitSecs - activeTimeTodaySecs); + else + return 0; + } + case TimeLimitsState.LIMIT_REACHED: { + const nowSecs = this.getCurrentTime(); + const [startOfTodaySecs] = this._getStartOfTodaySecs(nowSecs); + const dailyLimitSecs = this._screenTimeLimitSettings.get_uint('daily-limit-seconds'); + const dailyLimitReachedAtSecs = this._calculateDailyLimitReachedAtSecs(nowSecs, dailyLimitSecs, startOfTodaySecs); + + console.assert(dailyLimitReachedAtSecs > 0, + 'Daily limit reached-at unexpectedly low'); + + return dailyLimitReachedAtSecs; + } + default: + console.assert(false, `Unexpected state ${this._state}`); + return 0; + } + } + + /** + * Whether the screen should be made grayscale once the daily limit is + * reached. + * + * @type {boolean} + */ + get grayscaleEnabled() { + return this._screenTimeLimitSettings.get_boolean('grayscale'); + } +}); + +/** + * Glue class which takes the state-based output from TimeLimitsManager and + * converts it to event-based notifications for the user to tell them + * when their time limit has been reached. It factors the user’s UI preferences + * into account. + */ +export const TimeLimitsDispatcher = GObject.registerClass( +class TimeLimitsDispatcher extends GObject.Object { + constructor(manager) { + super(); + + this._manager = manager; + this._manager.connectObject( + 'notify::state', this._onStateChanged.bind(this), + 'notify::grayscale-enabled', this._onStateChanged.bind(this), + this); + + this._notificationSource = null; + this._desaturationEffect = null; + + if (this._manager.state === TimeLimitsState.DISABLED) + this._ensureDisabled(); + else + this._ensureEnabled(); + } + + destroy() { + this._ensureDisabled(); + + this._manager.disconnectObject(this); + this._manager = null; + } + + _ensureEnabled() { + if (this._notificationSource === null) + this._notificationSource = new TimeLimitsNotificationSource(this._manager); + + if (this._desaturationEffect === null) { + this._desaturationEffect = new Clutter.DesaturateEffect({name: 'desaturate'}); + this._desaturationEffect.set_enabled(false); + Main.layoutManager.uiGroup.add_effect(this._desaturationEffect); + Main.layoutManager.uiGroup.connect('destroy', () => (this._desaturationEffect = null)); + } + } + + _ensureDisabled() { + this._notificationSource?.destroy(); + this._notificationSource = null; + + if (this._desaturationEffect !== null) + Main.layoutManager.uiGroup.remove_effect(this._desaturationEffect); + this._desaturationEffect = null; + } + + _onStateChanged() { + switch (this._manager.state) { + case TimeLimitsState.DISABLED: + this._ensureDisabled(); + break; + + case TimeLimitsState.ACTIVE: { + this._ensureEnabled(); + this._desaturationEffect.set_enabled(false); + + break; + } + + case TimeLimitsState.LIMIT_REACHED: { + this._ensureEnabled(); + + if (this._manager.grayscaleEnabled) { + this._desaturationEffect.set_enabled(true); + Main.layoutManager.uiGroup.ease_property( + '@effects.desaturate.factor', 1.0 - GRAYSCALE_SATURATION, + { + duration: GRAYSCALE_FADE_TIME_SECONDS * 1000 || 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } else { + this._desaturationEffect.set_enabled(false); + } + + break; + } + default: + console.assert(false, `Unknown TimeLimitsManager state: ${this._manager.state}`); + break; + } + } +}); + +/* This can’t directly extend MessageTray.Source (even though it conceptually + * does) because the Shell will automatically destroy a MessageTray.Source when + * it becomes empty, and that destroys the state which we need for correctly + * showing the next notification (e.g. on a timer). + * + * So instead this is a normal GObject which closely wraps a MessageTray.Source. + */ +const TimeLimitsNotificationSource = GObject.registerClass( +class TimeLimitsNotificationSource extends GObject.Object { + constructor(manager) { + super(); + + this._app = Shell.AppSystem.get_default().lookup_app('gnome-wellbeing-panel.desktop'); + this._source = null; + + this._notification = null; + this._timerId = 0; + this._manager = manager; + this._manager.connectObject( + 'notify::state', this._onStateChanged.bind(this), + 'notify::daily-limit-time', this._onStateChanged.bind(this), + this); + + this._previousState = TimeLimitsState.DISABLED; + this._updateState(); + } + + destroy() { + this._notification?.destroy(); + this._notification = null; + + this._source?.destroy(); + this._source = null; + + if (this._timerId !== 0) + GLib.source_remove(this._timerId); + this._timerId = 0; + + this._manager?.disconnectObject(this); + this._manager = null; + + this.run_dispose(); + } + + _ensureNotification(params) { + if (!this._source) { + this._source = new MessageTray.Source({ + title: this._app.get_name(), + icon: this._app.get_icon(), + policy: MessageTray.NotificationPolicy.newForApp(this._app), + }); + this._source.connect('destroy', () => (this._source = null)); + Main.messageTray.add(this._source); + } + + if (this._notification === null) { + this._notification = new TimeLimitsNotification(this._source); + this._notification.connect('destroy', () => (this._notification = null)); + } + + // Unacknowledge the notification when it’s updated, by default. + this._notification.set({acknowledged: false, ...params}); + } + + _onStateChanged() { + this._updateState(); + + this._previousState = this._manager.state; + } + + _scheduleUpdateState(timeout) { + if (this._timerId !== 0) + GLib.source_remove(this._timerId); + + // Round up to avoid spinning + const timeoutSeconds = Math.ceil(timeout); + + console.debug(`TimeLimitsNotificationSource: Scheduling notification state update in ${timeoutSeconds}s`); + + this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, timeoutSeconds, () => { + this._timerId = 0; + console.debug('TimeLimitsNotificationSource: Scheduled notification state update'); + this._updateState(); + return GLib.SOURCE_REMOVE; + }); + } + + _updateState() { + const currentTime = this._manager.getCurrentTime(); + + console.debug('TimeLimitsNotificationSource: Time limits notification ' + + 'source state changed from ' + + `${timeLimitsStateToString(this._previousState)} ` + + `to ${timeLimitsStateToString(this._manager.state)}`); + + switch (this._manager.state) { + case TimeLimitsState.DISABLED: + this.destroy(); + break; + + case TimeLimitsState.ACTIVE: { + // Work out when the time limit will be, and display some warnings + // that it’s impending. + const limitDueTime = this._manager.dailyLimitTime; + const remainingSecs = limitDueTime - currentTime; + console.debug(`TimeLimitsNotificationSource: ${remainingSecs}s left before limit is reached`); + + if (remainingSecs > LIMIT_UPCOMING_NOTIFICATION_TIME_SECONDS) { + // Schedule to show a notification when the upcoming notification + // time is reached. + this._scheduleUpdateState(remainingSecs - LIMIT_UPCOMING_NOTIFICATION_TIME_SECONDS); + break; + } else if (Math.ceil(remainingSecs) === LIMIT_UPCOMING_NOTIFICATION_TIME_SECONDS) { + // Bang on time to show this notification. + const remainingMinutes = Math.floor(LIMIT_UPCOMING_NOTIFICATION_TIME_SECONDS / 60); + const titleText = Gettext.ngettext( + 'Screen Time Limit in %d Minute', + 'Screen Time Limit in %d Minutes', + remainingMinutes + ).format(remainingMinutes); + + this._ensureNotification({ + title: titleText, + body: _('Your screen time limit is approaching'), + urgency: MessageTray.Urgency.HIGH, + }); + this._source.addNotification(this._notification); + } + + break; + } + + case TimeLimitsState.LIMIT_REACHED: { + // Notify the user that they’ve reached their limit, when we + // transition from any state to LIMIT_REACHED. + if (this._previousState !== TimeLimitsState.LIMIT_REACHED) { + this._ensureNotification({ + title: _('Screen Time Limit Reached'), + body: _('It’s time to stop using the device'), + urgency: MessageTray.Urgency.HIGH, + }); + this._source.addNotification(this._notification); + } + + break; + } + + default: + console.assert(false, `Unknown TimeLimitsManager state: ${this._manager.state}`); + break; + } + } +}); + +const TimeLimitsNotification = GObject.registerClass( +class TimeLimitsNotification extends MessageTray.Notification { + constructor(source) { + super({ + source, + resident: true, + }); + } +}); diff --git a/js/ui/main.js b/js/ui/main.js index 769ef4c4c..c8de7fc65 100644 --- a/js/ui/main.js +++ b/js/ui/main.js @@ -37,6 +37,7 @@ import * as ScreenShield from './screenShield.js'; import * as SessionMode from './sessionMode.js'; import * as ShellDBus from './shellDBus.js'; import * as ShellMountOperation from './shellMountOperation.js'; +import * as TimeLimitsManager from '../misc/timeLimitsManager.js'; import * as WindowManager from './windowManager.js'; import * as Magnifier from './magnifier.js'; import * as XdndHandler from './xdndHandler.js'; @@ -93,6 +94,8 @@ export let endSessionDialog = null; export let breakManager = null; export let screenTimeDBus = null; export let breakManagerDispatcher = null; +export let timeLimitsManager = null; +export let timeLimitsDispatcher = null; let _startDate; let _defaultCssStylesheet = null; @@ -250,8 +253,24 @@ async function _initializeUI() { // Set up the global default break reminder manager and its D-Bus interface breakManager = new BreakManager.BreakManager(); + timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(); screenTimeDBus = new ShellDBus.ScreenTimeDBus(breakManager); breakManagerDispatcher = new BreakManager.BreakDispatcher(breakManager); + timeLimitsDispatcher = new TimeLimitsManager.TimeLimitsDispatcher(timeLimitsManager); + + global.connect('shutdown', () => { + // Block shutdown until the session history file has been written + const loop = new GLib.MainLoop(null, false); + const source = GLib.idle_source_new(); + source.set_callback(() => { + timeLimitsManager.shutdown() + .catch(e => console.warn(`Failed to stop time limits manager: ${e.message}`)) + .finally(() => loop.quit()); + return GLib.SOURCE_REMOVE; + }); + source.attach(loop.get_context()); + loop.run(); + }); layoutManager.init(); overview.init(); diff --git a/meson.build b/meson.build index 843aff291..87c294df8 100644 --- a/meson.build +++ b/meson.build @@ -27,7 +27,7 @@ gjs_req = '>= 1.73.1' gtk_req = '>= 4.0' mutter_req = '>= 47.0' polkit_req = '>= 0.100' -schemas_req = '>= 47.beta' +schemas_req = '>= 48.alpha' systemd_req = '>= 246' gnome_desktop_req = '>= 40' pipewire_req = '>= 0.3.49' diff --git a/po/POTFILES.in b/po/POTFILES.in index 25abaeb60..c3cfaaf4b 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -14,6 +14,7 @@ js/gdm/loginDialog.js js/gdm/util.js js/misc/breakManager.js js/misc/systemActions.js +js/misc/timeLimitsManager.js js/misc/util.js js/misc/dateUtils.js js/portalHelper/main.js diff --git a/tests/meson.build b/tests/meson.build index c7faa1129..1d2c220c9 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -29,6 +29,7 @@ unit_tests = [ 'markup', 'params', 'signalTracker', + 'timeLimitsManager', 'url', 'versionCompare', ] diff --git a/tests/unit/timeLimitsManager.js b/tests/unit/timeLimitsManager.js new file mode 100644 index 000000000..55d52d697 --- /dev/null +++ b/tests/unit/timeLimitsManager.js @@ -0,0 +1,1020 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +// +// Copyright 2024 GNOME Foundation, Inc. +// +// This is a GNOME Shell component to support screen time limits and statistics. +// +// Licensed under the GNU General Public License Version 2 +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import 'resource:///org/gnome/shell/ui/environment.js'; +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; + +import * as TimeLimitsManager from 'resource:///org/gnome/shell/misc/timeLimitsManager.js'; + +// Convenience aliases +const {TimeLimitsState, UserState} = TimeLimitsManager; + +/** + * A harness for testing the `TimeLimitsManager` class. It simulates the passage + * of time, maintaining an internal ordered queue of events, and providing three + * groups of mock functions which the `TimeLimitsManager` uses to interact with + * it: mock versions of GLib’s clock and timeout functions, a mock proxy of the + * logind `User` D-Bus object, and a mock version of `Gio.Settings`. + * + * The internal ordered queue of events is sorted by time (in real/wall clock + * seconds, i.e. UNIX timestamps). On each _tick(), the next event is shifted + * off the head of the queue and processed. An event might be a simulated user + * state change (mocking the user starting or stopping a session), a scheduled + * timeout, or an assertion function for actually running test assertions. + * + * The simulated clock jumps from the scheduled time of one event to the + * scheduled time of the next. This way, we can simulate half an hour of active + * time (simulating usage) on the device instantly. + * + * Times are provided as ISO 8601 date/time strings, to allow tests which span + * multiple days to be written more easily. This simplifies things because the + * daily time limit is reset at a specific time each morning. + */ +class TestHarness { + constructor(settings, historyFileContents = null) { + this._currentTimeSecs = 0; + this._clockOffset = 100; // make the monotonic clock lag by 100s, arbitrarily + this._nextSourceId = 1; + this._events = []; + this._timeChangeNotify = null; + this._timeChangeNotifySourceId = 0; + this._settings = settings; + this._settingsChangedCallback = null; + this._settingsChangedDailyLimitSecondsCallback = null; + this._settingsChangedGrayscaleCallback = null; + + // These two emulate relevant bits of the o.fdo.login1.User API + // See https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html#User%20Objects + this._currentUserState = 'active'; + this._currentUserIdleHint = false; + + this._loginUserPropertiesChangedCallback = null; + + // Create a fake history file containing the given contents. Or, if no + // contents are given, reserve a distinct new history file name but then + // delete it so it doesn’t exist for the manager. + const [file, stream] = Gio.File.new_tmp('gnome-shell-time-limits-manager-test-XXXXXX.json'); + if (historyFileContents) + stream.output_stream.write_bytes(new GLib.Bytes(historyFileContents), null); + stream.close(null); + if (!historyFileContents) + file.delete(null); + + this._historyFile = file; + + // And a mock D-Bus proxy for logind. + const harness = this; + class MockLoginUser { + connectObject(signalName, callback, unusedObject) { + if (signalName === 'g-properties-changed') { + if (harness._loginUserPropertiesChangedCallback !== null) + fail('Duplicate g-properties-changed connection'); + harness._loginUserPropertiesChangedCallback = callback; + } else { + // No-op for mock purposes + } + } + + disconnectObject(unused) { + // Very simple implementation for mock purposes + harness._loginUserPropertiesChangedCallback = null; + } + + get State() { + return harness._currentUserState; + } + + get IdleHint() { + return harness._currentUserIdleHint; + } + } + + this._mockLoginUser = new MockLoginUser(); + } + + _cleanup() { + try { + this._historyFile?.delete(null); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) + throw e; + } + } + + _allocateSourceId() { + const sourceId = this._nextSourceId; + this._nextSourceId++; + return sourceId; + } + + _removeEventBySourceId(sourceId) { + const idx = this._events.findIndex(a => { + return a.sourceId === sourceId; + }); + + if (idx === -1) + fail(`Removing non-existent source with ID ${sourceId}`); + + this._events.splice(idx, 1); + } + + _insertEvent(event) { + if (event.time < this._currentTimeSecs) + fail(`Event ${event} cannot be before current mock clock time (${event.time} vs ${this._currentTimeSecs}`); + + this._events.push(event); + this._events.sort((a, b) => { + return a.time - b.time; + }); + return event; + } + + /** + * Convert an ISO 8601 string to a UNIX timestamp for use in tests. + * + * Internally, the tests are all based on UNIX timestamps using wall clock + * time. Those aren’t very easy to reason about when reading or writing + * tests though, so we allow the tests to be written using ISO 8601 strings. + * + * @param {string} timeStr Date/Time in ISO 8601 format. + */ + static timeStrToSecs(timeStr) { + const dt = GLib.DateTime.new_from_iso8601(timeStr, null); + if (dt === null) + fail(`Time string ‘${timeStr}’ could not be parsed`); + return dt.to_unix(); + } + + /** + * Inverse of `timeStrToSecs()`. + * + * @param {number} timeSecs UNIX real/wall clock time in seconds. + */ + _timeSecsToStr(timeSecs) { + const dt = GLib.DateTime.new_from_unix_utc(timeSecs); + if (dt === null) + fail(`Time ‘${timeSecs}’ could not be represented`); + return dt.format_iso8601(); + } + + /** + * Add a timeout event to the event queue. It will be scheduled at the + * current simulated time plus `intervalSecs`. `callback` will be invoked + * when the event is processed. + * + * @param {number} intervalSecs Number of seconds in the future to schedule the event. + * @param {Function} callback Callback to invoke when timeout is reached. + */ + addTimeoutEvent(intervalSecs, callback) { + return this._insertEvent({ + type: 'timeout', + time: this._currentTimeSecs + intervalSecs, + callback, + sourceId: this._allocateSourceId(), + intervalSecs, + }); + } + + /** + * Add a time change event to the event queue. This simulates the machine’s + * real time clock changing relative to its monotonic clock, at date/time + * `timeStr`. Such a change can happen as the result of an NTP sync, for + * example. + * + * When the event is reached, the mock real/wall clock will have its time + * set to `newTimeStr`, and then `callback` will be invoked. `callback` + * should be used to enqueue any events *after* the time change event. If + * they are enqueued in the same scope as `addTimeChangeEvent()`, they will + * be mis-ordered as the event queue is sorted by mock real/wall clock time. + * + * @param {string} timeStr ISO 8601 date/time string to change the clock at. + * @param {string} newTimeStr ISO 8601 date/time string to change the clock to. + * @param {Function} callback Callback to invoke when time change is reached. + */ + addTimeChangeEvent(timeStr, newTimeStr, callback) { + return this._insertEvent({ + type: 'time-change', + time: TestHarness.timeStrToSecs(timeStr), + newTime: TestHarness.timeStrToSecs(newTimeStr), + callback, + }); + } + + /** + * Add a login user state change event to the event queue. This simulates + * the [D-Bus API for logind](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html#User%20Objects) + * notifying that the user has changed state at date/time `timeStr`. For + * example, this could represent the user logging out. + * + * @param {string} timeStr Date/Time the event happens, in ISO 8601 format. + * @param {string} newState New user state as if returned by. + * [`sd_ui_get_state()`](https://www.freedesktop.org/software/systemd/man/latest/sd_uid_get_state.html). + * @param {boolean} newIdleHint New user idle hint as per + * [the logind API](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html#User%20Objects). + */ + addLoginUserStateChangeEvent(timeStr, newState, newIdleHint) { + return this._insertEvent({ + type: 'login-user-state-change', + time: TestHarness.timeStrToSecs(timeStr), + newUserState: newState, + newUserIdleHint: newIdleHint, + }); + } + + /** + * Add a settings change event to the event queue. This simulates dconf + * notifying gnome-shell that the user has changed a setting, for example + * from gnome-control-center. + * + * @param {string} timeStr Date/Time the event happens, in ISO 8601 format. + * @param {string} schemaId ID of the schema of the setting which changed. + * @param {string} key Key of the setting which changed. + * @param {(number|boolean|string)} newValue New value of the setting. + */ + addSettingsChangeEvent(timeStr, schemaId, key, newValue) { + return this._insertEvent({ + type: 'settings-change', + time: TestHarness.timeStrToSecs(timeStr), + schemaId, + key, + newValue, + }); + } + + /** + * Add an assertion event to the event queue. This is a callback which is + * invoked when the simulated clock reaches `timeStr`. The callback can + * contain whatever test assertions you like. + * + * @param {string} timeStr Date/Time the event happens, in ISO 8601 format. + * @param {Function} callback Callback with test assertions to run when the + * time is reached. + */ + addAssertionEvent(timeStr, callback) { + return this._insertEvent({ + type: 'assertion', + time: TestHarness.timeStrToSecs(timeStr), + callback, + }); + } + + /** + * Add a shutdown action to the event queue. This shuts down the + * `timeLimitsManager` at date/time `timeStr`, and asserts that the state + * after shutdown is as expected. + * + * @param {string} timeStr Date/Time to shut down the manager at, in + * ISO 8601 format. + * @param {TimeLimitsManager} timeLimitsManager Manager to shut down. + */ + shutdownManager(timeStr, timeLimitsManager) { + return this._insertEvent({ + type: 'shutdown', + time: TestHarness.timeStrToSecs(timeStr), + timeLimitsManager, + }); + } + + /** + * Add a state assertion event to the event queue. This is a specialised + * form of `addAssertionEvent()` which asserts that the + * `TimeLimitsManager.state` equals `state` at date/time `timeStr`. + * + * @param {string} timeStr Date/Time to check the state at, in ISO 8601 + * format. + * @param {TimeLimitsManager} timeLimitsManager Manager to check the state of. + * @param {TimeLimitsState} expectedState Expected state at that time. + */ + expectState(timeStr, timeLimitsManager, expectedState) { + return this.addAssertionEvent(timeStr, () => { + expect(TimeLimitsManager.timeLimitsStateToString(timeLimitsManager.state)) + .withContext(`${timeStr} state`) + .toEqual(TimeLimitsManager.timeLimitsStateToString(expectedState)); + }); + } + + /** + * Add a state assertion event to the event queue. This is a specialised + * form of `addAssertionEvent()` which asserts that the given + * `TimeLimitsManager` properties equal the expected values at date/time + * `timeStr`. + * + * @param {string} timeStr Date/Time to check the state at, in ISO 8601 + * format. + * @param {TimeLimitsManager} timeLimitsManager Manager to check the state of. + * @param {object} expectedProperties Map of property names to expected + * values at that time. + */ + expectProperties(timeStr, timeLimitsManager, expectedProperties) { + return this.addAssertionEvent(timeStr, () => { + for (const [name, expectedValue] of Object.entries(expectedProperties)) { + expect(timeLimitsManager[name]) + .withContext(`${timeStr} ${name}`) + .toEqual(expectedValue); + } + }); + } + + _popEvent() { + return this._events.shift(); + } + + /** + * Get a `Gio.File` for the mock history file. + * + * This file is populated when the `TestHarness` is created, and deleted + * (as it’s a temporary file) after the harness is `run()`. + * + * @returns {Gio.File} + */ + get mockHistoryFile() { + return this._historyFile; + } + + /** + * Get a mock clock object for use in the `TimeLimitsManager` under test. + * This provides a basic implementation of GLib’s clock and timeout + * functions which use the simulated clock and event queue. + */ + get mockClock() { + return { + getRealTimeSecs: () => { + return this._currentTimeSecs; + }, + getMonotonicTimeSecs: () => { + return this._currentTimeSecs - this._clockOffset; + }, + timeoutAddSeconds: (priority, intervalSecs, callback) => { + return this.addTimeoutEvent(intervalSecs, callback).sourceId; + }, + sourceRemove: sourceId => { + if (this._timeChangeNotify !== null && + sourceId === this._timeChangeNotifySourceId) { + this._timeChangeNotify = null; + this._timeChangeNotifySourceId = 0; + return; + } + + this._removeEventBySourceId(sourceId); + }, + timeChangeNotify: callback => { + if (this._timeChangeNotify !== null) + fail('Duplicate time_change_notify() call'); + + this._timeChangeNotify = callback; + this._timeChangeNotifySourceId = this._nextSourceId; + this._nextSourceId++; + return this._timeChangeNotifySourceId; + }, + }; + } + + /** + * Set the initial time for the mock real/wall clock. + * + * This will typically become the time that the mock user first becomes + * active, when the `TimeLimitManager` is created. + * + * @param {string} timeStr Date/Time to initialise the clock to, in ISO 8601 + * format. + */ + initializeMockClock(timeStr) { + if (this._currentTimeSecs !== 0) + fail('mock clock already used'); + + this._currentTimeSecs = TestHarness.timeStrToSecs(timeStr); + } + + /** + * Get a mock login user factory for use in the `TimeLimitsManager` under + * test. This is an object providing constructors for `LoginUser` objects, + * which are proxies around the + * [`org.freedesktop.login1.User` D-Bus API](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html#User%20Objects). + * Each constructor returns a basic implementation of `LoginUser` which uses + * the current state from `TestHarness`. + * + * This has an extra layer of indirection to match `mockSettingsFactory`. + */ + get mockLoginUserFactory() { + return { + newAsync: () => { + return this._mockLoginUser; + }, + }; + } + + /** + * Get a mock settings factory for use in the `TimeLimitsManager` under test. + * This is an object providing constructors for `Gio.Settings` objects. Each + * constructor returns a basic implementation of `Gio.Settings` which uses + * the settings dictionary passed to `TestHarness` in its constructor. + * + * This necessarily has an extra layer of indirection because there are + * multiple ways to construct a `Gio.Settings`. + */ + get mockSettingsFactory() { + return { + new: schemaId => { + return { + connectObject: (...args) => { + // This is very much hardcoded to how the + // TimeLimitsManager currently uses connectObject(), to + // avoid having to reimplement all the argument parsing + // code from SignalTracker. + const [ + changedStr, changedCallback, + changedDailyLimitSecondsStr, changedDailyLimitSecondsCallback, + changedGrayscaleStr, changedGrayscaleCallback, + obj, + ] = args; + + if (changedStr !== 'changed' || + changedDailyLimitSecondsStr !== 'changed::daily-limit-seconds' || + changedGrayscaleStr !== 'changed::grayscale' || + typeof obj !== 'object') + fail('Gio.Settings.connectObject() not called in expected way'); + if (this._settingsChangedCallback !== null) + fail('Settings signals already connected'); + + this._settingsChangedCallback = changedCallback; + this._settingsChangedDailyLimitSecondsCallback = changedDailyLimitSecondsCallback; + this._settingsChangedGrayscaleCallback = changedGrayscaleCallback; + }, + get_boolean: key => { + return this._settings[schemaId][key]; + }, + get_uint: key => { + return this._settings[schemaId][key]; + }, + }; + }, + }; + } + + _tick() { + const event = this._popEvent(); + if (!event) + return false; + + console.debug(`Test tick: ${event.type} at ${this._timeSecsToStr(event.time)}`); + + this._currentTimeSecs = event.time; + + switch (event.type) { + case 'timeout': + if (event.callback()) { + event.time += event.intervalSecs; + this._insertEvent(event); + } + break; + case 'time-change': + this._clockOffset += event.newTime - this._currentTimeSecs; + this._currentTimeSecs = event.newTime; + + if (event.callback !== null) + event.callback(); + + if (this._timeChangeNotify) + this._timeChangeNotify(); + break; + case 'login-user-state-change': + this._currentUserState = event.newUserState; + this._currentUserIdleHint = event.newUserIdleHint; + + if (this._loginUserPropertiesChangedCallback) + this._loginUserPropertiesChangedCallback(); + break; + case 'settings-change': + this._settings[event.schemaId][event.key] = event.newValue; + + if (this._settingsChangedCallback) + this._settingsChangedCallback(event.key); + if (event.key === 'daily-limit-seconds' && + this._settingsChangedDailyLimitSecondsCallback) + this._settingsChangedDailyLimitSecondsCallback(event.key); + if (event.key === 'grayscale' && + this._settingsChangedGrayscaleCallback) + this._settingsChangedGrayscaleCallback(event.key); + + break; + case 'assertion': + event.callback(); + break; + case 'shutdown': + event.timeLimitsManager.shutdown().catch(() => {}); + + // FIXME: This doesn’t actually properly synchronise with the + // completion of the shutdown() call + this._insertEvent({ + type: 'assertion', + time: event.time + 1, + callback: () => { + expect(TimeLimitsManager.timeLimitsStateToString(event.timeLimitsManager.state)) + .withContext('Post-shutdown state') + .toEqual(TimeLimitsManager.timeLimitsStateToString(TimeLimitsState.DISABLED)); + expect(event.timeLimitsManager.dailyLimitTime) + .withContext('Post-shutdown dailyLimitTime') + .toEqual(0); + }, + }); + break; + default: + fail('not reached'); + } + + return true; + } + + /** + * Run the test in a loop, blocking until all events are processed or an + * exception is raised. + */ + run() { + console.debug('Starting new unit test'); + + const loop = new GLib.MainLoop(null, false); + let innerException = null; + + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + try { + if (this._tick()) + return GLib.SOURCE_CONTINUE; + loop.quit(); + return GLib.SOURCE_REMOVE; + } catch (e) { + // Quit the main loop then re-raise the exception + loop.quit(); + innerException = e; + return GLib.SOURCE_REMOVE; + } + }); + + loop.run(); + + this._cleanup(); + + // Did we exit with an exception? + if (innerException) + throw innerException; + } +} + +describe('Time limits manager', () => { + it('can be disabled via GSettings', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': false, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.DISABLED); + harness.expectState('2024-06-01T15:00:00Z', timeLimitsManager, TimeLimitsState.DISABLED); + harness.addLoginUserStateChangeEvent('2024-06-01T15:00:10Z', 'active', false); + harness.addLoginUserStateChangeEvent('2024-06-01T15:00:20Z', 'lingering', true); + harness.expectProperties('2024-06-01T15:00:30Z', timeLimitsManager, { + 'state': TimeLimitsState.DISABLED, + 'dailyLimitTime': 0, + }); + harness.shutdownManager('2024-06-01T15:10:00Z', timeLimitsManager); + + harness.run(); + }); + + it('can be toggled on and off via GSettings', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.addSettingsChangeEvent('2024-06-01T11:00:00Z', + 'org.gnome.desktop.screen-time-limits', 'enabled', false); + harness.expectProperties('2024-06-01T11:00:01Z', timeLimitsManager, { + 'state': TimeLimitsState.DISABLED, + 'dailyLimitTime': 0, + }); + + // Test that toggling it on and off fast is handled OK + for (var i = 0; i < 3; i++) { + harness.addSettingsChangeEvent('2024-06-01T11:00:02Z', + 'org.gnome.desktop.screen-time-limits', 'enabled', true); + harness.addSettingsChangeEvent('2024-06-01T11:00:02Z', + 'org.gnome.desktop.screen-time-limits', 'enabled', false); + } + + harness.addSettingsChangeEvent('2024-06-01T11:00:03Z', + 'org.gnome.desktop.screen-time-limits', 'enabled', true); + harness.expectState('2024-06-01T11:00:04Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.shutdownManager('2024-06-01T15:10:00Z', timeLimitsManager); + + harness.run(); + }); + + it('tracks a single day’s usage', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectProperties('2024-06-01T13:59:59Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'), + }); + harness.expectState('2024-06-01T14:00:01Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + harness.shutdownManager('2024-06-01T14:10:00Z', timeLimitsManager); + + harness.run(); + }); + + it('tracks a single day’s usage early in the morning', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': true, + 'daily-limit-seconds': 1 * 60 * 60, + }, + }); + harness.initializeMockClock('2024-06-01T00:30:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + harness.expectState('2024-06-01T00:30:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectProperties('2024-06-01T01:29:59Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T01:30:00Z'), + }); + harness.expectState('2024-06-01T01:30:01Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + harness.shutdownManager('2024-06-01T01:40:00Z', timeLimitsManager); + + harness.run(); + }); + + it('resets usage at the end of the day', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectProperties('2024-06-01T15:00:00Z', timeLimitsManager, { + 'state': TimeLimitsState.LIMIT_REACHED, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'), + }); + harness.addLoginUserStateChangeEvent('2024-06-01T15:00:10Z', 'offline', true); + + // the next day (after 03:00 in the morning) usage should be reset: + harness.expectProperties('2024-06-02T13:59:59Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': 0, + }); + harness.addLoginUserStateChangeEvent('2024-06-02T14:00:00Z', 'active', false); + harness.expectProperties('2024-06-02T14:00:00Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-02T18:00:00Z'), + }); + + // and that limit should be reached eventually + harness.expectProperties('2024-06-02T18:00:01Z', timeLimitsManager, { + 'state': TimeLimitsState.LIMIT_REACHED, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-02T18:00:00Z'), + }); + + harness.shutdownManager('2024-06-02T18:10:00Z', timeLimitsManager); + + harness.run(); + }); + + it('tracks usage correctly from an existing history file', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, JSON.stringify([ + { + 'oldState': UserState.INACTIVE, + 'newState': UserState.ACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T07:30:00Z'), + }, + { + 'oldState': UserState.ACTIVE, + 'newState': UserState.INACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T08:00:00Z'), + }, + { + 'oldState': UserState.INACTIVE, + 'newState': UserState.ACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T08:30:00Z'), + }, + { + 'oldState': UserState.ACTIVE, + 'newState': UserState.INACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T09:30:00Z'), + }, + ])); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + // The existing history file (above) lists two active periods, + // 07:30–08:00 and 08:30–09:30 that morning. So the user should have + // 2.5h left today. + harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectProperties('2024-06-01T12:29:59Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T12:30:00Z'), + }); + harness.expectState('2024-06-01T12:30:01Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + harness.shutdownManager('2024-06-01T12:40:00Z', timeLimitsManager); + + harness.run(); + }); + + it('immediately limits usage from an existing history file', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, JSON.stringify([ + { + 'oldState': UserState.INACTIVE, + 'newState': UserState.ACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T04:30:00Z'), + }, + { + 'oldState': UserState.ACTIVE, + 'newState': UserState.INACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T08:50:00Z'), + }, + ])); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + // The existing history file (above) lists one active period, + // 04:30–08:50 that morning. So the user should have no time left today. + harness.expectProperties('2024-06-01T10:00:01Z', timeLimitsManager, { + 'state': TimeLimitsState.LIMIT_REACHED, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T08:30:00Z'), + }); + harness.shutdownManager('2024-06-01T10:10:00Z', timeLimitsManager); + + harness.run(); + }); + + [ + '', + 'not valid JSON', + '[]', + '[{}]', + '[{"newState": 1, "wallTimeSecs": 123}]', + '[{"oldState": 0, "wallTimeSecs": 123}]', + '[{"oldState": 0, "newState": 1}]', + '[{"oldState": "not a number", "newState": 1, "wallTimeSecs": 123}]', + '[{"oldState": 0, "newState": "not a number", "wallTimeSecs": 123}]', + '[{"oldState": 0, "newState": 1, "wallTimeSecs": "not a number"}]', + '[{"oldState": 0, "newState": 1, "wallTimeSecs": 123.456}]', + '[{"oldState": 666, "newState": 1, "wallTimeSecs": 123}]', + '[{"oldState": 0, "newState": 666, "wallTimeSecs": 123}]', + '[{"oldState": 0, "newState": 0, "wallTimeSecs": 123}]', + '[{"oldState": 0, "newState": 1, "wallTimeSecs": 123},{"oldState": 1, "newState": 0, "wallTimeSecs": 1}]', + ].forEach((invalidHistoryFileContents, idx) => { + it(`ignores invalid history file syntax (test case ${idx + 1})`, () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, invalidHistoryFileContents); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + // The existing history file (above) is invalid or a no-op and + // should be ignored. + harness.expectProperties('2024-06-01T10:00:01Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'), + }); + harness.shutdownManager('2024-06-01T10:10:00Z', timeLimitsManager); + + harness.run(); + }); + }); + + it('expires old entries from an existing history file', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, JSON.stringify([ + // Old entries + { + 'oldState': UserState.INACTIVE, + 'newState': UserState.ACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T07:30:00Z') - 2 * TimeLimitsManager.HISTORY_THRESHOLD_SECONDS, + }, + { + 'oldState': UserState.ACTIVE, + 'newState': UserState.INACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T08:00:00Z') - 2 * TimeLimitsManager.HISTORY_THRESHOLD_SECONDS, + }, + // Recent entries + { + 'oldState': UserState.INACTIVE, + 'newState': UserState.ACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T08:30:00Z'), + }, + { + 'oldState': UserState.ACTIVE, + 'newState': UserState.INACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T09:30:00Z'), + }, + ])); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + // The existing history file (above) lists two active periods, + // one of which is a long time ago and the other is ‘this’ morning in + // June. After the manager is shut down and the history file stored + // again, the older entry should have been expired. + harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectProperties('2024-06-01T12:29:59Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T13:00:00Z'), + }); + harness.shutdownManager('2024-06-01T12:40:00Z', timeLimitsManager); + harness.addAssertionEvent('2024-06-01T12:50:00Z', () => { + const [, historyContents] = harness.mockHistoryFile.load_contents(null); + expect(JSON.parse(new TextDecoder().decode(historyContents))) + .withContext('History file contents') + .toEqual([ + // Recent entries + { + 'oldState': UserState.INACTIVE, + 'newState': UserState.ACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T08:30:00Z'), + }, + { + 'oldState': UserState.ACTIVE, + 'newState': UserState.INACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T09:30:00Z'), + }, + // New entries + { + 'oldState': UserState.INACTIVE, + 'newState': UserState.ACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + }, + { + 'oldState': UserState.ACTIVE, + 'newState': UserState.INACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('2024-06-01T12:40:00Z'), + }, + ]); + }); + + harness.run(); + }); + + it('expires future entries from an existing history file', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, JSON.stringify([ + { + 'oldState': UserState.INACTIVE, + 'newState': UserState.ACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('3000-06-01T04:30:00Z'), + }, + { + 'oldState': UserState.ACTIVE, + 'newState': UserState.INACTIVE, + 'wallTimeSecs': TestHarness.timeStrToSecs('3000-06-01T08:50:00Z'), + }, + ])); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + // The existing history file (above) lists one active period, + // 04:30–08:50 that morning IN THE YEAR 3000. This could have resulted + // from the clock offset changing while offline. Ignore it; the user + // should still have their full limit for the day. + harness.expectProperties('2024-06-01T10:00:01Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'), + }); + harness.shutdownManager('2024-06-01T10:10:00Z', timeLimitsManager); + + harness.run(); + }); + + it('doesn’t count usage across time change events forwards', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + // Use up 2h of the daily limit. + harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectProperties('2024-06-01T12:00:00Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'), + }); + + harness.addTimeChangeEvent('2024-06-01T12:00:01Z', '2024-06-01T16:00:00Z', () => { + // The following events are in the new time epoch. There should be + // 2h of time limit left for the day. + harness.expectProperties('2024-06-01T16:00:01Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T17:59:59Z'), + }); + + harness.expectProperties('2024-06-01T18:00:00Z', timeLimitsManager, { + 'state': TimeLimitsState.LIMIT_REACHED, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T17:59:59Z'), + }); + + harness.shutdownManager('2024-06-01T18:10:00Z', timeLimitsManager); + }); + + harness.run(); + }); + + it('doesn’t count usage across time change events backwards', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory); + + // Use up 2h of the daily limit. + harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectProperties('2024-06-01T12:00:00Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'), + }); + + harness.addTimeChangeEvent('2024-06-01T12:00:01Z', '2024-06-01T09:00:00Z', () => { + // The following events are in the new time epoch. There should be + // 2h of time limit left for the day. + harness.expectProperties('2024-06-01T09:00:01Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T10:59:59Z'), + }); + + harness.expectProperties('2024-06-01T11:00:00Z', timeLimitsManager, { + 'state': TimeLimitsState.LIMIT_REACHED, + 'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T10:59:59Z'), + }); + + harness.shutdownManager('2024-06-01T11:10:00Z', timeLimitsManager); + }); + + harness.run(); + }); +});