gnome-shell/js/misc/timeLimitsManager.js
Philip Withnall 7183e75f05 timeLimitsManager: Remove a redundant fallback value
This code was originally copied from `lightbox.js`, where the fallback
is potentially useful because the duration is provided as an argument.
The `timeLimitsManager` uses a constant as the duration, though, so the
fallback is just confusing.

Spotted in https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3655#note_2369352

Signed-off-by: Philip Withnall <pwithnall@gnome.org>
Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3655>
2025-03-05 12:06:07 +00:00

1156 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
//
// Copyright 2024, 2025 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 = 1.0; // saturation ([0.0, 1.0]) when grayscale mode is activated, 1.0 means full desaturation
/** @enum {number} */
export const TimeLimitsState = {
/* screen time limit history is disabled */
DISABLED: 0,
/* screen time limit history recording is enabled, but limits are disabled or
* 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, if limits are enabled.
*
* 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
* thats a subset of idle time), and not suspended.
* 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%20Objects,
* plus `PreparingForSleep` from
*.https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html#The%20Manager%20Object.
* Inactive time corresponds to all the other states from sd_uid_get_state(),
* or if `IdleHint` is true or if `PreparingForSleep` is true.
*
* All times within the class are handled in terms of wall/real clock time,
* rather than monotonic time. This is because its 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),
'daily-limit-enabled': GObject.ParamSpec.boolean(
'daily-limit-enabled', null, null,
GObject.ParamFlags.READABLE,
true),
'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, loginManagerFactory, 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._loginManagerFactory = loginManagerFactory ?? {
new: LoginManager.getLoginManager,
};
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::daily-limit-enabled', () => this.notify('daily-limit-enabled'),
'changed::grayscale', () => this.notify('grayscale-enabled'),
this);
this._state = TimeLimitsState.DISABLED;
this._stateTransitions = [];
this._cancellable = null;
this._loginManager = null;
this._inhibitor = 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('history-enabled')) {
if (this._state !== TimeLimitsState.DISABLED) {
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 users
// 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 dont 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 users state. Listening to
// the prepare-for-sleep signal requires taking a delay inhibitor to
// avoid races.
this._loginManager = this._loginManagerFactory.new();
await this._ensureInhibitor();
this._loginManager.connectObject(
'prepare-for-sleep',
(unused, preparingForSleep) => {
this._onPrepareForSleep(preparingForSleep).catch(logError);
},
this);
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._releaseInhibitor();
this._loginManager?.disconnectObject(this);
this._loginManager = null;
this._state = TimeLimitsState.DISABLED;
this._lastStateChangeTimeSecs = 0;
this.notify('state');
if (this._screenTimeLimitSettings.get_boolean('history-enabled')) {
// 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}`);
}
} else {
try {
await this._deleteTransitions();
} catch (e) {
console.warn(`Failed to delete screen time limits data: ${e.message}`);
}
}
// Make sure no async operations are still pending.
this._cancellable?.cancel();
this._cancellable = null;
}
async _ensureInhibitor() {
if (this._inhibitor)
return;
try {
this._inhibitor = await this._loginManager.inhibit(
_('GNOME needs to save screen time data'), this._cancellable);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
console.warn('Failed to inhibit suspend: %s'.format(e.message));
}
}
_releaseInhibitor() {
this._inhibitor?.close(null);
this._inhibitor = null;
}
async _onPrepareForSleep(preparingForSleep) {
// Just come back from sleep, so take another inhibitor.
if (!preparingForSleep)
this._ensureInhibitor();
try {
await this._updateUserState(true);
} catch (e) {
console.warn(`Failed to update user state: ${e.message}`);
}
// Release the inhibitor if were preparing to sleep.
if (preparingForSleep)
this._releaseInhibitor();
}
/** 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
* cant 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.
*
* Its 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 &&
!this._loginManager.preparingForSleep;
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. Theres 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);
}
}
async _deleteTransitions() {
const file = this._historyFile;
console.debug(`TimeLimitsManager: Deleting screen time limits data in ${file.peek_path()}`);
try {
await file.delete(this._cancellable);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
throw e;
}
}
/**
* 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:0003: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 users logged in rather
* than necessarily when theyre actively moving the mouse. Tracking the
* latter is a lot more work, doesnt 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) {
console.assert(this.dailyLimitEnabled,
'Daily limit reached-at time only makes sense if limits are enabled');
// 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');
const dailyLimitEnabled = this._screenTimeLimitSettings.get_boolean('daily-limit-enabled');
const dailyLimitDebug = dailyLimitEnabled ? `${dailyLimitSecs}s` : 'disabled';
console.debug('TimeLimitsManager: Active time today: ' +
`${activeTimeTodaySecs}s, daily limit ${dailyLimitDebug}`);
if (dailyLimitEnabled && 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) {
newState = TimeLimitsState.ACTIVE;
// Schedule an update for when we expect the limit will be reached.
if (dailyLimitEnabled)
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.
* Its measured in real time seconds.
*
* @type {number}
*/
get dailyLimitTime() {
switch (this._state) {
case TimeLimitsState.DISABLED:
return 0;
case TimeLimitsState.ACTIVE: {
if (!this.dailyLimitEnabled)
return 0;
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 daily limit is enabled.
*
* If false, screen usage information is recorded, but no limit is enforced.
* reached.
*
* @type {boolean}
*/
get dailyLimitEnabled() {
return this._screenTimeLimitSettings.get_boolean('daily-limit-enabled');
}
/**
* 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 users 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::daily-limit-enabled', 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._manager.dailyLimitEnabled)
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: {
if (this._manager.dailyLimitEnabled) {
this._ensureEnabled();
this._desaturationEffect.set_enabled(false);
} else {
this._ensureDisabled();
}
break;
}
case TimeLimitsState.LIMIT_REACHED: {
this._ensureEnabled();
if (this._manager.grayscaleEnabled) {
this._desaturationEffect.factor = 0.0;
this._desaturationEffect.set_enabled(true);
Main.layoutManager.uiGroup.ease_property(
'@effects.desaturate.factor', GRAYSCALE_SATURATION,
{
duration: GRAYSCALE_FADE_TIME_SECONDS * 1000,
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 cant 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-enabled', 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._app);
this._notification.connect('destroy', () => (this._notification = null));
}
// Unacknowledge the notification when its 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: {
// Remove active notifications if limits have been disabled.
if (!this._manager.dailyLimitEnabled) {
this.destroy();
break;
}
// Work out when the time limit will be, and display some warnings
// that its 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) {
this._notification?.destroy();
this._notification = null;
// 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 theyve 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: _('Its 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, app) {
super({
source,
resident: true,
});
this._app = app;
}
activate() {
this._app.activate();
super.activate();
}
});