timeLimitsManager: Store screen time state on suspend/resume

There are two main changes in this commit:
 * Listen to the `prepare-for-sleep` signal from `LoginManager`, which
   is emitted just before suspending and just after resuming. When the
   signal is received, update the user’s screen time state (active or
   inactive), add a transition if necessary, and save the screen time
   history if necessary.
 * Factor the `preparingForSleep` property of `LoginManager` into the
   user’s screen time state, meaning that the user will be considered
   inactive between the system going for suspend and coming back from
   resume.

The rest of the changes in the commit are boilerplate to allow for this
functionality to be unit tested.

Signed-off-by: Philip Withnall <pwithnall@gnome.org>
Closes: https://gitlab.gnome.org/GNOME/gnome-shell/-/issues/8185
Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3643>
This commit is contained in:
Philip Withnall 2025-02-19 15:54:13 +00:00 committed by Marge Bot
parent 9dd5f7a8a8
commit 6a43b6f551
2 changed files with 149 additions and 23 deletions

View File

@ -1,6 +1,6 @@
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
//
// Copyright 2024 GNOME Foundation, Inc.
// Copyright 2024, 2025 GNOME Foundation, Inc.
//
// This is a GNOME Shell component to support screen time limits and statistics.
//
@ -84,13 +84,15 @@ function userStateToString(userState) {
*
* 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).
* 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.
* 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 `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
@ -121,7 +123,7 @@ export const TimeLimitsManager = GObject.registerClass({
'daily-limit-reached': {},
},
}, class TimeLimitsManager extends GObject.Object {
constructor(historyFile, clock, loginUserFactory, settingsFactory) {
constructor(historyFile, clock, loginManagerFactory, loginUserFactory, settingsFactory) {
super();
// Allow these few bits of global state to be overridden for unit testing
@ -143,6 +145,9 @@ export const TimeLimitsManager = GObject.registerClass({
return timeChangeSource.attach(null);
},
};
this._loginManagerFactory = loginManagerFactory ?? {
new: LoginManager.getLoginManager,
};
this._loginUserFactory = loginUserFactory ?? {
newAsync: () => {
const loginManager = LoginManager.getLoginManager();
@ -163,6 +168,7 @@ export const TimeLimitsManager = GObject.registerClass({
this._state = TimeLimitsState.DISABLED;
this._stateTransitions = [];
this._cancellable = null;
this._loginManager = null;
this._loginUser = null;
this._lastStateChangeTimeSecs = 0;
this._timerId = 0;
@ -252,6 +258,13 @@ export const TimeLimitsManager = GObject.registerClass({
}
// Start listening for notifications to the users state.
this._loginManager = this._loginManagerFactory.new();
this._loginManager.connectObject(
'prepare-for-sleep',
() => this._updateUserState(true).catch(
e => console.warn(`Failed to update user state: ${e.message}`)),
this);
this._loginUser = await this._loginUserFactory.newAsync();
this._loginUser.connectObject(
'g-properties-changed',
@ -277,6 +290,9 @@ export const TimeLimitsManager = GObject.registerClass({
this._loginUser?.disconnectObject(this);
this._loginUser = null;
this._loginManager?.disconnectObject(this);
this._loginManager = null;
this._state = TimeLimitsState.DISABLED;
this._lastStateChangeTimeSecs = 0;
this.notify('state');
@ -360,7 +376,9 @@ export const TimeLimitsManager = GObject.registerClass({
}
_calculateUserStateFromLogind() {
const isActive = this._loginUser.State === 'active' && !this._loginUser.IdleHint;
const isActive = this._loginUser.State === 'active' &&
!this._loginUser.IdleHint &&
!this._loginManager.preparingForSleep;
return isActive ? UserState.ACTIVE : UserState.INACTIVE;
}

View File

@ -1,6 +1,6 @@
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
//
// Copyright 2024 GNOME Foundation, Inc.
// Copyright 2024, 2025 GNOME Foundation, Inc.
//
// This is a GNOME Shell component to support screen time limits and statistics.
//
@ -73,6 +73,9 @@ class TestHarness {
this._loginUserPropertiesChangedCallback = null;
this._currentPreparingForSleepState = false;
this._loginManagerPrepareForSleepCallback = null;
// Create a fake history file containing the given contents. Or, if no
// contents are given, reserve a distinct new history file name but then
// delete it so it doesnt exist for the manager.
@ -87,6 +90,28 @@ class TestHarness {
// And a mock D-Bus proxy for logind.
const harness = this;
class MockLoginManager {
connectObject(signalName, callback, unusedObject) {
if (signalName === 'prepare-for-sleep') {
if (harness._loginManagerPrepareForSleepCallback !== null)
fail('Duplicate prepare-for-sleep connection');
harness._loginManagerPrepareForSleepCallback = callback;
} else {
// No-op for mock purposes
}
}
disconnectObject(unused) {
// Very simple implementation for mock purposes
harness._loginManagerPrepareForSleepCallback = null;
}
get preparingForSleep() {
return harness._currentPreparingForSleepState;
}
}
class MockLoginUser {
connectObject(signalName, callback, unusedObject) {
if (signalName === 'g-properties-changed') {
@ -112,6 +137,7 @@ class TestHarness {
}
}
this._mockLoginManager = new MockLoginManager();
this._mockLoginUser = new MockLoginUser();
}
@ -223,6 +249,34 @@ class TestHarness {
});
}
/**
* Add a pair of sleep and resume events to the event queue. This simulates
* the machine being asleep (suspended) and then resumed after a period of
* time. No other events should be inserted into the queue between these
* two.
*
* This simulates the [D-Bus API for logind](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html#The%20Manager%20Object)
* notifying to prepare for sleep at date/time `timeStr`, then sleeping,
* then notifying that the machine has resumed from sleep at date/time
* `timeStr` plus `duration` (in seconds).
*
* @param {string} timeStr Date/Time the prepare-for-sleep event happens,
* in ISO 8601 format.
* @param {number} duration Duration of the sleep/suspend period, in seconds
*/
addSleepAndResumeEvent(timeStr, duration) {
this._insertEvent({
type: 'preparing-for-sleep-state-change',
time: TestHarness.timeStrToSecs(timeStr),
newPreparingForSleepState: true,
});
this._insertEvent({
type: 'preparing-for-sleep-state-change',
time: TestHarness.timeStrToSecs(timeStr) + duration,
newPreparingForSleepState: false,
});
}
/**
* Add a login user state change event to the event queue. This simulates
* the [D-Bus API for logind](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html#User%20Objects)
@ -408,6 +462,24 @@ class TestHarness {
this._currentTimeSecs = TestHarness.timeStrToSecs(timeStr);
}
/**
* Get a mock login manager factory for use in the `TimeLimitsManager` under
* test. This is an object providing a constructor for the objects returned
* by `LoginManager.getLoginManager()`. This is roughly a wrapper around the
* [`org.freedesktop.login1.Manager` D-Bus API](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html#The%20Manager%20Object).
* Each constructor returns a basic implementation of the manager which uses
* the current state from `TestHarness`.
*
* This has an extra layer of indirection to match `mockSettingsFactory`.
*/
get mockLoginManagerFactory() {
return {
new: () => {
return this._mockLoginManager;
},
};
}
/**
* Get a mock login user factory for use in the `TimeLimitsManager` under
* test. This is an object providing constructors for `LoginUser` objects,
@ -503,6 +575,12 @@ class TestHarness {
if (this._timeChangeNotify)
this._timeChangeNotify();
break;
case 'preparing-for-sleep-state-change':
this._currentPreparingForSleepState = event.newPreparingForSleepState;
if (this._loginManagerPrepareForSleepCallback)
this._loginManagerPrepareForSleepCallback(this._currentPreparingForSleepState);
break;
case 'login-user-state-change':
this._currentUserState = event.newUserState;
this._currentUserIdleHint = event.newUserIdleHint;
@ -598,7 +676,7 @@ describe('Time limits manager', () => {
},
});
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.DISABLED);
harness.expectState('2024-06-01T15:00:00Z', timeLimitsManager, TimeLimitsState.DISABLED);
@ -622,7 +700,7 @@ describe('Time limits manager', () => {
},
});
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
harness.addSettingsChangeEvent('2024-06-01T11:00:00Z',
@ -657,7 +735,7 @@ describe('Time limits manager', () => {
},
});
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
harness.expectProperties('2024-06-01T13:59:59Z', timeLimitsManager, {
@ -679,7 +757,7 @@ describe('Time limits manager', () => {
},
});
harness.initializeMockClock('2024-06-01T00:30:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
harness.expectState('2024-06-01T00:30:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
harness.expectProperties('2024-06-01T01:29:59Z', timeLimitsManager, {
@ -701,7 +779,7 @@ describe('Time limits manager', () => {
},
});
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
harness.expectProperties('2024-06-01T13:59:59Z', timeLimitsManager, {
@ -734,7 +812,7 @@ describe('Time limits manager', () => {
},
});
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
harness.expectProperties('2024-06-01T15:00:00Z', timeLimitsManager, {
@ -774,7 +852,7 @@ describe('Time limits manager', () => {
},
});
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
// Run until the limit is reached.
harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
@ -832,7 +910,7 @@ describe('Time limits manager', () => {
},
]));
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
// The existing history file (above) lists two active periods,
// 07:3008:00 and 08:3009:30 that morning. So the user should have
@ -868,7 +946,7 @@ describe('Time limits manager', () => {
},
]));
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
// The existing history file (above) lists one active period,
// 04:3008:50 that morning. So the user should have no time left today.
@ -907,7 +985,7 @@ describe('Time limits manager', () => {
},
}, invalidHistoryFileContents);
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
// The existing history file (above) is invalid or a no-op and
// should be ignored.
@ -953,7 +1031,7 @@ describe('Time limits manager', () => {
},
]));
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
// The existing history file (above) lists two active periods,
// one of which is a long time ago and the other is this morning in
@ -1018,7 +1096,7 @@ describe('Time limits manager', () => {
},
]));
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
// The existing history file (above) lists one active period,
// 04:3008:50 that morning IN THE YEAR 3000. This could have resulted
@ -1053,7 +1131,7 @@ describe('Time limits manager', () => {
},
]));
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
harness.addSettingsChangeEvent('2024-06-01T10:00:02Z',
@ -1089,7 +1167,7 @@ describe('Time limits manager', () => {
},
]));
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
harness.expectState('2024-06-01T14:00:01Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED);
@ -1127,7 +1205,7 @@ describe('Time limits manager', () => {
},
});
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
// Use up 2h of the daily limit.
harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
@ -1164,7 +1242,7 @@ describe('Time limits manager', () => {
},
});
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginUserFactory, harness.mockSettingsFactory);
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
// Use up 2h of the daily limit.
harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
@ -1191,4 +1269,34 @@ describe('Time limits manager', () => {
harness.run();
});
it('doesnt count usage when asleep/suspended', () => {
const harness = new TestHarness({
'org.gnome.desktop.screen-time-limits': {
'history-enabled': true,
'daily-limit-enabled': true,
'daily-limit-seconds': 4 * 60 * 60,
},
});
harness.initializeMockClock('2024-06-01T10:00:00Z');
const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory);
// Use up 2h of the daily limit.
harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
harness.expectProperties('2024-06-01T12:00:00Z', timeLimitsManager, {
'state': TimeLimitsState.ACTIVE,
'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'),
});
// Sleep for 3h and that shouldnt use up limit time.
harness.addSleepAndResumeEvent('2024-06-01T12:00:01Z', 3 * 60 * 60);
harness.expectProperties('2024-06-01T15:00:02Z', timeLimitsManager, {
'state': TimeLimitsState.ACTIVE,
'dailyLimitTime': TestHarness.timeStrToSecs('2024-06-01T17:00:00Z'),
});
harness.shutdownManager('2024-06-01T15:20:00Z', timeLimitsManager);
harness.run();
});
});