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(); + }); +});