From ceccf38e96b975e625e311489a5771b4ab968458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20Kuchci=C5=84ski?= Date: Mon, 18 Aug 2025 11:44:42 +0200 Subject: [PATCH 1/7] parentalControlsManager: Add support for session limits Add ability to check if session limits are enabled for users using API introduced in malcontent 0.14.0. The `get_session_limits_async` function is actually available since 0.5.0, but the signal `session-limits-changed` was only introduced in 0.14.0, so check for function `get_daily_limit` which was introduced in 0.14.0 to be sure we're using correct version. This allows us to keep filtering apps, even if the library version doesn't fully support session limits. Will be used in the future to integrate parental controls session limits. Part-of: --- js/misc/parentalControlsManager.js | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/js/misc/parentalControlsManager.js b/js/misc/parentalControlsManager.js index 3dfe13d5a8..f4b284120b 100644 --- a/js/misc/parentalControlsManager.js +++ b/js/misc/parentalControlsManager.js @@ -32,8 +32,13 @@ let Malcontent = null; if (HAVE_MALCONTENT) { ({default: Malcontent} = await import('gi://Malcontent?version=0')); Gio._promisify(Malcontent.Manager.prototype, 'get_app_filter_async'); + Gio._promisify(Malcontent.Manager.prototype, 'get_session_limits_async'); } +// We require libmalcontent ≥ 0.14.0 for session limits +const HAVE_MALCONTENT_0_14 = Malcontent && + GObject.signal_lookup('session-limits-changed', Malcontent.Manager) > 0; + let _singleton = null; /** @@ -53,6 +58,7 @@ export function getDefault() { const ParentalControlsManager = GObject.registerClass({ Signals: { 'app-filter-changed': {}, + 'session-limits-changed': {}, }, }, class ParentalControlsManager extends GObject.Object { _init() { @@ -61,6 +67,7 @@ const ParentalControlsManager = GObject.registerClass({ this._initialized = false; this._disabled = false; this._appFilter = null; + this._sessionLimits = null; this._initializeManager(); } @@ -70,6 +77,7 @@ const ParentalControlsManager = GObject.registerClass({ console.debug('Skipping parental controls support, malcontent not found'); this._initialized = true; this.emit('app-filter-changed'); + this.emit('session-limits-changed'); return; } @@ -77,16 +85,22 @@ const ParentalControlsManager = GObject.registerClass({ const connection = await Gio.DBus.get(Gio.BusType.SYSTEM, null); this._manager = new Malcontent.Manager({connection}); this._appFilter = await this._getAppFilter(); + + if (HAVE_MALCONTENT_0_14) + this._sessionLimits = await this._getSessionLimits(); } catch (e) { logError(e, 'Failed to get parental controls settings'); return; } this._manager.connect('app-filter-changed', this._onAppFilterChanged.bind(this)); + if (HAVE_MALCONTENT_0_14) + this._manager.connect('session-limits-changed', this._onSessionLimitsChanged.bind(this)); // Signal initialisation is complete. this._initialized = true; this.emit('app-filter-changed'); + this.emit('session-limits-changed'); } async _getAppFilter() { @@ -123,6 +137,40 @@ const ParentalControlsManager = GObject.registerClass({ } } + async _getSessionLimits() { + let sessionLimits = null; + + try { + sessionLimits = await this._manager.get_session_limits_async( + Shell.util_get_uid(), + Malcontent.ManagerGetValueFlags.NONE, + null); + } catch (e) { + if (!e.matches(Malcontent.ManagerError, Malcontent.ManagerError.DISABLED)) + throw e; + + console.debug('Parental controls globally disabled'); + this._disabled = true; + } + + return sessionLimits; + } + + async _onSessionLimitsChanged(manager, uid) { + // Emit 'changed' signal only if session-limits are changed for currently logged-in user + const currentUid = Shell.util_get_uid(); + if (currentUid !== uid) + return; + + try { + this._sessionLimits = await this._getSessionLimits(); + this.emit('session-limits-changed'); + } catch (e) { + // Keep the old session limits + logError(e, `Failed to get new MctSessionLimits for uid ${Shell.util_get_uid()} on session-limits-changed`); + } + } + get initialized() { return this._initialized; } @@ -152,4 +200,24 @@ const ParentalControlsManager = GObject.registerClass({ return this._appFilter.is_appinfo_allowed(appInfo); } + + /** + * Check whether parental controls session limits are enabled for the account, + * and if the library supports the features required for the functionality. + * + * @returns {bool} whether session limits are supported and enabled. + */ + sessionLimitsEnabled() { + // Are latest parental controls enabled (at configure or runtime)? + if (!HAVE_MALCONTENT_0_14 || this._disabled) + return false; + + // Have we finished initialising yet? + if (!this.initialized) { + console.debug('Ignoring session limits because parental controls not yet initialised'); + return false; + } + + return this._sessionLimits.is_enabled(); + } }); -- GitLab From 2c37ed8bd5716f4c89fc8db66d80e63c6ceca40b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20Kuchci=C5=84ski?= Date: Mon, 18 Aug 2025 11:54:57 +0200 Subject: [PATCH 2/7] dbus-interfaces: Add MalcontentTimer child interface Add org.freedesktop.MalcontentTimer1.Child interface for accessing the screen time and app usage periods for current user account. Currently unused, will provide necessary functionality when integrating parental controls session limits with TimeLimitsManager. Part-of: --- ...org.freedesktop.MalcontentTimer1.Child.xml | 127 ++++++++++++++++++ .../gnome-shell-dbus-interfaces.gresource.xml | 1 + 2 files changed, 128 insertions(+) create mode 100644 data/dbus-interfaces/org.freedesktop.MalcontentTimer1.Child.xml diff --git a/data/dbus-interfaces/org.freedesktop.MalcontentTimer1.Child.xml b/data/dbus-interfaces/org.freedesktop.MalcontentTimer1.Child.xml new file mode 100644 index 0000000000..ee5e714ba6 --- /dev/null +++ b/data/dbus-interfaces/org.freedesktop.MalcontentTimer1.Child.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/gnome-shell-dbus-interfaces.gresource.xml b/data/gnome-shell-dbus-interfaces.gresource.xml index 714e65a0c9..01c11f2e65 100644 --- a/data/gnome-shell-dbus-interfaces.gresource.xml +++ b/data/gnome-shell-dbus-interfaces.gresource.xml @@ -15,6 +15,7 @@ org.freedesktop.login1.Manager.xml org.freedesktop.login1.Session.xml org.freedesktop.login1.User.xml + org.freedesktop.MalcontentTimer1.Child.xml org.freedesktop.ModemManager1.Modem.Modem3gpp.xml org.freedesktop.ModemManager1.Modem.ModemCdma.xml org.freedesktop.ModemManager1.Modem.xml -- GitLab From 872d6a168e07eaef4a17603705b4033dd7016b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20Kuchci=C5=84ski?= Date: Fri, 22 Aug 2025 15:49:35 +0200 Subject: [PATCH 3/7] timeLimitsManager: Clean up property description Part-of: --- js/misc/timeLimitsManager.js | 1 - 1 file changed, 1 deletion(-) diff --git a/js/misc/timeLimitsManager.js b/js/misc/timeLimitsManager.js index 79642b9deb..d4918de5d5 100644 --- a/js/misc/timeLimitsManager.js +++ b/js/misc/timeLimitsManager.js @@ -878,7 +878,6 @@ export const TimeLimitsManager = GObject.registerClass({ * Whether the daily limit is enabled. * * If false, screen usage information is recorded, but no limit is enforced. - * reached. * * @type {boolean} */ -- GitLab From 5b693f01a3a7823b46297c55dbb1e4bac8321b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20Kuchci=C5=84ski?= Date: Thu, 28 Aug 2025 11:23:15 +0200 Subject: [PATCH 4/7] tests/unit: Add a helper method in TimeLimitsManager Currently we create a new `TimeLimitsManager` for each of the unit tests for testing the wellbeing screen time limit, with the same parameters. Rather than having to repeat the entire constructor call multiple times, factor it out into a helper method on the `TestHarness`. There is no difference in functionality itself. Part-of: --- tests/unit/timeLimitsManager.js | 47 +++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/tests/unit/timeLimitsManager.js b/tests/unit/timeLimitsManager.js index bcbd77e885..92cba542b5 100644 --- a/tests/unit/timeLimitsManager.js +++ b/tests/unit/timeLimitsManager.js @@ -480,6 +480,19 @@ class TestHarness { this._currentTimeSecs = TestHarness.timeStrToSecs(timeStr); } + /** + * Create `TimeLimitManager`. + * + * @returns {TimeLimitsManager} the new timeLimitsManager. + */ + createTimeLimitsManager() { + return new TimeLimitsManager.TimeLimitsManager(this.mockHistoryFile, + this.mockClock, + this.mockLoginManagerFactory, + this.mockLoginUserFactory, + this.mockSettingsFactory); + } + /** * Get a mock login manager factory for use in the `TimeLimitsManager` under * test. This is an object providing a constructor for the objects returned @@ -696,7 +709,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.DISABLED); harness.expectState('2024-06-01T15:00:00Z', timeLimitsManager, TimeLimitsState.DISABLED); @@ -720,7 +733,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.addSettingsChangeEvent('2024-06-01T11:00:00Z', @@ -755,7 +768,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.expectProperties('2024-06-01T13:59:59Z', timeLimitsManager, { @@ -777,7 +790,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T00:30:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T00:30:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.expectProperties('2024-06-01T01:29:59Z', timeLimitsManager, { @@ -799,7 +812,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.expectProperties('2024-06-01T13:59:59Z', timeLimitsManager, { @@ -832,7 +845,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.expectProperties('2024-06-01T15:00:00Z', timeLimitsManager, { @@ -872,7 +885,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // Run until the limit is reached. harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); @@ -930,7 +943,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // 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 @@ -966,7 +979,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // The existing history file (above) lists one active period, // 04:30–08:50 that morning. So the user should have no time left today. @@ -1005,7 +1018,7 @@ describe('Time limits manager', () => { }, }, invalidHistoryFileContents); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // The existing history file (above) is invalid or a no-op and // should be ignored. @@ -1051,7 +1064,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // The existing history file (above) lists two active periods, // one of which is a long time ago and the other is ‘this’ morning in @@ -1116,7 +1129,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // The existing history file (above) lists one active period, // 04:30–08:50 that morning IN THE YEAR 3000. This could have resulted @@ -1151,7 +1164,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.addSettingsChangeEvent('2024-06-01T10:00:02Z', @@ -1187,7 +1200,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.expectState('2024-06-01T14:00:01Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); @@ -1225,7 +1238,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // Use up 2h of the daily limit. harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); @@ -1262,7 +1275,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // Use up 2h of the daily limit. harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); @@ -1299,7 +1312,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // Use up 2h of the daily limit. harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); -- GitLab From 2dee27d8dd15825e12df810702ec8ba19e1174ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20Kuchci=C5=84ski?= Date: Tue, 28 Oct 2025 00:53:41 +0100 Subject: [PATCH 5/7] timeLimitsManager: Rename function updating state machine Rename _updateSettings function to _updateStateMachine, since that's what the function actually does, starting or stopping the machine depending on the 'history-enabled' GSetting. Moreover, in the future we'll take into account parental controls settings too, so renaming the function to be more generic will avoid confusion later on. Part-of: --- js/misc/timeLimitsManager.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/js/misc/timeLimitsManager.js b/js/misc/timeLimitsManager.js index d4918de5d5..1fa2c46ac2 100644 --- a/js/misc/timeLimitsManager.js +++ b/js/misc/timeLimitsManager.js @@ -159,7 +159,7 @@ export const TimeLimitsManager = GObject.registerClass({ }; this._screenTimeLimitSettings = this._settingsFactory.new('org.gnome.desktop.screen-time-limits'); this._screenTimeLimitSettings.connectObject( - 'changed', () => this._updateSettings(), + 'changed', () => this._updateStateMachine(), 'changed::daily-limit-seconds', () => this.notify('daily-limit-time'), 'changed::daily-limit-enabled', () => this.notify('daily-limit-enabled'), 'changed::grayscale', () => this.notify('grayscale-enabled'), @@ -178,10 +178,10 @@ export const TimeLimitsManager = GObject.registerClass({ this._ignoreClockOffsetChanges = false; // Start tracking timings - this._updateSettings(); + this._updateStateMachine(); } - _updateSettings() { + _updateStateMachine() { if (!this._screenTimeLimitSettings.get_boolean('history-enabled')) { if (this._state !== TimeLimitsState.DISABLED) { this._stopStateMachine().catch( @@ -190,7 +190,7 @@ export const TimeLimitsManager = GObject.registerClass({ return false; } - // If this is the first time _updateSettings() has been called, start + // If this is the first time _updateStateMachine() has been called, start // the state machine. if (this._state === TimeLimitsState.DISABLED) { this._startStateMachine().catch( -- GitLab From 96221910c7f9a57dc2e13f838c77dcee2c949701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20Kuchci=C5=84ski?= Date: Tue, 4 Nov 2025 23:07:54 +0100 Subject: [PATCH 6/7] timeLimitsManager: Convert unix times to ISO 8601 Convert unix epoch times to ISO 8601 formatted strings for logging purposes. Part-of: --- js/misc/timeLimitsManager.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/js/misc/timeLimitsManager.js b/js/misc/timeLimitsManager.js index 1fa2c46ac2..b99939778a 100644 --- a/js/misc/timeLimitsManager.js +++ b/js/misc/timeLimitsManager.js @@ -467,7 +467,7 @@ export const TimeLimitsManager = GObject.registerClass({ if (debugLog) { console.debug('TimeLimitsManager: User state changed from ' + - `${userStateToString(oldState)} to ${userStateToString(newState)} at ${wallTimeSecs}s`); + `${userStateToString(oldState)} to ${userStateToString(newState)} at ${this._unixToString(wallTimeSecs)}`); } // This potentially changed the limit time and timeout calculations. @@ -751,6 +751,17 @@ export const TimeLimitsManager = GObject.registerClass({ return 0; } + /** + * Convert unix epoch to ISO 8601 formatted string for logging purposes. + * + * @param {number} secs Unix time to represent in human readable form + * @returns {string} + */ + _unixToString(secs) { + const dateTime = GLib.DateTime.new_from_unix_local(secs); + return dateTime.format_iso8601(); + } + _updateState() { console.assert(this._state !== TimeLimitsState.DISABLED, 'Time limits should not be disabled when updating timer'); -- GitLab From 2cfc6d14957f43b847ca4c9581d17daba4056b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20Kuchci=C5=84ski?= Date: Mon, 18 Aug 2025 11:58:50 +0200 Subject: [PATCH 7/7] timeLimitsManager: Integrate with malcontent and lock when limit reached Lock the screen when screen time limit is reached in a child session, which required extending the existing TimeLimitsManager functionality to include the parental controls session limits. TimeLimitsDispatcher was also adjusted to take into account parental controls session limits before making any user facing changes. Closes: https://gitlab.gnome.org/GNOME/gnome-shell/-/work_items/8575 Part-of: --- js/misc/timeLimitsManager.js | 220 +++++++++++++++- tests/unit/timeLimitsManager.js | 436 +++++++++++++++++++++++++++++++- 2 files changed, 640 insertions(+), 16 deletions(-) diff --git a/js/misc/timeLimitsManager.js b/js/misc/timeLimitsManager.js index b99939778a..c0dd97ce9d 100644 --- a/js/misc/timeLimitsManager.js +++ b/js/misc/timeLimitsManager.js @@ -32,6 +32,13 @@ 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'; +import * as ParentalControlsManager from './parentalControlsManager.js'; +import * as SystemActions from './systemActions.js'; + +import {loadInterfaceXML} from './fileUtils.js'; + +const TimerChildIface = loadInterfaceXML('org.freedesktop.MalcontentTimer1.Child'); +const TimerChildProxy = Gio.DBusProxy.makeProxyWrapper(TimerChildIface); 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 @@ -79,10 +86,17 @@ function userStateToString(userState) { /** * A manager class which tracks total active/inactive time for a user, and + * signals when the user has reached their session time limit for actively + * using the device if the user has parental controls session limits set. When + * they are disabled, the wellbeing daily time limit is considered, and manager * 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 + * For parental controls session time limit, the manager tracks the active/inactive + * time via malcontent-timerd. + * + * For wellbeing daily time limit, the way in which the manager tracks the + * 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), and not suspended. * This corresponds to the `active` state from sd_uid_get_state() @@ -123,7 +137,7 @@ export const TimeLimitsManager = GObject.registerClass({ 'daily-limit-reached': {}, }, }, class TimeLimitsManager extends GObject.Object { - constructor(historyFile, clock, loginManagerFactory, loginUserFactory, settingsFactory) { + constructor(historyFile, clock, loginManagerFactory, loginUserFactory, settingsFactory, parentalControlsManagerFactory, timerChildProxyFactory) { super(); // Allow these few bits of global state to be overridden for unit testing @@ -154,6 +168,37 @@ export const TimeLimitsManager = GObject.registerClass({ return loginManager.getCurrentUserProxy(); }, }; + + this._parentalControlsManagerFactory = parentalControlsManagerFactory ?? { + new: ParentalControlsManager.getDefault, + }; + this._parentalControlsManager = this._parentalControlsManagerFactory.new(); + + this._parentalControlsManager.connectObject( + 'session-limits-changed', () => this._onSessionLimitsChanged().catch(logError), + this); + + this._estimatedTimes = []; + this._timerChildProxyFactory = timerChildProxyFactory ?? { + new: () => { + return TimerChildProxy(Gio.DBus.system, + 'org.freedesktop.MalcontentTimer1', + '/org/freedesktop/MalcontentTimer1', + (proxy, error) => { + if (error) + console.debug(`Failed to get TimerChild proxy: ${error}`); + }, + null, /* cancellable */ + Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION + ); + }, + }; + + this._timerChildProxy = this._timerChildProxyFactory.new(); + + this._timerChildProxy.connectSignal('EstimatedTimesChanged', + () => this._updateEstimatedTimes().catch(logError)); + this._settingsFactory = settingsFactory ?? { new: Gio.Settings.new, }; @@ -176,13 +221,68 @@ export const TimeLimitsManager = GObject.registerClass({ this._timeChangeId = 0; this._clockOffsetSecs = 0; this._ignoreClockOffsetChanges = false; + this._latestUsageEndSecs = 0; // Start tracking timings this._updateStateMachine(); } + async _recordUsage(startSecs, endSecs) { + console.debug('Recording usage to the parental controls daemon: ' + + `${this._unixToString(startSecs)} ${this._unixToString(endSecs)}`); + try { + await this._timerChildProxy.RecordUsageAsync( + [[startSecs, endSecs, 'login-session', '']]); + this._latestUsageEndSecs = endSecs; + } catch (e) { + if (e.matches(Gio.DBusError, Gio.DBusError.SERVICE_UNKNOWN)) { + // Limits should only be enabled when the daemon is available. + if (this._parentalControlsManager.sessionLimitsEnabled()) + throw e; + else + console.debug('Parental controls timer daemon not available'); + } else { + console.warn(`Failed to record usage: ${e.message}`); + } + } + } + + async _onSessionLimitsChanged() { + // New session limits may mean that the state machine will need to start/stop. + this._updateStateMachine(); + + // Checkpoint current active usage to the parental controls timer daemon, + // so that it can make a correct estimate of the remaining screen time. + // Wellbeing monitoring uses the stored edge events (state transitions), + // whereas the parental controls timer daemon stores time periods only after + // one is finished, which is why it cannot know the start of the usage period, + // without recording it first. Recording will trigger EstimatedTimesChanged + // signal, which handler updates the estimated times and state. + if (this._stateTransitions.length > 0 && + this._stateTransitions.at(-1).newState === UserState.ACTIVE) { + const startSecs = this._stateTransitions.at(-1).wallTimeSecs; + const endSecs = this.getCurrentTime(); + await this._recordUsage(startSecs, endSecs); + } + } + + async _updateEstimatedTimes() { + const [, timesSecs] = await this._timerChildProxy.GetEstimatedTimesAsync('login-session'); + if (timesSecs['']) + this._estimatedTimes = timesSecs['']; + else + this._estimatedTimes = []; + + this._updateState(); + } + + _storingTransitionsEnabled() { + return this._parentalControlsManager.sessionLimitsEnabled() || + this._screenTimeLimitSettings.get_boolean('history-enabled'); + } + _updateStateMachine() { - if (!this._screenTimeLimitSettings.get_boolean('history-enabled')) { + if (!this._storingTransitionsEnabled()) { if (this._state !== TimeLimitsState.DISABLED) { this._stopStateMachine().catch( e => console.warn(`Failed to stop state machine: ${e.message}`)); @@ -308,7 +408,7 @@ export const TimeLimitsManager = GObject.registerClass({ this._lastStateChangeTimeSecs = 0; this.notify('state'); - if (this._screenTimeLimitSettings.get_boolean('history-enabled')) { + if (this._storingTransitionsEnabled()) { // Add a fake transition to show the shutdown. if (this._userState !== UserState.INACTIVE) { const nowSecs = this.getCurrentTime(); @@ -497,6 +597,13 @@ export const TimeLimitsManager = GObject.registerClass({ * if the system real time clock changes (relative to the monotonic clock). */ async _loadTransitions() { + // When parental controls session limits are enabled, additionally update + // the cached estimated times from the parental controls timer daemon. + if (this._parentalControlsManager.sessionLimitsEnabled()) { + await this._updateEstimatedTimes(); + console.debug('TimeLimitsManager: Loaded time estimates from the timer daemon'); + } + const file = this._historyFile; let contents; @@ -605,6 +712,30 @@ export const TimeLimitsManager = GObject.registerClass({ this._stateTransitions = newTransitions; + // Record all of the usage records to the parental controls timer daemon + // if the parental controls session limits are in place. + if (this._parentalControlsManager.sessionLimitsEnabled()) { + for (var j = 0; j < this._stateTransitions.length - 1; j++) { + const start = this._stateTransitions[j]; + const end = this._stateTransitions[j + 1]; + + // Make sure the transition is correct + if (start['newState'] !== UserState.ACTIVE || + end['newState'] !== UserState.INACTIVE) + continue; + + const startSecs = start['wallTimeSecs']; + const endSecs = end['wallTimeSecs']; + + // Avoid re-sending the old state transitions + if (endSecs <= this._latestUsageEndSecs) + continue; + + /* eslint-disable-next-line no-await-in-loop */ + await this._recordUsage(startSecs, endSecs); + } + } + if (this._stateTransitions.length === 0) { try { await file.delete(this._cancellable); @@ -775,24 +906,63 @@ export const TimeLimitsManager = GObject.registerClass({ if (startOfTodaySecs > this._lastStateChangeTimeSecs) newState = TimeLimitsState.ACTIVE; + const [, currentSessionStart, currentSessionEnd, nextSessionStart] = this._estimatedTimes; + const sessionLimitsEnabled = this._parentalControlsManager.sessionLimitsEnabled(); + + if (sessionLimitsEnabled) { + // Parental controls session limits have either just been enabled + // and the estimated times have not been cached yet, or disabled + // but the manager has not detected that yet, so skip updating state. + if (nextSessionStart === undefined || currentSessionEnd === undefined) + return; + } + + const sessionLimitsDebug = sessionLimitsEnabled ? 'current session start: ' + + `${this._unixToString(currentSessionStart)}, current session end: ` + + `${this._unixToString(currentSessionEnd)}` : 'disabled'; + console.debug('TimeLimitsManager: Parental controls session limits: ' + + `${sessionLimitsDebug}`); + // 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: ' + + console.debug('TimeLimitsManager: Wellbeing active time today: ' + `${activeTimeTodaySecs}s, daily limit ${dailyLimitDebug}`); - if (dailyLimitEnabled && activeTimeTodaySecs >= dailyLimitSecs) { + + // Update TimeLimitsState. When the user is inactive, there's no point + // scheduling anything until they become active again. + if (sessionLimitsEnabled) { + if (nextSessionStart <= nowSecs) { + // Just entered daily schedule, so update cached estimated times + // which will perform state update afterwards. + this._updateEstimatedTimes(); + return; + } + + if (nowSecs >= currentSessionEnd) { + newState = TimeLimitsState.LIMIT_REACHED; + + // Schedule an update for when the session limit will be reset again. + this._scheduleUpdateState(nextSessionStart - nowSecs); + } else if (this._userState === UserState.ACTIVE) { + newState = TimeLimitsState.ACTIVE; + + // Schedule an update for when we expect the session limit will be reached. + this._scheduleUpdateState(currentSessionEnd - nowSecs); + } + } else if (dailyLimitEnabled && activeTimeTodaySecs >= dailyLimitSecs) { newState = TimeLimitsState.LIMIT_REACHED; - // Schedule an update for when the limit will be reset again. + // Schedule an update for when the daily 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. + // Schedule an update for when we expect the daily limit will be reached. if (dailyLimitEnabled) this._scheduleUpdateState(dailyLimitSecs - activeTimeTodaySecs); } else { @@ -802,6 +972,9 @@ export const TimeLimitsManager = GObject.registerClass({ // Update the saved state. if (newState !== this._state) { + console.debug('TimeLimitsManager: State changed from ' + + `${timeLimitsStateToString(this._state)} ` + + `to ${timeLimitsStateToString(newState)}`); this._state = newState; this._lastStateChangeTimeSecs = nowSecs; this.notify('state'); @@ -838,6 +1011,15 @@ export const TimeLimitsManager = GObject.registerClass({ return this._state; } + /** + * Whether the parental controls session limits are enabled. + * + * @type {boolean} + */ + get sessionLimitsEnabled() { + return this._parentalControlsManager.sessionLimitsEnabled(); + } + /** * 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 @@ -888,8 +1070,6 @@ export const TimeLimitsManager = GObject.registerClass({ /** * Whether the daily limit is enabled. * - * If false, screen usage information is recorded, but no limit is enforced. - * * @type {boolean} */ get dailyLimitEnabled() { @@ -911,13 +1091,15 @@ export const TimeLimitsManager = GObject.registerClass({ * 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. + * into account, and whether parental controls session limits are enabled. */ export const TimeLimitsDispatcher = GObject.registerClass( class TimeLimitsDispatcher extends GObject.Object { constructor(manager) { super(); + this._systemActions = new SystemActions.getDefault(); + this._manager = manager; this._manager.connectObject( 'notify::state', this._onStateChanged.bind(this), @@ -981,6 +1163,16 @@ class TimeLimitsDispatcher extends GObject.Object { } case TimeLimitsState.LIMIT_REACHED: { + if (this._manager.sessionLimitsEnabled) { + // Trying to lock the screen will fail if the action is not + // available. This happens when admin disables lock screen, + // or when not running under GDM/logind. Those are corner cases + // which don’t need to be handled. + if (this._systemActions.canLockScreen) + this._systemActions.activateLockScreen(); + break; + } + this._ensureEnabled(); if (this._manager.grayscaleEnabled) { @@ -1149,8 +1341,10 @@ class TimeLimitsNotificationSource extends GObject.Object { 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) { + // transition from any state to LIMIT_REACHED. Noop if parental + // controls session limits are enabled, since we’re locking then. + if (!this._manager.sessionLimitsEnabled && + this._previousState !== TimeLimitsState.LIMIT_REACHED) { this._ensureNotification({ title: _('Screen Time Limit Reached'), body: _('It’s time to stop using the device'), diff --git a/tests/unit/timeLimitsManager.js b/tests/unit/timeLimitsManager.js index 92cba542b5..d7c5a1724a 100644 --- a/tests/unit/timeLimitsManager.js +++ b/tests/unit/timeLimitsManager.js @@ -36,7 +36,9 @@ const {TimeLimitsState, UserState} = TimeLimitsManager; * 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`. + * logind `User` D-Bus object, a mock proxy around `Malcontent.Manager` object, + * a mock proxy of the malcontent-timerd `Child` 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 @@ -53,7 +55,7 @@ const {TimeLimitsState, UserState} = TimeLimitsManager; * daily time limit is reset at a specific time each morning. */ class TestHarness { - constructor(settings, historyFileContents = null) { + constructor(settings, historyFileContents = null, sessionLimitsEnabled = false) { this._currentTimeSecs = 0; this._clockOffset = 100; // make the monotonic clock lag by 100s, arbitrarily this._nextSourceId = 1; @@ -65,6 +67,8 @@ class TestHarness { this._settingsChangedDailyLimitSecondsCallback = null; this._settingsChangedDailyLimitEnabledCallback = null; this._settingsChangedGrayscaleCallback = null; + this._parentalControlsManagerSessionLimitsChangedCallback = null; + this._timerChildProxyEstimatedTimesChangedCallback = 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 @@ -88,6 +92,13 @@ class TestHarness { this._historyFile = file; + this._sessionLimitsEnabled = sessionLimitsEnabled; + this._limitReached = false; + this._currentSessionStartTimeSecs = 0; + this._currentSessionEstimatedEndTimeSecs = GLib.MAX_UINT64; + this._nextSessionStartTimeSecs = GLib.MAX_UINT64; + this._nextSessionEstimatedEndTimeSecs = GLib.MAX_UINT64; + // And a mock D-Bus proxy for logind. const harness = this; @@ -316,6 +327,55 @@ class TestHarness { }); } + /** + * Add a parental controls manager session limits changed event to the event + * queue. This simulates the `session-limits-changed` signal of the + * `Malcontent.Manager` object, notifying gnome-shell that the user’s + * session limits were changed, for example from malcontent-control. + * + * @param {string} timeStr Date/Time the event happens, in ISO 8601 format. + * @param {boolean} newSessionLimitsEnabled New session limits enabled setting. + */ + addParentalControlsManagerSessionLimitsChangedEvent(timeStr, newSessionLimitsEnabled) { + return this._insertEvent({ + type: 'parental-controls-manager-session-limits-changed', + time: TestHarness.timeStrToSecs(timeStr), + newSessionLimitsEnabled, + }); + } + + /** + * Add a timer child proxy estimated times changed event to the event + * queue. This simulates the `EstimatedTimesChanged` signal of the + * `org.freedesktop.MalcontentTimer1.Child` object, notifying gnome-shell + * that the user’s estimated session times were changed, for example as a + * result of gnome-shell recording usage. + * + * @param {string} timeStr Date/Time the event happens, in ISO 8601 format. + * @param {boolean} newLimitReached New limit reached setting. + * @param {number} newCurrentSessionStartTimeSecs New current session start + * time in seconds. + * @param {number} newCurrentSessionEstimatedEndTimeSecs New current session + * estimated end time in seconds. + * @param {number} newNextSessionStartTimeSecs New next session start time + * in seconds. + * @param {number} newNextSessionEstimatedEndTimeSecs New next session + * estimated end time in seconds. + */ + addTimerChildProxyEstimatedTimesChangedEvent(timeStr, newLimitReached, + newCurrentSessionStartTimeSecs, newCurrentSessionEstimatedEndTimeSecs, + newNextSessionStartTimeSecs, newNextSessionEstimatedEndTimeSecs) { + return this._insertEvent({ + type: 'timer-child-proxy-estimated-times-changed', + time: TestHarness.timeStrToSecs(timeStr), + newLimitReached, + newCurrentSessionStartTimeSecs, + newCurrentSessionEstimatedEndTimeSecs, + newNextSessionStartTimeSecs, + newNextSessionEstimatedEndTimeSecs, + }); + } + /** * Add a settings change event to the event queue. This simulates dconf * notifying gnome-shell that the user has changed a setting, for example @@ -410,6 +470,29 @@ class TestHarness { }); } + /** + * Add a state assertion event to the event queue. This is a specialised + * form of `addAssertionEvent()` which asserts that the given + * start and end date/times of the most recent usage recording equal the + * expected values at date/time `timeStr`. + * + * @param {string} timeStr Date/Time to check the times at, in ISO 8601 + * format. + * @param {string} startStr Start date/time in ISO 8601 format. + * @param {string} endStr End date/time in ISO 8601 format. + */ + expectUsage(timeStr, startStr, endStr) { + return this.addAssertionEvent(timeStr, () => { + const [start, end] = this._timeStore; + expect(start) + .withContext('start') + .toEqual(TestHarness.timeStrToSecs(startStr)); + expect(end) + .withContext('end') + .toEqual(TestHarness.timeStrToSecs(endStr)); + }); + } + _popEvent() { return this._events.shift(); } @@ -490,7 +573,9 @@ class TestHarness { this.mockClock, this.mockLoginManagerFactory, this.mockLoginUserFactory, - this.mockSettingsFactory); + this.mockSettingsFactory, + this.mockParentalControlsManagerFactory, + this.mockTimerChildProxyFactory); } /** @@ -529,6 +614,92 @@ class TestHarness { }; } + /** + * Get a mock parental controls manager factory for use in the + * `TimeLimitsManager` under test. This is an object providing constructors + * for `ParentalControlsManager` objects, which are proxies around the + * `Malcontent.Manager` objects. Each constructor returns a basic + * implementation of `ParentalControlsManager` which uses the daily limit + * and daily schedule variables passed to `TestHarness` in its constructor + * in the settings dictionary. + * + * This has an extra layer of indirection to match `mockSettingsFactory`. + */ + get mockParentalControlsManagerFactory() { + return { + new: () => { + return { + connectObject: (...args) => { + const [ + sessionLimitsChangedStr, sessionLimitsChangedCallback, + obj, + ] = args; + + if (sessionLimitsChangedStr !== 'session-limits-changed' || + typeof obj !== 'object') + fail('ParentalControlsManager.connectObject() not called in expected way'); + if (this._parentalControlsManagerSessionLimitsChangedCallback !== null) + fail('ParentalControlsManager signals already connected'); + + this._parentalControlsManagerSessionLimitsChangedCallback = sessionLimitsChangedCallback; + }, + sessionLimitsEnabled: () => { + return this._sessionLimitsEnabled; + }, + }; + }, + }; + } + + /** + * Get a mock timer child proxy factory for use in the `TimeLimitsManager` + * under test. This is an object providing constructors for `TimerChildProxy` + * objects, which are proxies around the `org.freedesktop.MalcontentTimer1.Child` + * D-Bus API. Each constructor returns a basic implementation of `TimerChildProxy`. + * + * This has an extra layer of indirection to match `mockSettingsFactory`. + */ + get mockTimerChildProxyFactory() { + return { + new: () => { + return { + connectSignal: (...args) => { + const [ + estimatedTimesChangedStr, estimatedTimesChangedCallback, + ] = args; + + if (estimatedTimesChangedStr !== 'EstimatedTimesChanged') + fail('TimerChildProxy.connectSignal() not called in expected way'); + if (this._timerChildProxyEstimatedTimesChangedCallback !== null) + fail('TimerChildProxy signals already connected'); + + this._timerChildProxyEstimatedTimesChangedCallback = estimatedTimesChangedCallback; + }, + RecordUsageAsync: args => { + return new Promise(resolve => { + const [[startSecs, endSecs]] = args; + this._timeStore = [startSecs, endSecs]; + resolve(); + }); + }, + GetEstimatedTimesAsync: _ => { + return new Promise(resolve => { + resolve([0, { + '': [ + this._limitReached, + this._currentSessionStartTimeSecs, + this._currentSessionEstimatedEndTimeSecs, + this._nextSessionStartTimeSecs, + this._nextSessionEstimatedEndTimeSecs, + ], + }]); + }); + }, + }; + }, + }; + } + /** * Get a mock settings factory for use in the `TimeLimitsManager` under test. * This is an object providing constructors for `Gio.Settings` objects. Each @@ -621,6 +792,22 @@ class TestHarness { if (this._loginUserPropertiesChangedCallback) this._loginUserPropertiesChangedCallback(); break; + case 'parental-controls-manager-session-limits-changed': + this._sessionLimitsEnabled = event.newSessionLimitsEnabled; + + if (this._parentalControlsManagerSessionLimitsChangedCallback) + this._parentalControlsManagerSessionLimitsChangedCallback(); + break; + case 'timer-child-proxy-estimated-times-changed': + this._limitReached = event.newLimitReached; + this._currentSessionStartTimeSecs = event.newCurrentSessionStartTimeSecs; + this._currentSessionEstimatedEndTimeSecs = event.newCurrentSessionEstimatedEndTimeSecs; + this._nextSessionStartTimeSecs = event.newNextSessionStartTimeSecs; + this._nextSessionEstimatedEndTimeSecs = event.newNextSessionEstimatedEndTimeSecs; + + if (this._timerChildProxyEstimatedTimesChangedCallback) + this._timerChildProxyEstimatedTimesChangedCallback(); + break; case 'settings-change': this._settings[event.schemaId][event.key] = event.newValue; @@ -724,6 +911,35 @@ describe('Time limits manager', () => { harness.run(); }); + it('cannot be disabled via GSettings if parental controls enabled', () => { + const harness = new TestHarness( + { + 'org.gnome.desktop.screen-time-limits': { + 'history-enabled': false, + 'daily-limit-enabled': false, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, + null, + { + 'parental-enabled': true, + } + ); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = harness.createTimeLimitsManager(); + + harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T15:00:00Z', timeLimitsManager, TimeLimitsState.ACTIVE); + 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.ACTIVE, + }); + 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': { @@ -781,6 +997,37 @@ describe('Time limits manager', () => { harness.run(); }); + it('tracks a single day’s usage with parental controls limit enabled', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'history-enabled': true, + 'daily-limit-enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, null, true); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T10:00:01Z', + false, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T13:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T03:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T10:00:02Z', true + ); + + const timeLimitsManager = harness.createTimeLimitsManager(); + + harness.expectState('2024-06-01T10:00:03Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T12:59:59Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T13:00:01Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + harness.shutdownManager('2024-06-01T13:00:02Z', timeLimitsManager); + + harness.run(); + }); + it('tracks a single day’s usage early in the morning', () => { const harness = new TestHarness({ 'org.gnome.desktop.screen-time-limits': { @@ -913,6 +1160,189 @@ describe('Time limits manager', () => { harness.run(); }); + it('resets usage if the parental controls session limit is changed', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'history-enabled': true, + 'daily-limit-enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, null, true); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T10:00:01Z', + false, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T13:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T03:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T10:00:02Z', true + ); + const timeLimitsManager = harness.createTimeLimitsManager(); + + // Run before the limit is reached + harness.expectState('2024-06-01T10:00:03Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T12:00:00Z', timeLimitsManager, TimeLimitsState.ACTIVE); + + // Change session limits part-way through the day in a way which means + // the child still has time left that day + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T12:30:00Z', + false, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T04:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T12:30:01Z', true + ); + + // Run before the limit is reached + harness.expectState('2024-06-01T12:30:02Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T13:30:00Z', timeLimitsManager, TimeLimitsState.ACTIVE); + + // Change session limits part-way through the day in a way which means + // the child no longer has time left that day + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T13:45:00Z', + true, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T13:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T03:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T13:45:01Z', true + ); + + // Run before the limit is over + harness.expectState('2024-06-01T13:45:02Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + harness.expectState('2024-06-01T14:00:00Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + + // Change session limits after the child has run out of time in a way + // which means the child has still run out of time + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T14:30:00Z', + true, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T04:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T14:30:01Z', true + ); + + // Run before the limit is over + harness.expectState('2024-06-01T14:30:02Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + harness.expectState('2024-06-01T15:00:00Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + + // Change session limits after the child has run out of time in a way + // which means the child has more time remaining + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T15:30:00Z', + true, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T16:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T06:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T15:30:01Z', true + ); + + // Run until the limit is reached + harness.expectState('2024-06-01T15:30:02Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T16:00:05Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + + // Run before the limit is over + harness.expectState('2024-06-01T16:00:06Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + harness.expectState('2024-06-01T23:59:59Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + + // After the limit is over, GetEstimatedTimes will return new values of + // the current and next sessions, so change the values accordingly + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-02T00:00:00Z', + false, + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T06:00:00Z'), + TestHarness.timeStrToSecs('2024-06-03T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-03T06:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-02T00:00:01Z', true + ); + + // Run after the limit is over + harness.expectState('2024-06-02T00:00:02Z', timeLimitsManager, TimeLimitsState.ACTIVE); + + harness.shutdownManager('2024-06-02T01:00:00Z', timeLimitsManager); + + harness.run(); + }); + + it('records usage correctly to the malcontent timer daemon', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'history-enabled': false, + 'daily-limit-enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, null, true); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T10:00:01Z', + false, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T20:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T10:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T10:00:02Z', true + ); + const timeLimitsManager = harness.createTimeLimitsManager(); + + // Session limits changed + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T11:00:00Z', true + ); + harness.expectUsage('2024-06-01T11:00:01Z', '2024-06-01T10:00:00Z', '2024-06-01T11:00:00Z'); + + // User state changed + harness.addLoginUserStateChangeEvent('2024-06-01T12:00:00Z', 'lingering', true); + harness.expectUsage('2024-06-01T12:00:01Z', '2024-06-01T10:00:00Z', '2024-06-01T12:00:00Z'); + + harness.addLoginUserStateChangeEvent('2024-06-01T13:00:00Z', 'active', false); + harness.expectUsage('2024-06-01T13:00:01Z', '2024-06-01T10:00:00Z', '2024-06-01T12:00:00Z'); + + harness.addLoginUserStateChangeEvent('2024-06-01T14:00:00Z', 'offline', true); + harness.expectUsage('2024-06-01T14:00:01Z', '2024-06-01T13:00:00Z', '2024-06-01T14:00:00Z'); + + harness.addLoginUserStateChangeEvent('2024-06-01T15:00:00Z', 'active', false); + harness.expectUsage('2024-06-01T15:00:01Z', '2024-06-01T13:00:00Z', '2024-06-01T14:00:00Z'); + + // Disable wellbeing & parental + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T16:00:00Z', false + ); + harness.expectUsage('2024-06-01T16:00:01Z', '2024-06-01T15:00:00Z', '2024-06-01T16:00:00Z'); + + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T17:00:00Z', true + ); + harness.expectUsage('2024-06-01T17:00:01Z', '2024-06-01T15:00:00Z', '2024-06-01T16:00:00Z'); + + // Shutdown + harness.shutdownManager('2024-06-01T18:00:00Z', timeLimitsManager); + harness.expectUsage('2024-06-01T18:00:00Z', '2024-06-01T17:00:00Z', '2024-06-01T18:00:00Z'); + + harness.run(); + }); + it('tracks usage correctly from an existing history file', () => { const harness = new TestHarness({ 'org.gnome.desktop.screen-time-limits': { -- GitLab