From df3b4d302d43519a460ceb897b4693696f3d0449 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Mon, 4 Mar 2024 16:13:53 +0000 Subject: [PATCH] breakManager: Add new state machine for screen time/health breaks This implements health break reminder support in gnome-shell. It depends on a few bits and bobs from other modules: - New settings schemas in gsettings-desktop-schemas (released in 47.beta, which Mutter already depends on) - A settings UI in gnome-control-center - User documentation in gnome-user-docs It implements the design from https://gitlab.gnome.org/Teams/Design/settings-mockups/-/blob/master/wellbeing/wellbeing.png. The core of the implementation is `BreakManager`, which is a state machine which uses the Mutter `IdleMonitor` to track whether the user is, or should be, in a screen time break. The `BreakDispatcher` is based on top of this, and controls showing notifications, countdown timers, screen fades, the lock shield, etc. to make the user aware of upcoming or due breaks, as per their notification preferences. Unit tests are included to check that `BreakManager` works. These provide mock implementations of basic GLib clock functions, the `IdleMonitor` and `Gio.Settings` in order to test the state machine in faster-than-real-time. Signed-off-by: Philip Withnall See: https://gitlab.gnome.org/Teams/Design/initiatives/-/issues/130 Part-of: --- data/dbus-interfaces/meson.build | 1 + .../org.gnome.Shell.ScreenTime.xml | 38 + .../gnome-shell-dbus-interfaces.gresource.xml | 1 + data/gnome-shell-icons.gresource.xml | 1 + .../scalable/status/wellbeing-symbolic.svg | 11 + data/theme/gnome-shell-sass/widgets/_osd.scss | 2 +- js/js-resources.gresource.xml | 1 + js/misc/breakManager.js | 1439 +++++++++++++++++ js/ui/main.js | 9 + js/ui/shellDBus.js | 28 + meson.build | 2 +- po/POTFILES.in | 1 + tests/meson.build | 1 + tests/unit/breakManager.js | 513 ++++++ 14 files changed, 2046 insertions(+), 2 deletions(-) create mode 100644 data/dbus-interfaces/org.gnome.Shell.ScreenTime.xml create mode 100644 data/icons/scalable/status/wellbeing-symbolic.svg create mode 100644 js/misc/breakManager.js create mode 100644 tests/unit/breakManager.js diff --git a/data/dbus-interfaces/meson.build b/data/dbus-interfaces/meson.build index c96bbbb4d..81281b64d 100644 --- a/data/dbus-interfaces/meson.build +++ b/data/dbus-interfaces/meson.build @@ -4,6 +4,7 @@ dbus_interfaces = [ 'org.gnome.Shell.PadOsd.xml', 'org.gnome.Shell.Screencast.xml', 'org.gnome.Shell.Screenshot.xml', + 'org.gnome.Shell.ScreenTime.xml', 'org.gnome.ShellSearchProvider.xml', 'org.gnome.ShellSearchProvider2.xml' ] diff --git a/data/dbus-interfaces/org.gnome.Shell.ScreenTime.xml b/data/dbus-interfaces/org.gnome.Shell.ScreenTime.xml new file mode 100644 index 000000000..4749c85b3 --- /dev/null +++ b/data/dbus-interfaces/org.gnome.Shell.ScreenTime.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/data/gnome-shell-dbus-interfaces.gresource.xml b/data/gnome-shell-dbus-interfaces.gresource.xml index c3954ba39..8eccc82bf 100644 --- a/data/gnome-shell-dbus-interfaces.gresource.xml +++ b/data/gnome-shell-dbus-interfaces.gresource.xml @@ -51,6 +51,7 @@ org.gnome.Shell.PortalHelper.xml org.gnome.Shell.Screencast.xml org.gnome.Shell.Screenshot.xml + org.gnome.Shell.ScreenTime.xml org.gnome.Shell.Wacom.PadOsd.xml org.gnome.Shell.WeatherIntegration.xml org.gnome.Shell.xml diff --git a/data/gnome-shell-icons.gresource.xml b/data/gnome-shell-icons.gresource.xml index f15d0c0e8..a64234092 100644 --- a/data/gnome-shell-icons.gresource.xml +++ b/data/gnome-shell-icons.gresource.xml @@ -53,6 +53,7 @@ scalable/status/screen-privacy-symbolic.svg scalable/status/switch-on-symbolic.svg scalable/status/switch-off-symbolic.svg + scalable/status/wellbeing-symbolic.svg diff --git a/data/icons/scalable/status/wellbeing-symbolic.svg b/data/icons/scalable/status/wellbeing-symbolic.svg new file mode 100644 index 000000000..569f27810 --- /dev/null +++ b/data/icons/scalable/status/wellbeing-symbolic.svg @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/data/theme/gnome-shell-sass/widgets/_osd.scss b/data/theme/gnome-shell-sass/widgets/_osd.scss index 13a77bca3..16b3bbc61 100644 --- a/data/theme/gnome-shell-sass/widgets/_osd.scss +++ b/data/theme/gnome-shell-sass/widgets/_osd.scss @@ -35,7 +35,7 @@ $osd_levelbar_height:6px; } // Monitor number label -.osd-monitor-label { +.osd-monitor-label, .osd-break-countdown-label { background-color: -st-accent-color; color: -st-accent-fg-color; border-radius: $modal_radius; diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml index fda48d398..898c61177 100644 --- a/js/js-resources.gresource.xml +++ b/js/js-resources.gresource.xml @@ -15,6 +15,7 @@ extensions/sharedInternals.js misc/animationUtils.js + misc/breakManager.js misc/config.js misc/dateUtils.js misc/dbusErrors.js diff --git a/js/misc/breakManager.js b/js/misc/breakManager.js new file mode 100644 index 000000000..748a862fc --- /dev/null +++ b/js/misc/breakManager.js @@ -0,0 +1,1439 @@ +// Copyright 2024 GNOME Foundation, Inc. +// +// This is a GNOME Shell component to support break reminders and screen time +// 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 Meta from 'gi://Meta'; +import Shell from 'gi://Shell'; +import St from 'gi://St'; + +import * as Gettext from 'gettext'; +import * as Lightbox from '../ui/lightbox.js'; +import * as Main from '../ui/main.js'; +import * as MessageTray from '../ui/messageTray.js'; +import * as SystemActions from './systemActions.js'; + +export const MIN_BREAK_LENGTH_SECONDS = 10; + +const BREAK_OVERDUE_TIME_SECONDS = 60; // time after a break is due when the user is notified it’s overdue +const BREAK_UPCOMING_NOTIFICATION_TIME_SECONDS = [2 * 60]; // notify the user 2min before a break is due; this must be kept in descending order +const BREAK_COUNTDOWN_TIME_SECONDS = 60; + +const LIGHTBOX_FADE_TIME_SECONDS = 3; +const LIGHTBOX_FADE_FACTOR = 0.6; + +/** @enum {number} */ +const IdleState = { + /* session is active */ + ACTIVE: 1, + /* session is idle */ + IDLE: 2, +}; + +/** @enum {number} */ +/* This enum is exposed over D-Bus (/org/gnome/Shell/ScreenTime) so is + * effectively public API */ +export const BreakState = { + /* break reminders are disabled */ + DISABLED: 0, + /* break reminders are enabled, user is active, no break is needed yet */ + ACTIVE: 1, + /* user is idle, but no break is needed */ + IDLE: 2, + /* a break is needed and the user is taking it */ + IN_BREAK: 3, + /* a break is needed but the user is still active */ + BREAK_DUE: 4, +}; + +/* The code supports whatever break types the user wants. However, they each + * need to be backed by a org.gnome.desktop.break-reminders.* child schema, and + * those have to be pre-defined because they all have different default values. + * So we need to validate the break types to prevent GSettings complaining about + * non-installed schemas. + * + * So this needs to mirror the child schemas defined in + * `schemas/org.gnome.desktop.break-reminders.gschema.xml.in` in + * gsettings-desktop-schemas. */ +const SUPPORTED_BREAK_TYPES = [ + 'eyesight', + 'movement', +]; + +/** + * Return a string form of `breakState`. + * + * @param {int} breakState The break state. + * @returns {string} A string form of `breakState`. + */ +export function breakStateToString(breakState) { + return Object.keys(BreakState).find(k => BreakState[k] === breakState); +} + +/** + * A manager class which tracks total active/inactive time and signals when the + * user needs to take a break (according to their break reminder preferences). + */ +export const BreakManager = GObject.registerClass({ + Properties: { + 'state': GObject.ParamSpec.int( + 'state', null, null, + GObject.ParamFlags.READABLE, + BreakState.DISABLED, BreakState.BREAK_DUE, BreakState.DISABLED), + 'last-break-end-time': GObject.ParamSpec.uint64( + 'last-break-end-time', null, null, + GObject.ParamFlags.READABLE, + 0, GLib.MAX_UINT64, 0), + 'next-break-due-time': GObject.ParamSpec.uint64( + 'next-break-due-time', null, null, + GObject.ParamFlags.READABLE, + 0, GLib.MAX_UINT64, 0), + }, + Signals: { + 'break-due': {}, + 'break-finished': {}, + 'take-break': {}, + }, +}, class BreakManager extends GObject.Object { + constructor(clock, idleMonitor, settingsFactory) { + super(); + + // Allow these two bits of global state to be overridden for unit testing + this._idleMonitor = idleMonitor ?? global.backend.get_core_idle_monitor(); + this._clock = clock ?? { + sourceRemove: GLib.source_remove, + getRealTimeSecs: () => { + return GLib.get_real_time() / GLib.USEC_PER_SEC; + }, + timeoutAddSeconds: GLib.timeout_add_seconds, + }; + this._settingsFactory = settingsFactory ?? { + new: Gio.Settings.new, + newWithPath: Gio.Settings.new_with_path, + }; + this._breakSettings = this._settingsFactory.new('org.gnome.desktop.break-reminders'); + this._breakSettings.connect('changed', () => this._updateSettings()); + + this._state = BreakState.DISABLED; + this._breakTypeSettings = new Map(); // map of breakType to GSettings + this._breakTypeSettingsChangedId = new Map(); + this._breakLastEnd = new Map(); // map of breakType to wall clock time (in seconds) + this._idleWatchId = 0; + this._activeWatchId = 0; + this._timerId = 0; + + // Start tracking timings + this._updateSettings(); + } + + _updateSettings() { + // Load settings for each enabled type of break + const selectedBreaks = this._breakSettings.get_strv('selected-breaks'); + + if (!selectedBreaks || !selectedBreaks.length) { + this._stopStateMachine(); + return false; + } + + const currentTime = this.getCurrentTime(); + + for (const breakType of SUPPORTED_BREAK_TYPES) { + if (selectedBreaks.includes(breakType) && + !this._breakTypeSettings.has(breakType)) { + // Enabling a previously disabled break + const breakSettings = this._settingsFactory.newWithPath( + `org.gnome.desktop.break-reminders.${breakType}`, + `${this._breakSettings.path}${breakType}/`); + this._breakTypeSettings.set(breakType, breakSettings); + this._breakTypeSettingsChangedId.set(breakType, + breakSettings.connect('changed', () => this._updateState(this.getCurrentTime()))); + this._breakLastEnd.set(breakType, currentTime); + } else if (!selectedBreaks.includes(breakType) && + this._breakTypeSettings.has(breakType)) { + // Disabling a previously enabled break + const breakSettings = this._breakTypeSettings.get(breakType); + breakSettings.disconnect(this._breakTypeSettingsChangedId.get(breakType)); + this._breakTypeSettings.delete(breakType); + this._breakTypeSettingsChangedId.delete(breakType); + this._breakLastEnd.delete(breakType); + } + } + + this.notify('next-break-due-time'); + + if (this._state === BreakState.DISABLED) + this._startStateMachine(); + + return true; + } + + _startStateMachine() { + this._idleWatchId = this._idleMonitor.add_idle_watch(MIN_BREAK_LENGTH_SECONDS * 1000, this._onIdleWatch.bind(this)); + + this._state = BreakState.ACTIVE; + this._currentBreakType = null; + this._currentBreakStartTime = 0; + this._idleState = IdleState.ACTIVE; + this._idleStartTime = 0; + + this._updateState(this.getCurrentTime()); + } + + _stopStateMachine() { + if (this._idleWatchId !== 0) + this._idleMonitor.remove_watch(this._idleWatchId); + this._idleWatchId = 0; + + if (this._activeWatchId !== 0) + this._idleMonitor.remove_watch(this._activeWatchId); + this._activeWatchId = 0; + + if (this._timerId !== 0) + this._clock.sourceRemove(this._timerId); + this._timerId = 0; + + for (const [breakType, breakSettings] of this._breakTypeSettings) + breakSettings.disconnect(this._breakTypeSettingsChangedId.get(breakType)); + this._breakTypeSettings = new Map(); + this._breakTypeSettingsChangedId = new Map(); + this._breakLastEnd = new Map(); + + this._state = BreakState.DISABLED; + this._currentBreakType = null; + this._currentBreakStartTime = 0; + this._idleState = IdleState.ACTIVE; + this._idleStartTime = 0; + + this.notify('state'); + } + + _onIdleWatch() { + console.assert(this._state !== BreakState.DISABLED, 'Idle received when manager is disabled'); + + const currentTime = this.getCurrentTime(); + + console.debug(`BreakManager: _onIdleWatch at ${currentTime}s`); + + // Start watching to see if the user becomes active again. + if (this._activeWatchId === 0) + this._activeWatchId = this._idleMonitor.add_user_active_watch(this._onUserActiveWatch.bind(this)); + + this._idleState = IdleState.IDLE; + this._idleStartTime = currentTime - MIN_BREAK_LENGTH_SECONDS; /* already waited MIN_BREAK_LENGTH_SECONDS for the idle watch */ + this._updateState(currentTime); + } + + _onUserActiveWatch() { + console.assert(this._state !== BreakState.DISABLED, 'Active received when manager is disabled'); + + const currentTime = this.getCurrentTime(); + + console.debug(`BreakManager: _onUserActiveWatch at ${currentTime}s`); + + // Tidy up after the active watch, which is one-shot. + // (The idle watch stays installed until explicitly removed.) + this._idleMonitor.remove_watch(this._activeWatchId); + this._activeWatchId = 0; + + this._idleState = IdleState.ACTIVE; + this._updateState(currentTime); + } + + /** + * Get the current real time, in seconds since the Unix epoch. + * + * @returns {number} + */ + getCurrentTime() { + return this._clock.getRealTimeSecs(); + } + + _updateState(currentTime) { + const idleTimeSeconds = currentTime - this._idleStartTime; + + if (this._idleState === IdleState.IDLE) { + // What kind of break are we due, if any? + const [dueBreakType, nextDueTime] = this.getNextBreakDue(currentTime); + let inBreak = false; + let dueBreakStartTime = 0; + + if (dueBreakType != null && nextDueTime <= currentTime) { + const dueBreakTypeDuration = this._breakTypeSettings.get(dueBreakType).get_uint('duration-seconds'); + + // Has the break been finished? + if (nextDueTime + dueBreakTypeDuration <= currentTime) { + if (this._state === BreakState.IN_BREAK) + this.emit('break-finished'); + } else { + // Start a timer to announce the end of the break. + inBreak = true; + dueBreakStartTime = nextDueTime; + this._scheduleUpdateState(nextDueTime + dueBreakTypeDuration - currentTime); + } + } else if (nextDueTime !== 0) { + // The user is idle but is not due a break. Store up the idle time + // and update the break end times when the user next becomes active. + // But also schedule a timer to notify the user about the start of + // their scheduled break. + console.assert(nextDueTime > currentTime, `nextDueTime (${nextDueTime}) should be greater than currentTime (${currentTime})`); + this._scheduleUpdateState(nextDueTime - currentTime); + } + + const newState = inBreak ? BreakState.IN_BREAK : BreakState.IDLE; + if (this._state !== newState) { + console.debug(`BreakManager: Changing state to ${breakStateToString(newState)}`); + this._state = newState; + this._currentBreakType = dueBreakType; + this._currentBreakStartTime = dueBreakStartTime; + this.notify('state'); + } + } else if (this._idleState === IdleState.ACTIVE) { + let emitBreakDue = false; + + this.freeze_notify(); + + // How long was the user idle before becoming active? Use that to reset the break end times. + // Reset every break type which is shorter than the idle time. This allows + // the user to start a break a little early, or take an unexpected break, + // and avoid their computer pestering them to take a break on schedule + // shortly after they get back. + console.debug(`BreakManager: idleStartTime: ${this._idleStartTime}s`); + + if (this._idleStartTime > 0) { + for (const [breakType, breakTypeSettings] of this._breakTypeSettings) { + const breakTypeDuration = breakTypeSettings.get_uint('duration-seconds'); + + if (idleTimeSeconds >= breakTypeDuration) + this._breakLastEnd.set(breakType, currentTime); + } + + this.notify('next-break-due-time'); + this.notify('last-break-end-time'); + } + + // Reset the idle start time state. + this._idleStartTime = 0; + + // Are any breaks due now? + const [dueBreakType, nextDueTime] = this.getNextBreakDue(currentTime); + const isBreakDue = dueBreakType != null && nextDueTime <= currentTime; + + console.debug(`BreakManager: nextDueTime: ${nextDueTime}s, currentTime: ${currentTime}s, dueBreakType: ${dueBreakType}`); + + if (isBreakDue) { + // Notify that a break is due if we haven’t done so already. + if (this._state === BreakState.ACTIVE) { + this._state = BreakState.BREAK_DUE; + this._currentBreakType = dueBreakType; + this._currentBreakStartTime = nextDueTime; + this.notify('state'); + emitBreakDue = true; + } + } else { + if (nextDueTime !== 0) { + // Set a timer for when the next break is due. + console.assert(nextDueTime > currentTime, `nextDueTime (${nextDueTime}) should be greater than currentTime (${currentTime})`); + this._scheduleUpdateState(nextDueTime - currentTime); + } + + if (this._state !== BreakState.ACTIVE) { + console.debug('BreakManager: Changing state to ACTIVE'); + this._state = BreakState.ACTIVE; + this._currentBreakType = null; + this._currentBreakStartTime = 0; + this.notify('state'); + } + } + + this.thaw_notify(); + if (emitBreakDue) + this.emit('break-due'); + } + } + + _scheduleUpdateState(timeout) { + if (this._timerId !== 0) + this._clock.sourceRemove(this._timerId); + + // Round up to avoid spinning + const timeoutSeconds = Math.ceil(timeout); + + console.debug(`BreakManager: Scheduling state update in ${timeoutSeconds}s`); + + this._timerId = this._clock.timeoutAddSeconds(GLib.PRIORITY_DEFAULT, timeoutSeconds, () => { + this._timerId = 0; + console.debug('BreakManager: Scheduled state update'); + this._updateState(this.getCurrentTime()); + return GLib.SOURCE_REMOVE; + }); + } + + /** + * Get a tuple of information about the break type which is currently due or + * which will be due next, relative to `fromTime`. + * 1. The type of break which is currently due or which will be due next, + * or `null` if no break is due. + * 2. The time when the next break is due; if the first member of the tuple + * is non-null, then this is the time when that break was due. This is + * zero if no break types are enabled. + * + * @param {number} fromTime ‘Current’ time to calculate from + * @returns {Array} Two-element array of {?string} break type and {number} next due time + */ + getNextBreakDue(fromTime) { + let maxDuration = 0; + let maxDurationType = null; + let dueBreakTypes = []; + let nextDueTime = 0; + + console.debug(`BreakManager: Current time: ${fromTime}s`); + + for (const [breakType, breakTypeSettings] of this._breakTypeSettings) { + const breakTypeInterval = breakTypeSettings.get_uint('interval-seconds'); + const breakTypeDuration = breakTypeSettings.get_uint('duration-seconds'); + + // Work out which break types are now due. + const breakTypeWouldBeDueAt = this._breakLastEnd.get(breakType) + breakTypeInterval; + const breakTypeIsDue = breakTypeWouldBeDueAt <= fromTime; + + console.debug(`BreakManager: Break type ${breakType}: would be due at ${breakTypeWouldBeDueAt}`); + + if (breakTypeIsDue) { + dueBreakTypes.push(breakType); + nextDueTime = breakTypeWouldBeDueAt; + + // Of the break types which are now due, which has the longest + // duration? + if (maxDuration === 0 || breakTypeDuration > maxDuration) { + maxDuration = breakTypeDuration; + maxDurationType = breakType; + } + } else if (nextDueTime === 0 || nextDueTime > breakTypeWouldBeDueAt) { + // This break type isn’t due now, but when is it due? + nextDueTime = breakTypeWouldBeDueAt; + maxDurationType = breakType; + } + } + + console.debug(`BreakManager: Due break types: ${dueBreakTypes.length ? dueBreakTypes : '(none)'}, max duration type: ${maxDurationType}, next due time: ${nextDueTime}`); + + // maxDurationType is null and nextDueTime is zero if no break types are enabled + return [maxDurationType, nextDueTime]; + } + + /** + * Current state machine state. + * + * @type {BreakState} + */ + get state() { + return this._state; + } + + /** + * String identifier for the break type which is currently in progress or + * due. + * + * Returns `null` if no break is currently due or happening. + * + * @type {?string} + */ + get currentBreakType() { + return this._currentBreakType; + } + + /** + * Start time for the break which is currently in progress , or `0` if no + * break is in progress. + * + * If the user was idle before the break started, the start time will be + * during the idle period, not the start of the idle period. + * + * @type {number} + */ + get currentBreakStartTime() { + if (this.state !== BreakState.IN_BREAK) + return 0; + + return this._currentBreakStartTime; + } + + /** + * End time for the most recent break, or `0` if a break is currently in + * progress or no breaks have happened yet. + * + * @type {number} + */ + get lastBreakEndTime() { + if (this.state === BreakState.IN_BREAK) + return 0; + + return Math.max(0, ...this._breakLastEnd.values()); + } + + /** + * Get the time when the next break is due. If a break is currently due, + * then this is the time when that break was due. This is zero if no break + * types are enabled. + * + * @param {number} fromTime ‘Current’ time to calculate from + * @returns {number} + */ + getNextBreakDueTime(fromTime) { + const [, nextDueTime] = this.getNextBreakDue(fromTime); + return nextDueTime; + } + + /** + * Time when the next break is due. This is the result of calling + * `getNextBreakDueTime()` with the current time. + * + * @type {number} + */ + get nextBreakDueTime() { + const currentTime = this.getCurrentTime(); + return this.getNextBreakDueTime(currentTime); + } + + /** + * Delays the currently upcoming, due or in-progress break. + * + * This is a no-op if no break is currently due or in progress. + */ + delayBreak() { + if (this.state === BreakState.DISABLED) + return; + + const currentTime = this.getCurrentTime(); + + console.debug('BreakManager: Delaying current break'); + + // Increment all the last break end times for any break types which are + // currently due by the delay. We do this over all break types, rather + // than just the current break type, because multiple break types might + // be due at the same time, and it would be a bit annoying to delay one + // only for another break type to immediately become due. + for (const [breakType, breakTypeSettings] of this._breakTypeSettings) { + const breakTypeInterval = breakTypeSettings.get_uint('interval-seconds'); + const delaySecs = breakTypeSettings.get_uint('delay-seconds'); + + if (this._breakLastEnd.get(breakType) + breakTypeInterval <= currentTime) + this._breakLastEnd.set(breakType, this._breakLastEnd.get(breakType) + delaySecs); + } + + this.freeze_notify(); + this.notify('next-break-due-time'); + this.notify('last-break-end-time'); + + this._updateState(currentTime); + this.thaw_notify(); + } + + /** + * Skips the currently upcoming, due or in-progress break. + * + * This is a no-op if no break is currently due or in progress. + */ + skipBreak() { + if (this.state === BreakState.DISABLED) + return; + + const currentTime = this.getCurrentTime(); + + console.debug('BreakManager: Skipping current break'); + + // Set all the last break end times for any break types which are + // currently due to now. We do this over all break types, rather than + // just the current break type, because multiple break types might be + // due at the same time, and it would be a bit annoying to skip one only + // for another break type to immediately become due. + for (const [breakType, breakTypeSettings] of this._breakTypeSettings) { + const breakTypeInterval = breakTypeSettings.get_uint('interval-seconds'); + + if (this._breakLastEnd.get(breakType) + breakTypeInterval <= currentTime) + this._breakLastEnd.set(breakType, currentTime); + } + + this.freeze_notify(); + this.notify('next-break-due-time'); + this.notify('last-break-end-time'); + + this._updateState(currentTime); + this.thaw_notify(); + } + + /** + * Signals that the user explicitly wants to start taking a break now, even + * if they are technically still active. + */ + takeBreak() { + // We can’t force the user to be idle, but we can indicate to the break + // dispatcher that it should try and make the user be idle. + this.emit('take-break'); + } + + /** + * Whether the given breakType should emit notification popups to the user. + * + * @param {string} breakType + * @returns {boolean} + */ + breakTypeShouldNotify(breakType) { + if (!this._breakTypeSettings.has(breakType)) + return false; + return this._breakTypeSettings.get(breakType).get_boolean('notify'); + } + + /** + * Whether the given breakType should emit notification popups to the user + * when it’s upcoming. + * + * @param {string} breakType + * @returns {boolean} + */ + breakTypeShouldNotifyUpcoming(breakType) { + if (!this._breakTypeSettings.has(breakType)) + return false; + return this._breakTypeSettings.get(breakType).get_boolean('notify-upcoming'); + } + + /** + * Whether the given breakType should emit notification popups to the user + * if it’s overdue. + * + * @param {string} breakType + * @returns {boolean} + */ + breakTypeShouldNotifyOverdue(breakType) { + if (!this._breakTypeSettings.has(breakType)) + return false; + return this._breakTypeSettings.get(breakType).get_boolean('notify-overdue'); + } + + /** + * Whether the given breakType should show a prominent countdown for the + * last 60s before it’s due. + * + * @param {string} breakType + * @returns {boolean} + */ + breakTypeShouldCountdown(breakType) { + if (!this._breakTypeSettings.has(breakType)) + return false; + return this._breakTypeSettings.get(breakType).get_boolean('countdown'); + } + + /** + * Whether the given breakType should play sounds to notify the user. + * + * @param {string} breakType + * @returns {boolean} + */ + breakTypeShouldPlaySound(breakType) { + if (!this._breakTypeSettings.has(breakType)) + return false; + return this._breakTypeSettings.get(breakType).get_boolean('play-sound'); + } + + /** + * Whether the given breakType should fade the screen during breaks. + * + * @param {string} breakType + * @returns {boolean} + */ + breakTypeShouldFadeScreen(breakType) { + if (!this._breakTypeSettings.has(breakType)) + return false; + return this._breakTypeSettings.get(breakType).get_boolean('fade-screen'); + } + + /** + * Whether the given breakType should lock the screen during breaks. + * + * @param {string} breakType + * @returns {boolean} + */ + breakTypeShouldLockScreen(breakType) { + if (!this._breakTypeSettings.has(breakType)) + return false; + return this._breakTypeSettings.get(breakType).get_boolean('lock-screen'); + } + + /** + * Duration (in seconds) of the given breakType. + * + * @param {string} breakType + * @returns {number} + */ + getDurationForBreakType(breakType) { + if (!this._breakTypeSettings.has(breakType)) + return 0; + return this._breakTypeSettings.get(breakType).get_uint('duration-seconds'); + } +}); + +/** + * Glue class which takes the state-based output from BreakManager and converts + * it to event-based notifications/sounds/screen fades for the user to tell them + * when to take breaks. It factors the user’s UI preferences into account. + */ +export const BreakDispatcher = GObject.registerClass( +class BreakDispatcher extends GObject.Object { + constructor(manager) { + super(); + + this._manager = manager; + this._previousState = BreakState.DISABLED; + this._previousBreakType = null; + this._manager.connectObject( + 'take-break', this._onTakeBreak.bind(this), + 'notify::state', this._onStateChanged.bind(this), + 'notify::next-break-due-time', this._onStateChanged.bind(this), + this); + + this._systemActions = SystemActions.getDefault(); + + this._notificationSource = null; + this._lightbox = null; + this._countdownOsd = null; + this._countdownTimerId = 0; + + if (this._manager.state === BreakState.DISABLED) + this._ensureDisabled(); + else + this._ensureEnabled(); + } + + destroy() { + this._ensureDisabled(); + + this._manager.disconnectObject(this); + this._manager = null; + } + + _ensureEnabled() { + if (this._notificationSource === null) + this._notificationSource = new BreakNotificationSource(this._manager); + + if (this._lightbox === null) { + this._lightbox = new Lightbox.Lightbox(Main.uiGroup, { + inhibitEvents: false, + fadeFactor: LIGHTBOX_FADE_FACTOR, + }); + } + } + + _ensureDisabled() { + this._notificationSource?.destroy(); + this._notificationSource = null; + + this._lightbox?.destroy(); + this._lightbox = null; + + this._removeCountdown(); + + this._previousState = BreakState.DISABLED; + this._previousBreakType = null; + } + + _maybePlayCompleteSound() { + if (this._manager.breakTypeShouldPlaySound(this._previousBreakType)) { + const player = global.display.get_sound_player(); + + player.play_from_theme('complete', _('Break complete sound'), null); + } + } + + _removeCountdown() { + if (this._countdownTimerId !== 0) + GLib.source_remove(this._countdownTimerId); + this._countdownTimerId = 0; + + this._countdownOsd?.destroy(); + this._countdownOsd = null; + } + + _onStateChanged() { + switch (this._manager.state) { + case BreakState.DISABLED: + this._ensureDisabled(); + break; + + case BreakState.ACTIVE: { + this._ensureEnabled(); + + if (this._previousState === BreakState.IN_BREAK) + this._maybePlayCompleteSound(); + + this._lightbox?.lightOff(); + + // Work out when the next break is due, and schedule a countdown. + const currentTime = this._manager.getCurrentTime(); + const [nextBreakType, nextBreakDueTime] = this._manager.getNextBreakDue(currentTime); + + if (this._manager.breakTypeShouldCountdown(nextBreakType)) { + const dueInSeconds = nextBreakDueTime - currentTime; + const countdownStart = Math.max(dueInSeconds - BREAK_COUNTDOWN_TIME_SECONDS, 0); + console.debug(`BreakDispatcher: Scheduling break countdown to start in ${countdownStart}s`); + + if (this._countdownTimerId !== 0) + GLib.source_remove(this._countdownTimerId); + this._countdownTimerId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + countdownStart, () => { + if (this._countdownOsd == null) { + this._countdownOsd = new OsdBreakCountdownLabel(this._manager); + this._countdownOsd.connect('destroy', + () => (this._countdownOsd = null)); + } + this._countdownTimerId = 0; + return GLib.SOURCE_REMOVE; + }); + } + + break; + } + + case BreakState.IDLE: { + this._ensureEnabled(); + + if (this._previousState === BreakState.IN_BREAK) + this._maybePlayCompleteSound(); + + this._lightbox?.lightOff(); + + break; + } + + case BreakState.IN_BREAK: { + this._ensureEnabled(); + + if (this._manager.breakTypeShouldLockScreen(this._manager.currentBreakType) && + this._previousState !== BreakState.IN_BREAK) { + this._systemActions.activateLockScreen(); + } else if (this._manager.breakTypeShouldFadeScreen(this._manager.currentBreakType)) { + Main.uiGroup.set_child_above_sibling(this._lightbox, null); + this._lightbox.lightOn(LIGHTBOX_FADE_TIME_SECONDS * 1000); + } + + this._removeCountdown(); + + break; + } + + case BreakState.BREAK_DUE: { + this._ensureEnabled(); + this._removeCountdown(); + + break; + } + default: + console.assert(false, `Unknown BreakManager state: ${this._manager.state}`); + break; + } + + this._previousState = this._manager.state; + this._previousBreakType = this._manager.currentBreakType; + } + + _onTakeBreak() { + if (this._manager.breakTypeShouldLockScreen(this._manager.currentBreakType)) { + this._systemActions.activateLockScreen(); + } else if (this._manager.breakTypeShouldFadeScreen(this._manager.currentBreakType)) { + Main.uiGroup.set_child_above_sibling(this._lightbox, null); + this._lightbox.lightOn(LIGHTBOX_FADE_TIME_SECONDS * 1000); + } + } +}); + +/* This can’t directly extend MessageTray.Source (even though it conceptually + * does) because the Shell will automatically destroy a MessageTray.Source when + * it becomes empty, and that destroys the state which we need for correctly + * showing the next notification (e.g. on a timer). + * + * So instead this is a normal GObject which closely wraps a MessageTray.Source. + */ +const BreakNotificationSource = GObject.registerClass( +class BreakNotificationSource 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::next-break-due-time', this._onStateChanged.bind(this), + this); + + this._previousState = BreakState.DISABLED; + this._previousBreakType = null; + 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 BreakNotification(this._source, this._manager); + this._notification.connect('destroy', () => (this._notification = null)); + } + + // Unacknowledge the notification when it’s updated, by default. + this._notification.set({acknowledged: false, ...params}); + } + + /** + * Formats the given time span as a translated string including units. + * Returns a tuple of: + * 1. The translated string + * 2. The number which determines whether the string is plural, for use + * with `ngettext()` calls to embed the returned string into (as they + * will need plural handling). + * 3. The number of seconds until the string would change if re-generated, + * for use in setting a timer to update a notification. + * + * @param {number} secondsAgo + * + * @returns {Array} + */ + _formatTimeSpan(secondsAgo) { + const minutesAgo = secondsAgo / 60; + const hoursAgo = minutesAgo / 60; + const daysAgo = hoursAgo / 24; + + if (secondsAgo < 60) { + return [Gettext.ngettext( + '%d second', + '%d seconds', + secondsAgo + ).format(secondsAgo), secondsAgo, 1]; + } else if (minutesAgo < 60) { + return [Gettext.ngettext( + '%d minute', + '%d minutes', + minutesAgo + ).format(minutesAgo), minutesAgo, 60 - (secondsAgo % 60)]; + } else if (hoursAgo < 24) { + return [Gettext.ngettext( + '%d hour', + '%d hours', + hoursAgo + ).format(hoursAgo), hoursAgo, (60 - (minutesAgo % 60)) * 60]; + } else { + return [Gettext.ngettext( + '%d day', + '%d days', + daysAgo + ).format(daysAgo), daysAgo, (24 - (hoursAgo % 24)) * 60 * 60]; + } + } + + _onStateChanged() { + this._updateState(); + + this._previousState = this._manager.state; + this._previousBreakType = this._manager.currentBreakType; + } + + _urgencyForBreakType(breakType) { + console.assert(breakType != null, 'null breakType'); + + // While the preferences indicate that certain break types should notify + // and others shouldn’t, that’s not really possible: we only have one + // notification which gets constantly updated with information about + // breaks, interleaving messages from multiple breaks over time. Having + // it disappear and reappear for different break types would be jarring. + // Instead, set the urgency to LOW for break types which shouldn’t + // notify. This means the notification remains, but is not presented to + // the user — it’s just visible if they expand the message tray. + if (this._manager.breakTypeShouldNotify(breakType)) + return MessageTray.Urgency.HIGH; + else + return MessageTray.Urgency.LOW; + } + + _scheduleUpdateState(timeout) { + if (this._timerId !== 0) + GLib.source_remove(this._timerId); + + // Round up to avoid spinning + const timeoutSeconds = Math.ceil(timeout); + + console.debug(`BreakNotificationSource: Scheduling notification state update in ${timeoutSeconds}s`); + + this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, timeoutSeconds, () => { + this._timerId = 0; + console.debug('BreakNotificationSource: Scheduled notification state update'); + this._updateState(); + return GLib.SOURCE_REMOVE; + }); + } + + _updateState() { + const currentTime = this._manager.getCurrentTime(); + + console.debug(`BreakNotificationSource: Break notification source state changed from ${breakStateToString(this._previousState)} to ${breakStateToString(this._manager.state)}`); + + switch (this._manager.state) { + case BreakState.DISABLED: + this.destroy(); + break; + + case BreakState.ACTIVE: { + if (this._previousState === BreakState.IN_BREAK) { + // Break is complete. + this._notification?.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + } else { + // Work out when the next break is due, and display some warnings + // that it’s impending. + const [nextBreakType, nextBreakDueTime] = this._manager.getNextBreakDue(currentTime); + const remainingSecs = nextBreakDueTime - currentTime; + console.debug(`BreakNotificationSource: ${remainingSecs}s left before next break`); + + if (this._manager.breakTypeShouldNotifyUpcoming(nextBreakType)) { + for (const notificationTime of BREAK_UPCOMING_NOTIFICATION_TIME_SECONDS) { + console.debug(`BreakNotificationSource: Considering upcoming notification ${notificationTime}s`); + + if (remainingSecs > notificationTime) { + // Schedule to show the first (longest) notification + // which is less than the remaining time before the break. + this._scheduleUpdateState(remainingSecs - notificationTime); + break; + } else if (Math.ceil(remainingSecs) === notificationTime) { + // Bang on time to show this notification. + const [remainingText, remainingValue, unused] = this._formatTimeSpan(remainingSecs); + const bodyText = Gettext.ngettext( + /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ + 'It will be time for a break in %s', + 'It will be time for a break in %s', + remainingValue + ).format(remainingText); + + let titleText; + switch (nextBreakType) { + case 'movement': + titleText = _('Movement Break Soon'); + break; + case 'eyesight': + titleText = _('Eyesight Break Soon'); + break; + default: + titleText = _('Break Soon'); + break; + } + + this._ensureNotification({ + title: titleText, + body: bodyText, + sound: null, + allowDelay: true, + allowSkip: true, + allowTake: false, + }); + this._source.addNotification(this._notification); + + // Continue to the next iteration, to schedule an update + // for the next-biggest notification. + continue; + } + } + } + } + break; + } + + case BreakState.IDLE: { + // Do nothing. + break; + } + + case BreakState.IN_BREAK: { + // Clamp to a minimum of 1s because saying “you have 0 seconds left” + // is a bit odd. The state machine may remain in + // `BreakState.IN_BREAK` even after the break duration is over, + // because the user might remain idle. In that case, display no + // further notifications until the user becomes active again — at + // that point they get a ‘Break is over’ notification. + const breakDurationRemaining = this._manager.getDurationForBreakType(this._manager.currentBreakType) - (currentTime - this._manager.currentBreakStartTime); + + if (breakDurationRemaining >= 1) { + const [remainingText, remainingValue, updateTimeoutSeconds] = this._formatTimeSpan(breakDurationRemaining); + + let titleText, bodyText; + switch (this._manager.currentBreakType) { + case 'movement': + titleText = _('Movement Break in Progress'); + bodyText = Gettext.ngettext( + /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ + 'Continue moving around for %s', + 'Continue moving around for %s', + remainingValue + ).format(remainingText); + break; + case 'eyesight': + titleText = _('Eyesight Break in Progress'); + bodyText = Gettext.ngettext( + /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ + 'Continue looking away for %s', + 'Continue looking away for %s', + remainingValue + ).format(remainingText); + break; + default: + titleText = _('Break in Progress'); + bodyText = Gettext.ngettext( + /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ + '%s left in your break', + '%s left in your break', + remainingValue + ).format(remainingText); + break; + } + + this._ensureNotification({ + title: titleText, + body: bodyText, + sound: null, + urgency: this._urgencyForBreakType(this._manager.currentBreakType), + allowDelay: false, + allowSkip: false, + allowTake: false, + }); + this._source.addNotification(this._notification); + + this._scheduleUpdateState(updateTimeoutSeconds); + } else { + this._notification?.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + } + break; + } + + case BreakState.BREAK_DUE: { + const [nextBreakType, nextBreakDueTime] = this._manager.getNextBreakDue(currentTime); + const breakDueAgo = currentTime - nextBreakDueTime; + console.assert(breakDueAgo >= 0, 'breakDueAgo should be non-negative'); + + if ((this._previousState === BreakState.ACTIVE || + this._previousState === BreakState.DISABLED) && + breakDueAgo < BREAK_OVERDUE_TIME_SECONDS) { + const durationSecs = this._manager.getDurationForBreakType(this._manager.currentBreakType); + const [durationText, durationValue, unused] = this._formatTimeSpan(durationSecs); + + let titleText, bodyText; + switch (this._manager.currentBreakType) { + case 'movement': + titleText = _('Time for a Movement Break'); + bodyText = Gettext.ngettext( + /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ + 'Take a break from the device and move around for %s', + 'Take a break from the device and move around for %s', + durationValue + ).format(durationText); + break; + case 'eyesight': + titleText = _('Time for an Eyesight Break'); + bodyText = Gettext.ngettext( + /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ + 'Take a break from the screen. Look at least 6 meters away for at least %s', + 'Take a break from the screen. Look at least 6 meters away for at least %s', + durationValue + ).format(durationText); + break; + default: + titleText = _('Time for a Break'); + bodyText = Gettext.ngettext( + /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ + 'It’s time to take a break. Get away from the device for %s!', + 'It’s time to take a break. Get away from the device for %s!', + durationValue + ).format(durationText); + break; + } + + this._ensureNotification({ + title: titleText, + body: bodyText, + sound: null, + allowDelay: true, + allowSkip: false, + allowTake: true, + }); + + this._scheduleUpdateState(BREAK_OVERDUE_TIME_SECONDS); + } else if (breakDueAgo >= BREAK_OVERDUE_TIME_SECONDS && + this._manager.breakTypeShouldNotifyOverdue(nextBreakType)) { + const [delayText, delayValue, updateTimeoutSeconds] = this._formatTimeSpan(breakDueAgo); + const bodyText = Gettext.ngettext( + /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ + 'You were due to take a break %s ago', + 'You were due to take a break %s ago', + delayValue + ).format(delayText); + + this._ensureNotification({ + title: _('Break Overdue'), + body: bodyText, + sound: null, + allowDelay: false, + allowSkip: false, + allowTake: false, + }); + + this._scheduleUpdateState(updateTimeoutSeconds); + } else if (this._previousState === BreakState.IN_BREAK) { + const durationSecs = this._manager.getDurationForBreakType(this._manager.currentBreakType); + const [countdownText, countdownValue, updateTimeoutSeconds] = this._formatTimeSpan(durationSecs - breakDueAgo); + const bodyText = Gettext.ngettext( + /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ + 'There is %s remaining in your break', + 'There are %s remaining in your break', + countdownValue + ).format(countdownText); + + this._ensureNotification({ + title: _('Break Interrupted'), + body: bodyText, + sound: null, + allowDelay: false, + allowSkip: false, + allowTake: false, + }); + + this._scheduleUpdateState(updateTimeoutSeconds); + } + + this._notification.urgency = this._urgencyForBreakType(this._manager.currentBreakType); + this._source.addNotification(this._notification); + break; + } + + default: + console.assert(false, `Unknown BreakManager state: ${this._manager.state}`); + break; + } + } +}); + +const BreakNotification = GObject.registerClass({ + Properties: { + 'allow-delay': GObject.ParamSpec.boolean( + 'allow-delay', null, null, + GObject.ParamFlags.READWRITE, + false), + 'allow-skip': GObject.ParamSpec.boolean( + 'allow-skip', null, null, + GObject.ParamFlags.READWRITE, + false), + 'allow-take': GObject.ParamSpec.boolean( + 'allow-take', null, null, + GObject.ParamFlags.READWRITE, + false), + }, +}, class BreakNotification extends MessageTray.Notification { + constructor(source, manager) { + super({ + source, + resident: true, + }); + + this._manager = manager; + this.connect('destroy', this._onDestroy.bind(this)); + + this._delayAction = null; + this._skipAction = null; + this._takeAction = null; + } + + _onDestroy(_notification, destroyedReason) { + // If it was destroyed by the user (by pressing the close button), skip + // the current break. + if (destroyedReason === MessageTray.NotificationDestroyedReason.DISMISSED) + this._manager.skipBreak(); + + this._manager = null; + } + + get allowDelay() { + return this._delayAction !== null; + } + + set allowDelay(allowDelay) { + if (allowDelay === this.allowDelay) + return; + + if (allowDelay) { + this._delayAction = this.addAction(_('Delay'), this._onDelayAction.bind(this)); + } else { + this.removeAction(this._delayAction); + this._delayAction = null; + } + } + + _onDelayAction() { + this._manager.delayBreak(); + this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + } + + get allowSkip() { + return this._skipAction !== null; + } + + set allowSkip(allowSkip) { + if (allowSkip === this.allowSkip) + return; + + if (allowSkip) { + this._skipAction = this.addAction(_('Skip'), this._onSkipAction.bind(this)); + } else { + this.removeAction(this._skipAction); + this._skipAction = null; + } + } + + _onSkipAction() { + this._manager.skipBreak(); + this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + } + + get allowTake() { + return this._takeAction !== null; + } + + set allowTake(allowTake) { + if (allowTake === this.allowTake) + return; + + if (allowTake) { + this._takeAction = this.addAction(_('Take'), this._onTakeAction.bind(this)); + } else { + this.removeAction(this._takeAction); + this._takeAction = null; + } + } + + _onTakeAction() { + this._manager.takeBreak(); + this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + } +}); + +const OsdBreakCountdownLabel = GObject.registerClass( +class OsdBreakCountdownLabel extends St.Widget { + constructor(manager) { + super({x_expand: true, y_expand: true}); + + this._manager = manager; + this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => { + return this._updateState() ? GLib.SOURCE_CONTINUE : GLib.SOURCE_REMOVE; + }); + + this._box = new St.BoxLayout({ + vertical: true, + }); + this.add_child(this._box); + + this._label = new St.Label({ + style_class: 'osd-break-countdown-label', + text: '', + }); + this._box.add_child(this._label); + + Main.uiGroup.add_child(this); + Main.uiGroup.set_child_above_sibling(this, null); + this._updateState(); + this._position(); + + Meta.disable_unredirect_for_display(global.display); + this.connect('destroy', () => { + if (this._timerId !== 0) + GLib.source_remove(this._timerId); + this._timerId = 0; + + Meta.enable_unredirect_for_display(global.display); + }); + } + + _updateState() { + const currentTime = this._manager.getCurrentTime(); + const nextDueTime = this._manager.getNextBreakDueTime(currentTime); + const breakDueInSecs = nextDueTime - currentTime; + console.debug(`OsdBreakCountdownLabel: breakDueInSecs ${breakDueInSecs} nextDueTime ${nextDueTime} currentTime ${currentTime}`); + + if (breakDueInSecs < 1) { + this.destroy(); + return false; + } + + this._label.text = Gettext.ngettext( + // Translators: This is a notification to warn the user that a + // screen time break will start shortly. + 'Break in %d second', + 'Break in %d seconds', + breakDueInSecs + ).format(breakDueInSecs); + + return true; + } + + _position() { + let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); + + if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) + this._box.x = workArea.x + (workArea.width - this._box.width); + else + this._box.x = workArea.x; + + this._box.y = workArea.y; + } +}); diff --git a/js/ui/main.js b/js/ui/main.js index 73980edb8..67cc0bbb2 100644 --- a/js/ui/main.js +++ b/js/ui/main.js @@ -8,6 +8,7 @@ import St from 'gi://St'; import * as AccessDialog from './accessDialog.js'; import * as AudioDeviceSelection from './audioDeviceSelection.js'; +import * as BreakManager from '../misc/breakManager.js'; import * as Config from '../misc/config.js'; import * as Components from './components.js'; import * as CtrlAltTab from './ctrlAltTab.js'; @@ -89,6 +90,9 @@ export let inputMethod = null; export let introspectService = null; export let locatePointer = null; export let endSessionDialog = null; +export let breakManager = null; +export let screenTimeDBus = null; +export let breakManagerDispatcher = null; let _startDate; let _defaultCssStylesheet = null; @@ -244,6 +248,11 @@ async function _initializeUI() { introspectService = new Introspect.IntrospectService(); + // Set up the global default break reminder manager and its D-Bus interface + breakManager = new BreakManager.BreakManager(); + screenTimeDBus = new ShellDBus.ScreenTimeDBus(breakManager); + breakManagerDispatcher = new BreakManager.BreakDispatcher(breakManager); + layoutManager.init(); overview.init(); diff --git a/js/ui/shellDBus.js b/js/ui/shellDBus.js index ba49771e9..e5a8b7324 100644 --- a/js/ui/shellDBus.js +++ b/js/ui/shellDBus.js @@ -15,6 +15,7 @@ import {ControlsState} from './overviewControls.js'; const GnomeShellIface = loadInterfaceXML('org.gnome.Shell'); const ScreenSaverIface = loadInterfaceXML('org.gnome.ScreenSaver'); +const ScreenTimeIface = loadInterfaceXML('org.gnome.Shell.ScreenTime'); export class GnomeShell { constructor() { @@ -542,3 +543,30 @@ export class ScreenSaverDBus { return 0; } } + +export class ScreenTimeDBus { + constructor(breakManager) { + this._manager = breakManager; + + this._manager.connect('notify::state', this._onNotify.bind(this)); + this._manager.connect('notify::last-break-end-time', this._onNotify.bind(this)); + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ScreenTimeIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/ScreenTime'); + } + + _onNotify() { + // We always want to notify the two properties together, as clients need + // both values to be useful. GJS will combine the two emissions for us. + this._dbusImpl.emit_property_changed('State', new GLib.Variant('u', this.State)); + this._dbusImpl.emit_property_changed('LastBreakEndTime', new GLib.Variant('t', this.LastBreakEndTime)); + } + + get State() { + return this._manager.state; + } + + get LastBreakEndTime() { + return this._manager.lastBreakEndTime; + } +} diff --git a/meson.build b/meson.build index d623775ef..dd6495723 100644 --- a/meson.build +++ b/meson.build @@ -27,7 +27,7 @@ gjs_req = '>= 1.73.1' gtk_req = '>= 4.0' mutter_req = '>= 47.0' polkit_req = '>= 0.100' -schemas_req = '>= 47.alpha' +schemas_req = '>= 47.beta' systemd_req = '>= 246' ibus_req = '>= 1.5.19' gnome_desktop_req = '>= 40' diff --git a/po/POTFILES.in b/po/POTFILES.in index f4853514b..25abaeb60 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -12,6 +12,7 @@ js/dbusServices/extensions/ui/extension-error-page.ui js/gdm/authPrompt.js js/gdm/loginDialog.js js/gdm/util.js +js/misc/breakManager.js js/misc/systemActions.js js/misc/util.js js/misc/dateUtils.js diff --git a/tests/meson.build b/tests/meson.build index a8f49a7ab..c7faa1129 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -20,6 +20,7 @@ unit_testenv.append('GI_TYPELIB_PATH', shell_typelib_path, separator: ':') unit_testenv.append('GI_TYPELIB_PATH', st_typelib_path, separator: ':') unit_tests = [ + 'breakManager', 'extensionUtils', 'highlighter', 'injectionManager', diff --git a/tests/unit/breakManager.js b/tests/unit/breakManager.js new file mode 100644 index 000000000..e74477696 --- /dev/null +++ b/tests/unit/breakManager.js @@ -0,0 +1,513 @@ +// Copyright 2024 GNOME Foundation, Inc. +// +// This is a GNOME Shell component to support break reminders and screen time +// statistics. +// +// Licensed under the GNU General Public License Version 2 +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import 'resource:///org/gnome/shell/ui/environment.js'; +import GLib from 'gi://GLib'; + +import * as BreakManager from 'resource:///org/gnome/shell/misc/breakManager.js'; + +// Convenience alias +const {BreakState} = BreakManager; + +// A harness for testing the BreakManager class. It simulates the passage of +// time, maintaining an internal ordered queue of events, and providing three +// groups of mock functions which the BreakManager uses to interact with it: +// a mock version of the IdleMonitor, mock versions of GLib’s clock and timeout +// functions, and a mock version of Gio.Settings. +// +// The internal ordered queue of events is sorted by time (in seconds since an +// arbitrary epoch; the tests arbitrarily start from 100s to avoid potential +// issues around time zero). On each _tick(), the next event is shifted off the +// head of the queue and processed. An event might be a simulated idle watch +// (mocking the user being idle), a simulated active watch, a scheduled timeout, +// or an assertion function for actually running test assertions. +// +// The simulated clock jumps from the scheduled time of one event to the +// scheduled time of the next. This way, we can simulate half an hour of active +// time (waiting for the next rest break to be due) on the computer instantly. +class TestHarness { + constructor(settings) { + this._currentTimeSecs = 100; + this._nextSourceId = 1; + this._events = []; + this._idleWatch = null; + this._activeWatch = null; + this._settings = settings; + } + + _allocateSourceId() { + const sourceId = this._nextSourceId; + this._nextSourceId++; + return sourceId; + } + + _removeEventBySourceId(sourceId) { + const idx = this._events.findIndex(a => { + return a.sourceId === sourceId; + }); + console.assert(idx !== -1); + this._events.splice(idx, 1); + } + + _insertEvent(event) { + this._events.push(event); + this._events.sort((a, b) => { + return a.time - b.time; + }); + return event; + } + + // Add a timeout event to the event queue. It will be scheduled at the + // current simulated time plus `intervalSecs`. `callback` will be invoked + // when the event is processed. + addTimeoutEvent(intervalSecs, callback) { + return this._insertEvent({ + type: 'timeout', + time: this._currentTimeSecs + intervalSecs, + callback, + sourceId: this._allocateSourceId(), + intervalSecs, + }); + } + + // Add an idle watch event to the event queue. This simulates the user + // becoming idle (no keyboard or mouse input) at time `timeSecs`. + addIdleEvent(timeSecs) { + return this._insertEvent({ + type: 'idle', + time: timeSecs, + }); + } + + // Add an active watch event to the event queue. This simulates the user + // becoming active (using the keyboard or mouse after a period of + // inactivity) at time `timeSecs`. + addActiveEvent(timeSecs) { + return this._insertEvent({ + type: 'active', + time: timeSecs, + }); + } + + // Add a delay action invocation to the event queue. This simulates the user + // invoking the ‘delay’ action (typically via a notification) at time + // `timeSecs`. + addDelayAction(timeSecs, breakManager) { + return this._insertEvent({ + type: 'action', + time: timeSecs, + callback: () => { + breakManager.delayBreak(); + }, + }); + } + + // Add a skip action invocation to the event queue. This simulates the user + // invoking the ‘skip’ action (typically via a notification) at time + // `timeSecs`. + addSkipAction(timeSecs, breakManager) { + return this._insertEvent({ + type: 'action', + time: timeSecs, + callback: () => { + breakManager.skipBreak(); + }, + }); + } + + // Add a take action invocation to the event queue. This simulates the user + // invoking the ‘take’ action (typically via a notification) at time + // `timeSecs`. + addTakeAction(timeSecs, breakManager) { + return this._insertEvent({ + type: 'action', + time: timeSecs, + callback: () => { + breakManager.takeBreak(); + }, + }); + } + + // Add an assertion event to the event queue. This is a callback which is + // invoked when the simulated clock reaches `timeSecs`. The callback can + // contain whatever test assertions you like. + addAssertionEvent(timeSecs, callback) { + return this._insertEvent({ + type: 'assertion', + time: timeSecs, + callback, + }); + } + + // Add a state assertion event to the event queue. This is a specialised + // form of `addAssertionEvent()` which asserts that the `BreakManager.state` + // equals `state` at time `timeSecs`. + expectState(timeSecs, breakManager, expectedState) { + return this.addAssertionEvent(timeSecs, () => { + expect(BreakManager.breakStateToString(breakManager.state)) + .withContext(`${timeSecs}s state`) + .toEqual(BreakManager.breakStateToString(expectedState)); + }); + } + + // Add a state assertion event to the event queue. This is a specialised + // form of `addAssertionEvent()` which asserts that the given `BreakManager` + // properties equal the expected values at time `timeSecs`. + expectProperties(timeSecs, breakManager, expectedProperties) { + return this.addAssertionEvent(timeSecs, () => { + for (const [name, expectedValue] of Object.entries(expectedProperties)) { + expect(breakManager[name]) + .withContext(`${timeSecs}s ${name}`) + .toEqual(expectedValue); + } + }); + } + + _popEvent() { + return this._events.shift(); + } + + // Get a mock clock object for use in the `BreakManager` under test. + // This provides a basic implementation of GLib’s clock and timeout + // functions which use the simulated clock and event queue. + get mockClock() { + return { + getRealTimeSecs: () => { + return this._currentTimeSecs; + }, + timeoutAddSeconds: (priority, intervalSecs, callback) => { + return this.addTimeoutEvent(intervalSecs, callback).sourceId; + }, + sourceRemove: sourceId => { + this._removeEventBySourceId(sourceId); + }, + }; + } + + // Get a mock idle monitor object for use in the `BreakManager` under test. + // This provides a basic implementation of the `IdleMonitor` which uses the + // simulated clock and event queue. + get mockIdleMonitor() { + return { + add_idle_watch: (waitMsec, callback) => { + console.assert(this._idleWatch === null); + this._idleWatch = { + waitMsec, + callback, + }; + return 1; + }, + + add_user_active_watch: callback => { + console.assert(this._activeWatch === null); + this._activeWatch = callback; + return 2; + }, + + remove_watch: id => { + console.assert(id === 1 || id === 2); + if (id === 1) + this._idleWatch = null; + else if (id === 2) + this._activeWatch = null; + }, + }; + } + + // Get a mock settings factory for use in the `BreakManager` under test. + // This is an object providing a couple of constructors for `Gio.Settings` + // objects. Each constructor returns a basic implementation of + // `Gio.Settings` which uses the settings dictionary passed to `TestHarness` + // in its constructor. + // This necessarily has an extra layer of indirection because there are + // multiple ways to construct a `Gio.Settings`. + get mockSettingsFactory() { + return { + new: schemaId => { + return { + connect: (unusedSignalName, unusedCallback) => { + /* no-op for mock purposes */ + return 1; + }, + get_boolean: key => { + return this._settings[schemaId][key]; + }, + get_strv: key => { + return this._settings[schemaId][key]; + }, + get_uint: key => { + return this._settings[schemaId][key]; + }, + }; + }, + + newWithPath: (schemaId, unusedPath) => { + return { + connect: (unusedSignalName, unusedCallback) => { + /* no-op for mock purposes */ + return 1; + }, + get_boolean: key => { + return this._settings[schemaId][key]; + }, + get_strv: key => { + return this._settings[schemaId][key]; + }, + get_uint: key => { + return this._settings[schemaId][key]; + }, + }; + }, + }; + } + + _tick() { + const event = this._popEvent(); + if (!event) + return false; + this._currentTimeSecs = event.time; + + switch (event.type) { + case 'timeout': + if (event.callback()) { + event.time += event.intervalSecs; + this._insertEvent(event); + } + break; + case 'idle': + if (this._idleWatch) + this._idleWatch.callback(); + break; + case 'active': + if (this._activeWatch) { + this._activeWatch(); + this._activeWatch = null; // one-shot + } + break; + case 'action': + event.callback(); + break; + case 'assertion': + event.callback(); + break; + default: + console.assert(false, 'not reached'); + } + + return true; + } + + // Run the test in a loop, blocking until all events are processed or an + // exception is raised. + run() { + const loop = new GLib.MainLoop(null, false); + let innerException = null; + + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + try { + if (this._tick()) + return GLib.SOURCE_CONTINUE; + loop.quit(); + return GLib.SOURCE_REMOVE; + } catch (e) { + // Quit the main loop then re-raise the exception + loop.quit(); + innerException = e; + return GLib.SOURCE_REMOVE; + } + }); + + loop.run(); + + // Did we exit with an exception? + if (innerException) + throw innerException; + } +} + +describe('Break manager', () => { + it('can be disabled via GSettings', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.break-reminders': { + 'selected-breaks': [], + }, + }); + const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory); + + harness.addActiveEvent(101); + harness.expectState(102, breakManager, BreakState.DISABLED); + harness.addIdleEvent(130); + harness.expectState(135, breakManager, BreakState.DISABLED); + + harness.run(); + }); + + // A simple test which simulates the user being active briefly, taking a short + // break before one is due, and then being active again until their next break + // is overdue. + it('tracks a single break type', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.break-reminders': { + 'selected-breaks': ['movement'], + }, + 'org.gnome.desktop.break-reminders.movement': { + 'duration-seconds': 300, /* 5 minutes */ + 'interval-seconds': 1800, /* 30 minutes */ + 'delay-seconds': 300, /* 5 minutes */ + 'notify': true, + 'play-sound': false, + 'fade-screen': false, + }, + }); + const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory); + + harness.addActiveEvent(101); + harness.expectState(102, breakManager, BreakState.ACTIVE); + harness.addIdleEvent(130); + harness.expectState(135, breakManager, BreakState.IDLE); + harness.addActiveEvent(200); // cut the break short before its duration + harness.expectState(201, breakManager, BreakState.ACTIVE); + harness.expectProperties(2001, breakManager, { // break is due after 30 mins + 'state': BreakState.BREAK_DUE, + 'currentBreakType': 'movement', + 'currentBreakStartTime': 0, + 'lastBreakEndTime': 100, + }); + harness.addIdleEvent(2005); + harness.expectProperties(2006, breakManager, { + 'state': BreakState.IN_BREAK, + 'currentBreakType': 'movement', + 'currentBreakStartTime': 1900, + 'lastBreakEndTime': 0, + }); + harness.expectState(2195, breakManager, BreakState.IN_BREAK); // near the end of the break + harness.expectState(2210, breakManager, BreakState.IDLE); // just after the end of the break + harness.addActiveEvent(2320); + harness.expectProperties(2321, breakManager, { + 'state': BreakState.ACTIVE, + 'currentBreakType': null, + 'currentBreakStartTime': 0, + 'lastBreakEndTime': 2320, + }); + harness.addIdleEvent(4100); // start the next break a little early + harness.expectState(4101, breakManager, BreakState.IDLE); + harness.expectState(4121, breakManager, BreakState.IN_BREAK); + harness.addActiveEvent(4420); + harness.expectProperties(4421, breakManager, { + 'state': BreakState.ACTIVE, + 'currentBreakType': null, + 'currentBreakStartTime': 0, + 'lastBreakEndTime': 4420, + }); + + harness.run(); + }); + + // Test requesting to delay a break. + it('supports delaying a break', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.break-reminders': { + 'selected-breaks': ['movement'], + }, + 'org.gnome.desktop.break-reminders.movement': { + 'duration-seconds': 300, /* 5 minutes */ + 'interval-seconds': 1800, /* 30 minutes */ + 'delay-seconds': 300, /* 5 minutes */ + 'notify': true, + 'play-sound': false, + 'fade-screen': false, + }, + }); + const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory); + + harness.addActiveEvent(101); + harness.expectState(102, breakManager, BreakState.ACTIVE); + harness.expectProperties(1901, breakManager, { // break is due after 30 mins + 'state': BreakState.BREAK_DUE, + 'currentBreakType': 'movement', + 'currentBreakStartTime': 0, + 'lastBreakEndTime': 100, + }); + harness.addDelayAction(1902, breakManager); + harness.expectProperties(1903, breakManager, { // break is delayed by 5 mins + 'state': BreakState.ACTIVE, + 'currentBreakType': null, + 'currentBreakStartTime': 0, + 'lastBreakEndTime': 400, + }); + harness.expectProperties(2201, breakManager, { // break is due after another 5 mins + 'state': BreakState.BREAK_DUE, + 'currentBreakType': 'movement', + 'currentBreakStartTime': 0, + 'lastBreakEndTime': 400, + }); + harness.addIdleEvent(2202); + harness.expectState(2203, breakManager, BreakState.IN_BREAK); + + harness.run(); + }); + + // Test requesting to skip a break. + it('supports skipping a break', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.break-reminders': { + 'selected-breaks': ['movement'], + }, + 'org.gnome.desktop.break-reminders.movement': { + 'duration-seconds': 300, /* 5 minutes */ + 'interval-seconds': 1800, /* 30 minutes */ + 'delay-seconds': 300, /* 5 minutes */ + 'notify': true, + 'play-sound': false, + 'fade-screen': false, + }, + }); + const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory); + + harness.addActiveEvent(101); + harness.expectState(102, breakManager, BreakState.ACTIVE); + harness.expectProperties(1901, breakManager, { // break is due after 30 mins + 'state': BreakState.BREAK_DUE, + 'currentBreakType': 'movement', + 'currentBreakStartTime': 0, + 'lastBreakEndTime': 100, + }); + harness.addSkipAction(1902, breakManager); + harness.expectProperties(1903, breakManager, { // break is skipped for 30 mins + 'state': BreakState.ACTIVE, + 'currentBreakType': null, + 'currentBreakStartTime': 0, + 'lastBreakEndTime': 1902, + }); + harness.expectProperties(3703, breakManager, { // break is due after another 30 mins + 'state': BreakState.BREAK_DUE, + 'currentBreakType': 'movement', + 'currentBreakStartTime': 0, + 'lastBreakEndTime': 1902, + }); + harness.addIdleEvent(3704); + harness.expectState(3704, breakManager, BreakState.IN_BREAK); + + harness.run(); + }); +});