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