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