summaryrefslogtreecommitdiff
path: root/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules
diff options
context:
space:
mode:
Diffstat (limited to 'raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules')
-rw-r--r--raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/info-fetcher.js108
-rw-r--r--raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/injection.js192
-rw-r--r--raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/install.js64
-rw-r--r--raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/menu-item.js573
-rw-r--r--raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/quicksettings.js77
-rw-r--r--raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/schedule-info.js150
-rw-r--r--raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/session-mode-aware.js41
-rw-r--r--raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/text-box.js130
-rw-r--r--raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/translation.js51
-rw-r--r--raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/util.js178
10 files changed, 1564 insertions, 0 deletions
diff --git a/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/info-fetcher.js b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/info-fetcher.js
new file mode 100644
index 0000000..e18df7b
--- /dev/null
+++ b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/info-fetcher.js
@@ -0,0 +1,108 @@
+// SPDX-FileCopyrightText: 2023 Deminder <tremminder@gmail.com>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import * as Signals from 'resource:///org/gnome/shell/misc/signals.js';
+
+import { throttleTimeout, logDebug, readFileAsync } from './util.js';
+
+export class InfoFetcher extends Signals.EventEmitter {
+ constructor() {
+ super();
+ this._intervalId = null;
+ this._tickPromise = null;
+ this._shutdownInfo = {};
+ this._wakeInfo = {};
+ this._rtc = 'rtc0';
+ this._cancellable = new Gio.Cancellable();
+ [this.refresh, this._refreshCancel] = throttleTimeout(
+ this._refresh.bind(this),
+ 300
+ );
+ }
+
+ _refresh() {
+ this._refreshCancel();
+ this._clearInterval();
+ logDebug('Extra info refresh...');
+ this._intervalId = setInterval(this.tick.bind(this), 5000);
+ this.tick();
+ }
+
+ _clearInterval() {
+ if (this._intervalId !== null) {
+ clearInterval(this._intervalId);
+ this._intervalId = null;
+ }
+ }
+
+ tick() {
+ if (this._tickPromise === null) {
+ this._tickPromise = Promise.all([
+ this._fetchShutdownInfo(),
+ this._fetchWakeInfo(this._rtc),
+ ]).then(([shutdown, wake]) => {
+ this._tickPromise = null;
+ this._shutdownInfo = shutdown;
+ this._wakeInfo = wake;
+ this.emit('changed');
+ });
+ }
+ }
+
+ _readFile(path) {
+ return readFileAsync(path, this._cancellable);
+ }
+
+ async _isWakeInfoLocal() {
+ const content = await this._readFile('/etc/adjtime').catch(() => '');
+ return content.trim().toLowerCase().endsWith('local');
+ }
+
+ async _fetchWakeInfo(rtc) {
+ const content = await this._readFile(
+ `/sys/class/rtc/${rtc}/wakealarm`
+ ).catch(() => '');
+ let timestamp = content !== '' ? parseInt(content) : -1;
+ if (timestamp > -1 && (await this._isWakeInfoLocal())) {
+ const dt = GLib.DateTime.new_from_unix_local(timestamp);
+ timestamp = dt.to_unix() - dt.get_utc_offset() / 1000000;
+ dt.unref();
+ }
+ return { deadline: timestamp };
+ }
+
+ async _fetchShutdownInfo() {
+ const content = await this._readFile(
+ '/run/systemd/shutdown/scheduled'
+ ).catch(() => '');
+ if (content === '') {
+ return { deadline: -1 };
+ }
+ // content: schedule unix-timestamp (micro-seconds), warn-all, shutdown-mode
+ const [usec, _, mode] = content.split('\n').map(l => l.split('=')[1]);
+ return {
+ mode,
+ deadline: parseInt(usec) / 1000000,
+ };
+ }
+
+ get shutdownInfo() {
+ return this._shutdownInfo;
+ }
+
+ get wakeInfo() {
+ return this._wakeInfo;
+ }
+
+ destroy() {
+ this.disconnectAll();
+ this._refreshCancel();
+ this._clearInterval();
+ if (this._cancellable !== null) {
+ this._cancellable.cancel();
+ this._cancellable = null;
+ }
+ }
+}
diff --git a/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/injection.js b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/injection.js
new file mode 100644
index 0000000..7d5bdd1
--- /dev/null
+++ b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/injection.js
@@ -0,0 +1,192 @@
+// SPDX-FileCopyrightText: 2023 Deminder <tremminder@gmail.com>
+// SPDX-License-Identifier: GPL-3.0-or-later
+import { logDebug } from './util.js';
+
+/**
+ * The InjectionTracker helps revert object property injections 'out of order'
+ * by keeping track of the injection history.
+ * This is useful when overriding the same method from mulitple extensions,
+ * for instance, `Main.panel.statusArea.quickSettings._addItems`.
+ *
+ * The InjectionManager does not work when multiple extensions override the same method.
+ * Its `restoreMethod` only restores correctly if it is called in reverse order to `overrideMethod`.
+ *
+ */
+export class InjectionTracker {
+ localInjections = [];
+
+ /**
+ * Inject a property into an object.
+ *
+ * To revert the injection call `injection.clear()`.
+ *
+ * @param {object} obj the target object
+ * @param {string} prop the property name
+ * @param {any} value the new property value
+ * @param {boolean} isPropertyDescriptor whether the value is a property descriptor
+ *
+ * @returns {object} an injection object
+ * provding `injection.original` and `injection.previous` property values
+ */
+ injectProperty(obj, prop, value, isPropertyDescriptor) {
+ let propertyDescriptor = Object.getOwnPropertyDescriptor(obj, prop);
+ if (!propertyDescriptor) {
+ if (prop in obj) {
+ propertyDescriptor = {
+ value: obj[prop],
+ writable: true,
+ configurable: true,
+ };
+ } else {
+ throw new Error(
+ `Injection target object does not have a '${prop}' property!`
+ );
+ }
+ }
+ // Keep history; mutated by each injection
+ const histories = obj.__injectionHistories ?? {};
+ const history = histories[prop] ?? [];
+ // Push old property descriptor to history
+ history.push(propertyDescriptor);
+ histories[prop] = history;
+ obj.__injectionHistories = histories;
+
+ const injectionId = history.length;
+ logDebug('[new] injectionid', injectionId);
+
+ // Override value
+ Object.defineProperty(
+ obj,
+ prop,
+ isPropertyDescriptor
+ ? value
+ : { value, writable: false, configurable: true }
+ );
+
+ const localInjectionId = this.localInjections.length;
+ const injection = createInjection(obj, prop, history, injectionId, () => {
+ // Remove from local injections
+ this.localInjections[localInjectionId] = undefined;
+ const pruneIndex =
+ this.localInjections.length -
+ 1 -
+ [...this.localInjections].reverse().findIndex(inj => inj !== undefined);
+ if (pruneIndex === this.localInjections.length) {
+ logDebug('[local-cleanclear]');
+ this.localInjections = [];
+ } else {
+ logDebug('[local-nocleanclear]');
+ this.localInjections = this.localInjections.slice(0, pruneIndex);
+ }
+ });
+ this.localInjections.push(injection);
+ return injection;
+ }
+
+ /**
+ * Clear all injections made by this instance.
+ */
+ clearAll() {
+ this.localInjections
+ .filter(inj => inj !== undefined)
+ .forEach(inj => inj.clear());
+ }
+}
+
+function createInjection(obj, prop, history, injectionId, clearHook) {
+ let reverted = false;
+ const readDescriptorValue = descriptor =>
+ 'get' in descriptor ? descriptor.get.call(obj) : descriptor.value;
+ return {
+ /**
+ * Read the original property value before any injections.
+ */
+ get original() {
+ // Using h instead history to remain valid after `clear()`
+ const h = obj.__injectionHistories?.[prop];
+ return readDescriptorValue(
+ h?.length ? h[0] : Object.getOwnPropertyDescriptor(obj, prop)
+ );
+ },
+
+ /**
+ * Read the previous property value from the injection history.
+ *
+ * Valid after `clear()` as long as no new injection occurs.
+ */
+ get previous() {
+ return history.length
+ ? readDescriptorValue(
+ injectionId > history.length
+ ? Object.getOwnPropertyDescriptor(obj, prop)
+ : popPropertyDescriptorFromHistory(
+ // previous history
+ history.slice(0, injectionId)
+ )
+ )
+ : this.original;
+ },
+
+ /**
+ * Clear the injection from the injection history.
+ */
+ clear() {
+ if (!reverted) {
+ reverted = true;
+ clearHook();
+
+ // Remove from global injections
+ if (injectionId >= history.length) {
+ logDebug(
+ '[remclear] injectionID',
+ injectionId,
+ 'historylen',
+ history.length
+ );
+ // Restore property of obj
+ Object.defineProperty(
+ obj,
+ prop,
+ popPropertyDescriptorFromHistory(history)
+ );
+ // Cleanup empty history
+ if (history.length === 0) {
+ logDebug('[cleanclear]');
+ delete obj.__injectionHistories[prop];
+
+ // Cleanup empty history store
+ if (Object.keys(obj.__injectionHistories).length === 0) {
+ delete obj.__injectionHistories;
+ }
+ } else {
+ logDebug('[nocleanclear] history.length', history.length);
+ }
+ } else {
+ logDebug(
+ '[setclear] injectionID',
+ injectionId,
+ 'historylen',
+ history.length
+ );
+ // Clear injection from history
+ history[injectionId] = undefined;
+ }
+ }
+ },
+
+ /**
+ * Count of previous injections. Is 1 if there is only this injection.
+ */
+ get count() {
+ return history.slice(0, injectionId).filter(i => i !== undefined).length;
+ },
+ };
+}
+
+function popPropertyDescriptorFromHistory(history) {
+ let propertyDescriptor;
+ do {
+ propertyDescriptor = history.pop();
+ } while (history.length && propertyDescriptor === undefined);
+ return propertyDescriptor;
+}
diff --git a/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/install.js b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/install.js
new file mode 100644
index 0000000..701da6a
--- /dev/null
+++ b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/install.js
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: 2023 Deminder <tremminder@gmail.com>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import GLib from 'gi://GLib';
+import Gio from 'gi://Gio';
+import { gettext as _ } from './translation.js';
+
+import { execCheck, installedScriptPath } from '../dbus-service/control.js';
+import { logDebug } from './util.js';
+
+export class Install {
+ destroy() {
+ if (this.installCancel !== undefined) {
+ this.installCancel.cancel();
+ }
+ this.installCancel = undefined;
+ }
+
+ /**
+ * @param installerScriptPath
+ * @param action
+ * @param logInstall
+ */
+ async installAction(installerScriptPath, action, logInstall) {
+ const label = this.actionLabel(action);
+ if (this.installCancel !== undefined) {
+ logDebug(`Trigger cancel install. ${label}`);
+ this.installCancel.cancel();
+ } else {
+ logDebug(`Trigger ${action} action.`);
+ this.installCancel = new Gio.Cancellable();
+ logInstall(`[${_('START')} ${label}]`);
+ try {
+ const user = GLib.get_user_name();
+ logDebug(`? installer.sh --tool-user ${user} ${action}`);
+ await execCheck(
+ ['pkexec', installerScriptPath, '--tool-user', user, action],
+ this.installCancel,
+ false,
+ logInstall
+ );
+ logInstall(`[${_('END')} ${label}]`);
+ } catch (err) {
+ logInstall(`[${_('FAIL')} ${label}]\n# ${err}`);
+ console.error(err, 'InstallError');
+ } finally {
+ this.installCancel = undefined;
+ }
+ }
+ }
+
+ checkInstalled() {
+ const scriptPath = installedScriptPath();
+ const isScriptInstalled = scriptPath !== null;
+ if (isScriptInstalled) {
+ logDebug(`Existing installation at: ${scriptPath}`);
+ }
+ return isScriptInstalled;
+ }
+
+ actionLabel(action) {
+ return { install: _('install'), uninstall: _('uninstall') }[action];
+ }
+}
diff --git a/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/menu-item.js b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/menu-item.js
new file mode 100644
index 0000000..3a80198
--- /dev/null
+++ b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/menu-item.js
@@ -0,0 +1,573 @@
+// SPDX-FileCopyrightText: 2023 Deminder <tremminder@gmail.com>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import GObject from 'gi://GObject';
+import St from 'gi://St';
+import Gio from 'gi://Gio';
+import Clutter from 'gi://Clutter';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
+import * as Slider from 'resource:///org/gnome/shell/ui/slider.js';
+import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js';
+
+import { InfoFetcher } from './info-fetcher.js';
+import { Textbox } from './text-box.js';
+import { gettext as _, ngettext as _n, pgettext as C_ } from './translation.js';
+import * as SessionModeAware from './session-mode-aware.js';
+import { getShutdownScheduleFromSettings } from './schedule-info.js';
+import {
+ ShutdownTimerName,
+ ShutdownTimerObjectPath,
+} from '../dbus-service/shutdown-timer-dbus.js';
+
+import {
+ foregroundActive,
+ observeForegroundActive,
+ unobserveForegroundActive,
+} from './session-mode-aware.js';
+import {
+ durationString,
+ longDurationString,
+ absoluteTimeString,
+ getSliderMinutesFromSettings,
+ ScheduleInfo,
+} from './schedule-info.js';
+
+import { logDebug, proxyPromise } from './util.js';
+import {
+ WAKE_ACTIONS,
+ actionLabel,
+ ACTIONS,
+ mapLegacyAction,
+} from '../dbus-service/action.js';
+
+/**
+ * The ShutdownTimerItem controls wake/shutdown action time and mode.
+ * Additionally, it shows wake and external/internal shutdown schedules.
+ *
+ * The external schedule of the `shutdown` command is fetched by the InfoFetcher.
+ * Note that there is no external schedule for `suspend`.
+ */
+const ShutdownTimerItem = GObject.registerClass(
+ {
+ Properties: {
+ 'shutdown-text': GObject.ParamSpec.string(
+ 'shutdown-text',
+ '',
+ '',
+ GObject.ParamFlags.READWRITE,
+ ''
+ ),
+ 'indicator-icon-name': GObject.ParamSpec.string(
+ 'indicator-icon-name',
+ '',
+ '',
+ GObject.ParamFlags.READWRITE,
+ 'go-down-symbolic'
+ ),
+ },
+ Signals: {
+ 'open-preferences': {},
+ shutdown: {
+ param_types: [GObject.TYPE_BOOLEAN, GObject.TYPE_STRING],
+ },
+ wake: {
+ param_types: [GObject.TYPE_BOOLEAN],
+ },
+ },
+ },
+ class ShutdownTimerItem extends QuickSettings.QuickMenuToggle {
+ _init({ path, settings }) {
+ const gicon = Gio.icon_new_for_string(
+ `${path}/icons/shutdown-timer-symbolic.svg`
+ );
+ super._init({ gicon, accessible_name: _('Shutdown Timer') });
+ this.info = {
+ internalShutdown: getShutdownScheduleFromSettings(settings),
+ externalShutdown: new ScheduleInfo({ external: true }),
+ externalWake: new ScheduleInfo({ mode: 'wake' }),
+ state: 'inactive',
+ };
+ this._settings = settings;
+ this.shutdownTimerIcon = gicon;
+ this._updatingSwitcherState = false;
+
+ // submenu in status area menu with slider and toggle button
+ this.sliderItems = {};
+ this.sliders = {};
+ ['shutdown', 'wake'].forEach(prefix => {
+ const [item, slider] = this._createSliderItem(prefix);
+ this.sliderItems[prefix] = item;
+ this.sliders[prefix] = slider;
+ this._onShowSliderChanged(prefix);
+ });
+ this.switcher = new PopupMenu.PopupSwitchMenuItem('', false);
+ // start/stop shutdown timer
+ this.switcher.connect('toggled', () => {
+ if (!this._updatingSwitcherState) {
+ this.emit(
+ 'shutdown',
+ this.switcher.state,
+ this.info.internalShutdown.mode
+ );
+ }
+ });
+ this.switcherSettingsButton = new St.Button({
+ reactive: true,
+ can_focus: true,
+ track_hover: true,
+ accessible_name: _('Settings'),
+ style_class: 'icon-button settings-button',
+ });
+ this.switcherSettingsButton.child = new St.Icon({
+ icon_name: 'applications-system-symbolic',
+ });
+ this.switcherSettingsButton.connect('clicked', () =>
+ this.emit('open-preferences')
+ );
+ this.switcher.add_child(this.switcherSettingsButton);
+
+ this.menu.addMenuItem(this.switcher);
+ // make switcher toggle without popup menu closing
+ this.switcher.activate = __ => {
+ if (this.switcher._switch.mapped) {
+ this.switcher.toggle();
+ }
+ };
+ this.menu.addMenuItem(this.sliderItems['shutdown']);
+
+ this.modeItems = Object.keys(ACTIONS).map(action => {
+ const modeItem = new PopupMenu.PopupMenuItem(actionLabel(action));
+ modeItem.connect('activate', () => {
+ this.emit('shutdown', true, action);
+ });
+ this.menu.addMenuItem(modeItem);
+ return [action, modeItem];
+ });
+
+ this.wakeItems = [
+ new PopupMenu.PopupSeparatorMenuItem(),
+ this.sliderItems['wake'],
+ ...Object.keys(WAKE_ACTIONS).map(action => {
+ const modeItem = new PopupMenu.PopupMenuItem(actionLabel(action));
+ if (action === 'wake') {
+ this.wakeModeItem = modeItem;
+ }
+ modeItem.connect('activate', () =>
+ this.emit('wake', action === 'wake')
+ );
+ return modeItem;
+ }),
+ ];
+ this.wakeItems.forEach(item => {
+ this.menu.addMenuItem(item);
+ });
+ this._updateWakeModeItem();
+ this._updateSwitchLabel();
+
+ // handlers for changed values in settings
+ const settingsHandlerIds = [
+ [
+ [
+ 'shutdown-max-timer-value',
+ 'nonlinear-shutdown-slider-value',
+ 'shutdown-ref-timer-value',
+ 'show-shutdown-absolute-timer-value',
+ 'root-mode-value',
+ 'shutdown-slider-value',
+ ],
+ () => this._updateSwitchLabel(),
+ ],
+ [
+ [
+ 'wake-max-timer-value',
+ 'wake-ref-timer-value',
+ 'show-wake-absolute-timer-value',
+ 'nonlinear-wake-slider-value',
+ 'wake-slider-value',
+ ],
+ () => this._updateWakeModeItem(),
+ ],
+ [['shutdown-slider-value'], () => this._updateSlider('shutdown')],
+ [['wake-slider-value'], () => this._updateSlider('wake')],
+ [
+ [
+ 'show-wake-items-value',
+ 'show-shutdown-mode-value',
+ 'show-shutdown-slider-value',
+ 'show-wake-slider-value',
+ 'show-shutdown-indicator-value',
+ 'show-settings-value',
+ ],
+ () => this._sync(),
+ ],
+ ]
+ .flatMap(([names, func]) => names.map(n => [n, func]))
+ .map(([name, func]) =>
+ this._settings.connect(`changed::${name}`, func)
+ );
+
+ this.connect('clicked', () => this.switcher.toggle());
+ observeForegroundActive(this, () => this._sync());
+ const tickHandlerId = setInterval(() => this.updateShutdownInfo(), 1000);
+ this.connect('destroy', () => {
+ unobserveForegroundActive(this);
+ clearInterval(tickHandlerId);
+ settingsHandlerIds.forEach(handlerId => {
+ this._settings.disconnect(handlerId);
+ });
+ });
+ }
+
+ _sync() {
+ // Update wake mode items
+ this.wakeItems.forEach(item => {
+ item.visible = this._settings.get_boolean('show-wake-items-value');
+ });
+ this._onShowSliderChanged('wake');
+
+ // Update shutdown mode items
+ const activeActions = this._settings
+ .get_string('show-shutdown-mode-value')
+ .split(',')
+ .map(s => mapLegacyAction(s.trim()))
+ .filter(action => action in ACTIONS);
+ logDebug('[menu-item]', activeActions);
+ this.modeItems.forEach(([action, item]) => {
+ const position = activeActions.indexOf(action);
+ if (position > -1) {
+ this.menu.moveMenuItem(item, position + 2);
+ }
+ item.visible = position > -1;
+ });
+
+ this._onShowSliderChanged('shutdown');
+
+ this.switcherSettingsButton.visible =
+ foregroundActive() && this._settings.get_boolean('show-settings-value');
+
+ this.updateShutdownInfo();
+ }
+
+ updateShutdownInfo() {
+ const schedule = this.info.internalShutdown;
+ const externalSchedule = this.info.externalShutdown;
+ const externalWake = this.info.externalWake;
+ const urgendSchedule = externalSchedule.isMoreUrgendThan(schedule)
+ ? externalSchedule
+ : schedule;
+ const active = schedule.scheduled || this.info.state !== 'inactive';
+ const showIndicator = this._settings.get_boolean(
+ 'show-shutdown-indicator-value'
+ );
+
+ // Update Indicator
+ this.set({
+ checked: active,
+ shutdownText:
+ urgendSchedule.scheduled && showIndicator
+ ? urgendSchedule.secondsLeft > 0
+ ? durationString(urgendSchedule.secondsLeft)
+ : _('now')
+ : '',
+ indicatorIconName:
+ showIndicator &&
+ (this.info.internalShutdown.scheduled ||
+ this.info.externalShutdown.scheduled)
+ ? 'go-down-symbolic'
+ : '',
+ });
+
+ // Update selected shutdown mode
+ this.modeItems.forEach(([mode, item]) => {
+ item.setOrnament(
+ mode === schedule.mode
+ ? PopupMenu.Ornament.DOT
+ : PopupMenu.Ornament.NONE
+ );
+ });
+
+ // Update title with scheduled action
+ this.title = actionLabel(schedule.mode);
+
+ // Update toggle state of switcher
+ this._updatingSwitcherState = true;
+ this.switcher.setToggleState(active);
+ this._updatingSwitcherState = false;
+
+ // Update long status description in menu
+ this.menu.setHeader(
+ this.shutdownTimerIcon,
+ _('Shutdown Timer'),
+ [schedule.label, externalWake.label].filter(v => !!v).join('\n')
+ );
+ if (this._settings.get_boolean('show-shutdown-absolute-timer-value')) {
+ this._updateSwitchLabel();
+ }
+ if (this._settings.get_boolean('show-wake-absolute-timer-value')) {
+ this._updateWakeModeItem();
+ }
+ this._updateSubtitle();
+ }
+
+ // update timer value if slider has changed
+ _updateSlider(prefix) {
+ this.sliders[prefix].value =
+ this._settings.get_double(`${prefix}-slider-value`) / 100.0;
+ }
+
+ _createSliderItem(settingsPrefix) {
+ const valueName = `${settingsPrefix}-slider-value`;
+ const sliderValue = this._settings.get_double(valueName) / 100.0;
+ const item = new PopupMenu.PopupBaseMenuItem({ activate: false });
+ const sliderIcon = new St.Icon({
+ icon_name:
+ settingsPrefix === 'wake'
+ ? 'alarm-symbolic'
+ : 'system-shutdown-symbolic',
+ style_class: 'popup-menu-icon',
+ });
+ item.add_child(sliderIcon);
+ const slider = new Slider.Slider(sliderValue);
+ slider.connect('notify::value', () => {
+ const v = slider.value * 100;
+ if (v !== this._settings.get_double(valueName))
+ this._settings.set_double(valueName, v);
+ });
+ item.add_child(slider);
+ return [item, slider];
+ }
+
+ get shutdownTimeStr() {
+ const minutes = Math.abs(
+ getSliderMinutesFromSettings(this._settings, 'shutdown')
+ );
+ return this._settings.get_boolean('show-shutdown-absolute-timer-value')
+ ? absoluteTimeString(minutes, C_('absolute time notation', '%a, %R'))
+ : longDurationString(
+ minutes,
+ h => _n('%s hr', '%s hrs', h),
+ m => _n('%s min', '%s mins', m)
+ );
+ }
+
+ _updateSwitchLabel() {
+ this.switcher.label.text = this._settings.get_boolean('root-mode-value')
+ ? _('%s (protect)').format(this.shutdownTimeStr)
+ : this.shutdownTimeStr;
+
+ if (this._settings.get_string('wake-ref-timer-value') === 'shutdown') {
+ this._updateWakeModeItem();
+ }
+ if (!this.info.internalShutdown.scheduled) {
+ this._updateSubtitle();
+ }
+ }
+
+ _updateSubtitle() {
+ this.subtitle = this.info.internalShutdown.scheduled
+ ? this._settings.get_boolean('show-shutdown-absolute-timer-value')
+ ? this.info.internalShutdown.absoluteTimeString
+ : durationString(this.info.internalShutdown.secondsLeft)
+ : this.shutdownTimeStr;
+ }
+
+ _updateWakeModeItem() {
+ const minutes = getSliderMinutesFromSettings(this._settings, 'wake');
+ const abs = this._settings.get_boolean('show-wake-absolute-timer-value');
+ this.wakeModeItem.label.text = (
+ abs
+ ? C_('WakeButtonText', '%s at %s')
+ : C_('WakeButtonText', '%s after %s')
+ ).format(
+ actionLabel('wake'),
+ abs
+ ? absoluteTimeString(minutes, C_('absolute time notation', '%a, %R'))
+ : longDurationString(
+ minutes,
+ h => _n('%s hour', '%s hours', h),
+ m => _n('%s minute', '%s minutes', m)
+ )
+ );
+ }
+
+ _onShowSliderChanged(settingsPrefix) {
+ this.sliderItems[settingsPrefix].visible =
+ (settingsPrefix !== 'wake' ||
+ this._settings.get_boolean('show-wake-items-value')) &&
+ this._settings.get_boolean(`show-${settingsPrefix}-slider-value`);
+ }
+ }
+);
+
+export const ShutdownTimerIndicator = GObject.registerClass(
+ {
+ Properties: {
+ 'wake-minutes': GObject.ParamSpec.int(
+ 'wake-minutes',
+ '',
+ '',
+ GObject.ParamFlags.READABLE,
+ 0
+ ),
+ 'shutdown-minutes': GObject.ParamSpec.int(
+ 'shutdown-minutes',
+ '',
+ '',
+ GObject.ParamFlags.READABLE,
+ 0
+ ),
+ },
+ Signals: {
+ 'open-preferences': {},
+ shutdown: {
+ param_types: [GObject.TYPE_BOOLEAN, GObject.TYPE_STRING],
+ },
+ wake: {
+ param_types: [GObject.TYPE_BOOLEAN],
+ },
+ },
+ },
+ class ShutdownTimerIndicator extends QuickSettings.SystemIndicator {
+ _init({ path, settings }) {
+ super._init();
+ this._state = 'inactive';
+ this._settings = settings;
+ this._textbox = new Textbox({ settings });
+ const item = new ShutdownTimerItem({ path, settings });
+ const infoFetcher = new InfoFetcher();
+ const proxyCancel = new Gio.Cancellable();
+ this._sdtProxy = null;
+ this._initProxy(item, this._textbox, infoFetcher, proxyCancel).catch(
+ err => {
+ if (!proxyCancel.is_cancelled()) console.error('[sdt-proxy]', err);
+ }
+ );
+ // React to changes in external shutdown and wake schedule
+ infoFetcher.connect('changed', () => this._syncShutdownInfo());
+ this._infoFetcher = infoFetcher;
+ this._shutdownTimerItem = item;
+
+ item.connect('open-preferences', () => this.emit('open-preferences'));
+
+ const icon = new St.Icon({ style_class: 'system-status-icon' });
+ const updateIcon = () => {
+ const name = item.indicatorIconName;
+ icon.visible = !!name;
+ if (icon.visible) icon.iconName = name;
+ this._syncIndicatorsVisible();
+ };
+
+ const scheduleLabel = new St.Label({
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ const updateLabel = () => {
+ const text = item.shutdownText;
+ scheduleLabel.text = text;
+ scheduleLabel.visible = !!text;
+ this._syncIndicatorsVisible();
+ };
+
+ this.add_child(icon);
+ this.add_child(scheduleLabel);
+
+ item.connect('notify::indicator-icon-name', () => updateIcon());
+ item.connect('notify::shutdown-text', () => updateLabel());
+
+ updateLabel();
+ updateIcon();
+
+ this.quickSettingsItems.push(item);
+ this.connect('destroy', () => {
+ proxyCancel.cancel();
+ infoFetcher.destroy();
+ this._infoFetcher = null;
+ this._textbox.destroy();
+ this.quickSettingsItems.forEach(i => i.destroy());
+ this.quickSettingsItems = [];
+ this._shutdownTimerItem = null;
+ if (this._sdtProxy !== null) {
+ this._sdtProxy.destroy();
+ this._sdtProxy = null;
+ }
+ this._settings = null;
+ });
+ item.connect('destroy', () => {
+ // Mitigate already destroyed error when logging out
+ // Does js/ui/layout.js:256 destroy quickSettingsItems?
+ this.quickSettingsItems = [];
+ });
+ }
+
+ async _initProxy(item, textbox, infoFetcher, cancellable) {
+ const proxy = await proxyPromise(
+ ShutdownTimerName,
+ Gio.DBus.session,
+ 'org.gnome.Shell',
+ ShutdownTimerObjectPath,
+ cancellable
+ );
+ if (cancellable?.is_cancelled()) return;
+ item.connect('shutdown', (__, shutdown, action) => {
+ logDebug(
+ '[menu-item] [shutdown-signal] shutdown: ',
+ shutdown,
+ 'action:',
+ action
+ );
+ proxy
+ .ScheduleShutdownAsync(shutdown, action)
+ .catch(err => console.error('[shutdown]', err));
+ });
+ item.connect('wake', (__, wake) => {
+ proxy
+ .ScheduleWakeAsync(wake)
+ .catch(err => console.error('[wake]', err));
+ });
+
+ const signalIds = [
+ proxy.connectSignal('OnMessage', (__, ___, [msg]) => {
+ textbox.showTextbox(msg);
+ }),
+ proxy.connectSignal('OnStateChange', (__, ___, [state]) => {
+ logDebug('[menu-item] [OnStateChange-signal] state:', state);
+ this._state = state;
+ this._syncShutdownInfo();
+ }),
+ proxy.connectSignal('OnExternalChange', () => {
+ infoFetcher.refresh();
+ }),
+ ];
+ proxy.destroy = function () {
+ signalIds.forEach(signalId => this.disconnectSignal(signalId));
+ };
+
+ this._sdtProxy = proxy;
+ [this._state] = await this._sdtProxy.GetStateAsync();
+ if (cancellable?.is_cancelled()) return;
+ logDebug('[sdt-proxy] state', this._state);
+ this._syncShutdownInfo();
+ infoFetcher.refresh();
+ }
+
+ _syncShutdownInfo() {
+ const item = this._shutdownTimerItem;
+ if (this._state === 'action') {
+ if (SessionModeAware.foregroundActive()) Main.overview.hide();
+ this._textbox.hideAll();
+ }
+ item.info = {
+ internalShutdown: getShutdownScheduleFromSettings(this._settings),
+ externalShutdown: item.info.externalShutdown.copy({
+ ...this._infoFetcher.shutdownInfo,
+ }),
+ externalWake: item.info.externalWake.copy({
+ ...this._infoFetcher.wakeInfo,
+ }),
+ state: this._state,
+ };
+ item.updateShutdownInfo();
+ }
+ }
+);
diff --git a/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/quicksettings.js b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/quicksettings.js
new file mode 100644
index 0000000..dd7b161
--- /dev/null
+++ b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/quicksettings.js
@@ -0,0 +1,77 @@
+// SPDX-FileCopyrightText: 2023 Deminder <tremminder@gmail.com>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+
+/**
+ * Add an external indicator after an existing indicator and above an existing indicator item.
+ */
+export function addExternalIndicator(
+ tracker,
+ indicator,
+ after = '_system',
+ above = '_backgroundApps',
+ colSpan
+) {
+ const qs = Main.panel.statusArea.quickSettings;
+ const indicatorItems = indicator.quickSettingsItems;
+ const aboveIndicator = qs[above];
+ if (aboveIndicator === undefined) {
+ if ('_addItems' in qs) {
+ // 45.beta.1: _setupIndicators is not done
+ const injection = tracker.injectProperty(
+ qs,
+ '_addItems',
+ (items, col) => {
+ if (Object.is(items, qs[above].quickSettingsItems)) {
+ injection.clear();
+ // Insert after: insert_child_above(a,b): inserts 'a' after 'b'
+ qs._indicators.insert_child_above(indicator, qs[after]);
+ // Insert above
+ injection.original.call(qs, indicatorItems, colSpan);
+ }
+ injection.previous.call(qs, items, col);
+ }
+ );
+ } else {
+ // 45.rc: _setupIndicators is not done
+ const qsm = qs.menu;
+ const injection = tracker.injectProperty(
+ qsm,
+ '_completeAddItem',
+ (item, col) => {
+ const firstAboveItem = qs[above]?.quickSettingsItems.at(-1);
+ if (Object.is(firstAboveItem, item)) {
+ injection.clear();
+ // Insert after: insert_child_above(a,b): inserts 'a' after 'b'
+ qs._indicators.insert_child_above(indicator, qs[after]);
+ // Insert above
+ indicatorItems.forEach(newItem =>
+ qsm.insertItemBefore(newItem, item, colSpan)
+ );
+ }
+ injection.previous.call(qsm, item, col);
+ }
+ );
+ }
+ } else if ('_addItems' in qs) {
+ // 45.beta.1: _setupIndicators is done
+ // Insert after: insert_child_above(a,b): inserts 'a' after 'b'
+ qs._indicators.insert_child_above(indicator, qs[after]);
+ // Insert above
+ qs._addItems(indicatorItems, colSpan);
+ const firstAboveItem = aboveIndicator.quickSettingsItems.at(-1);
+ indicatorItems.forEach(item => {
+ qs.menu._grid.remove_child(item);
+ qs.menu._grid.insert_child_below(item, firstAboveItem);
+ });
+ } else {
+ // 45.rc: _setupIndicators is done
+ // Insert after
+ qs._indicators.insert_child_above(indicator, qs[after]);
+
+ // Insert above
+ const firstAboveItem = aboveIndicator.quickSettingsItems.at(-1);
+ qs._addItemsBefore(indicatorItems, firstAboveItem, colSpan);
+ }
+}
diff --git a/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/schedule-info.js b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/schedule-info.js
new file mode 100644
index 0000000..394ebc8
--- /dev/null
+++ b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/schedule-info.js
@@ -0,0 +1,150 @@
+// SPDX-FileCopyrightText: 2023 Deminder <tremminder@gmail.com>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import GLib from 'gi://GLib';
+import { gettext as _, ngettext as _n, pgettext as C_ } from './translation.js';
+import { mapLegacyAction, untilText } from '../dbus-service/action.js';
+
+export class ScheduleInfo {
+ constructor({ mode = '?', deadline = -1, external = false }) {
+ this._v = { mode: mapLegacyAction(mode), deadline, external };
+ }
+
+ copy(vals) {
+ return new ScheduleInfo({ ...this._v, ...vals });
+ }
+
+ get deadline() {
+ return this._v.deadline;
+ }
+
+ get external() {
+ return this._v.external;
+ }
+
+ get mode() {
+ return this._v.mode;
+ }
+
+ get scheduled() {
+ return this.deadline > -1;
+ }
+
+ get secondsLeft() {
+ return this.deadline - GLib.DateTime.new_now_utc().to_unix();
+ }
+
+ get minutes() {
+ return Math.floor(this.secondsLeft / 60);
+ }
+
+ get label() {
+ let label = '';
+ if (this.scheduled) {
+ label = _('{durationString} until {untiltext}')
+ .replace('{durationString}', durationString(this.secondsLeft))
+ .replace('{untiltext}', untilText(this.mode));
+ if (this.external) {
+ label = _('{label} (sys)').replace('{label}', label);
+ }
+ }
+ return label;
+ }
+
+ get absoluteTimeString() {
+ return GLib.DateTime.new_from_unix_utc(this.deadline)
+ .to_local()
+ .format(C_('absolute schedule notation', '%a, %T'));
+ }
+
+ isMoreUrgendThan(otherInfo) {
+ return (
+ !otherInfo.scheduled ||
+ (this.scheduled &&
+ // external deadline is instant, internal deadline has 1 min slack time
+ (this.external ? this.deadline : this.deadline + 58) <
+ otherInfo.deadline)
+ );
+ }
+}
+
+export function getShutdownScheduleFromSettings(settings) {
+ return new ScheduleInfo({
+ mode: settings.get_string('shutdown-mode-value'),
+ deadline: settings.get_int('shutdown-timestamp-value'),
+ });
+}
+
+export function getSliderMinutesFromSettings(settings, prefix) {
+ const sliderValue = settings.get_double(`${prefix}-slider-value`) / 100.0;
+ const rampUp = settings.get_double(`nonlinear-${prefix}-slider-value`);
+ const ramp = x => Math.expm1(rampUp * x) / Math.expm1(rampUp);
+ let minutes = Math.floor(
+ (rampUp === 0 ? sliderValue : ramp(sliderValue)) *
+ settings.get_int(`${prefix}-max-timer-value`)
+ );
+
+ const refstr = settings.get_string(`${prefix}-ref-timer-value`);
+ // default: 'now'
+ const MS = 1000 * 60;
+ if (refstr.includes(':')) {
+ const mh = refstr
+ .split(':')
+ .map(s => Number.parseInt(s))
+ .filter(n => !Number.isNaN(n) && n >= 0);
+ if (mh.length >= 2) {
+ const d = new Date();
+ const nowTime = d.getTime();
+ d.setHours(mh[0]);
+ d.setMinutes(mh[1]);
+
+ if (d.getTime() + MS * minutes < nowTime) {
+ d.setDate(d.getDate() + 1);
+ }
+ minutes += Math.floor(new Date(d.getTime() - nowTime).getTime() / MS);
+ }
+ } else if (prefix !== 'shutdown' && refstr === 'shutdown') {
+ minutes += getSliderMinutesFromSettings(settings, 'shutdown');
+ }
+ return minutes;
+}
+
+/**
+ * A short duration string showing >=3 hours, >=1 mins, or secs.
+ *
+ * @param {number} seconds duration in seconds
+ */
+export function durationString(seconds) {
+ const sign = Math.sign(seconds);
+ const absSec = Math.floor(Math.abs(seconds));
+ const minutes = Math.floor(absSec / 60);
+ const hours = Math.floor(minutes / 60);
+ if (hours >= 3) {
+ return _n('%s hour', '%s hours', hours).format(sign * hours);
+ } else if (minutes === 0) {
+ return _n('%s sec', '%s secs', absSec).format(
+ sign * (absSec > 5 ? 10 * Math.ceil(absSec / 10) : absSec)
+ );
+ }
+ return _n('%s min', '%s mins', minutes).format(sign * minutes);
+}
+
+/**
+ *
+ * @param minutes
+ * @param hrFmt
+ * @param minFmt
+ */
+export function longDurationString(minutes, hrFmt, minFmt) {
+ const hours = Math.floor(minutes / 60);
+ const residualMinutes = minutes % 60;
+ let parts = [minFmt(residualMinutes).format(residualMinutes)];
+ if (hours) {
+ parts = [hrFmt(hours).format(hours)].concat(parts);
+ }
+ return parts.join(' ');
+}
+
+export function absoluteTimeString(minutes, timeFmt) {
+ return GLib.DateTime.new_now_local().add_minutes(minutes).format(timeFmt);
+}
diff --git a/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/session-mode-aware.js b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/session-mode-aware.js
new file mode 100644
index 0000000..cb6e562
--- /dev/null
+++ b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/session-mode-aware.js
@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: 2023 Deminder <tremminder@gmail.com>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+
+export function currentSessionMode() {
+ return Main.sessionMode.currentMode;
+}
+
+/**
+ * Check if we want to show foreground activity
+ */
+export function foregroundActive() {
+ // ubuntu22.04 uses 'ubuntu' as 'user' sessionMode
+ return Main.sessionMode.currentMode !== 'unlock-dialog';
+}
+
+/**
+ * Observe foreground activity changes
+ *
+ * @param {object} obj bind obj as observer
+ * @param {Function} callback called upon change
+ */
+export function observeForegroundActive(obj, callback) {
+ if (!obj._sessionModeSignalId) {
+ obj._sessionModeSignalId = Main.sessionMode.connect('updated', () => {
+ callback(foregroundActive());
+ });
+ }
+ callback(foregroundActive());
+}
+
+/**
+ * Unobserve session mode
+ */
+export function unobserveForegroundActive(obj) {
+ if (obj._sessionModeSignalId) {
+ Main.sessionMode.disconnect(obj._sessionModeSignalId);
+ delete obj._sessionModeSignalId;
+ }
+}
diff --git a/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/text-box.js b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/text-box.js
new file mode 100644
index 0000000..b97cdb1
--- /dev/null
+++ b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/text-box.js
@@ -0,0 +1,130 @@
+// SPDX-FileCopyrightText: 2023 Deminder <tremminder@gmail.com>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import St from 'gi://St';
+import Clutter from 'gi://Clutter';
+
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+import { throttleTimeout, logDebug } from './util.js';
+import {
+ foregroundActive,
+ observeForegroundActive,
+ unobserveForegroundActive,
+} from './session-mode-aware.js';
+
+export class Textbox {
+ textboxes = [];
+ constructor({ settings }) {
+ this.settings = settings;
+ [this.syncThrottled, this.syncThrottledCancel] = throttleTimeout(
+ this.sync.bind(this),
+ 50
+ );
+ this.showSettingsId = settings.connect(
+ 'changed::show-textboxes-value',
+ this.sync.bind(this)
+ );
+ observeForegroundActive(this, fgActive => {
+ if (!fgActive) {
+ this.hideAll();
+ }
+ });
+ }
+
+ destroy() {
+ if (this.showSettingsId) {
+ unobserveForegroundActive(this);
+ this.hideAll();
+ this.settings.disconnect(this.showSettingsId);
+ this.showSettingsId = null;
+ }
+ }
+
+ sync() {
+ this.syncThrottledCancel();
+ // remove hidden textboxes
+ this.textboxes = this.textboxes.filter(t => {
+ if (t['_hidden']) {
+ const sid = t['_sourceId'];
+ if (sid) {
+ clearTimeout(sid);
+ }
+ delete t['_sourceId'];
+ t.destroy();
+ }
+ return !t['_hidden'];
+ });
+ const monitor = Main.layoutManager.primaryMonitor;
+ let heightOffset = 0;
+ this.textboxes.forEach((textbox, i) => {
+ if (!('_sourceId' in textbox)) {
+ // start fadeout of textbox after 3 seconds
+ textbox['_sourceId'] = setTimeout(() => {
+ textbox['_sourceId'] = 0;
+ textbox.ease({
+ opacity: 0,
+ duration: 1000,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ textbox['_hidden'] = 1;
+ this.syncThrottled();
+ },
+ });
+ }, 3000);
+ }
+ textbox.visible = this.settings.get_boolean('show-textboxes-value');
+ if (!textbox.visible) return;
+
+ if (i === 0) {
+ heightOffset = -textbox.height / 2;
+ }
+ textbox.set_position(
+ monitor.x + Math.floor(monitor.width / 2 - textbox.width / 2),
+ monitor.y + Math.floor(monitor.height / 2 + heightOffset)
+ );
+ heightOffset += textbox.height + 10;
+ if (textbox['_sourceId']) {
+ // set opacity before fadeout starts
+ textbox.opacity =
+ i === 0
+ ? 255
+ : Math.max(
+ 25,
+ 25 + 230 * (1 - heightOffset / (monitor.height / 2))
+ );
+ }
+ });
+ }
+
+ hideAll() {
+ for (const t of this.textboxes) {
+ t['_hidden'] = 1;
+ }
+ this.sync();
+ }
+
+ /**
+ * Show a textbox message on the primary monitor
+ *
+ * @param textmsg
+ */
+ showTextbox(textmsg) {
+ if (textmsg && foregroundActive()) {
+ for (const t of this.textboxes) {
+ // replace old textbox if it has the same text
+ if (t.text === textmsg) {
+ t['_hidden'] = 1;
+ }
+ }
+ logDebug(`show textbox: ${textmsg}`);
+ const textbox = new St.Label({
+ style_class: 'textbox-label',
+ text: textmsg,
+ opacity: 0,
+ });
+ Main.uiGroup.add_child(textbox);
+ this.textboxes.unshift(textbox);
+ this.syncThrottled();
+ }
+ }
+}
diff --git a/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/translation.js b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/translation.js
new file mode 100644
index 0000000..af18232
--- /dev/null
+++ b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/translation.js
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: 2023 Deminder <tremminder@gmail.com>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import GLib from 'gi://GLib';
+
+/**
+ * The import paths for translations differ for `extension.js` and `prefs.js`.
+ * https://gjs.guide/extensions/upgrading/gnome-shell-45.html#esm
+ *
+ * This module provides shared access to translations.
+ * It is assumed that the gettext-domain is already bound.
+ */
+const domain = 'ShutdownTimer';
+
+/**
+ * Translate `str` using the extension's gettext domain
+ *
+ * @param {string} str - the string to translated
+ *
+ * @returns {string} the translated string
+ */
+export function gettext(str) {
+ return GLib.dgettext(domain, str);
+}
+
+/**
+ * Translate `str` and choose plural form using the extension's
+ * gettext domain
+ *
+ * @param {string} str - the string to translate
+ * @param {string} strPlural - the plural form of the string
+ * @param {number} n - the quantity for which translation is needed
+ *
+ * @returns {string} the translated string
+ */
+export function ngettext(str, strPlural, n) {
+ return GLib.dngettext(domain, str, strPlural, n);
+}
+
+/**
+ * Translate `str` in the context of `context` using the extension's
+ * gettext domain
+ *
+ * @param {string} context - context to disambiguate `str`
+ * @param {string} str - the string to translate
+ *
+ * @returns {string} the translated string
+ */
+export function pgettext(context, str) {
+ return GLib.dpgettext2(domain, context, str);
+}
diff --git a/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/util.js b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/util.js
new file mode 100644
index 0000000..6f360aa
--- /dev/null
+++ b/raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules/util.js
@@ -0,0 +1,178 @@
+// SPDX-FileCopyrightText: 2023 Deminder <tremminder@gmail.com>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import GLib from 'gi://GLib';
+import Gio from 'gi://Gio';
+
+export const debugMode = false;
+
+/**
+ * Log debug message if debug is enabled .
+ *
+ * @param {...any} args log arguments
+ */
+export function logDebug(...args) {
+ if ('logDebug' in globalThis) {
+ globalThis.logDebug(...args);
+ } else if (debugMode) {
+ console.log('[SDT]', ...args);
+ }
+}
+
+export async function proxyPromise(
+ ProxyTypeOrName,
+ session,
+ dest,
+ objectPath,
+ cancellable = null
+) {
+ if (typeof ProxyTypeOrName === 'string') {
+ try {
+ ProxyTypeOrName = Gio.DBusProxy.makeProxyWrapper(
+ await loadInterfaceXML(ProxyTypeOrName, cancellable)
+ );
+ } catch (err) {
+ throw new Error('Failed to load proxy interface!', { cause: err });
+ }
+ }
+ const p = await new Promise((resolve, reject) => {
+ new ProxyTypeOrName(
+ session,
+ dest,
+ objectPath,
+ (proxy, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(proxy);
+ }
+ },
+ cancellable
+ );
+ });
+ return p;
+}
+
+export class Idle {
+ #idleSourceId = null;
+ #idleResolves = [];
+
+ destroy() {
+ // Ensure that promises are resolved
+ for (const resolve of this.#idleResolves) {
+ resolve();
+ }
+ this.#idleResolves = [];
+ if (this.#idleSourceId) {
+ GLib.Source.remove(this.#idleSourceId);
+ }
+ this.#idleSourceId = null;
+ }
+
+ /**
+ * Resolves when event loop is idle
+ */
+ guiIdle() {
+ return new Promise(resolve => {
+ this.#idleResolves.push(resolve);
+ if (!this.#idleSourceId) {
+ this.#idleSourceId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
+ for (const res of this.#idleResolves) {
+ res();
+ }
+ this.#idleResolves = [];
+ this.#idleSourceId = null;
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+ });
+ }
+}
+
+/**
+ * Calls to `event` are delayed (throttled).
+ * Call `cancel` drop last event.
+ *
+ * @param {Function} timeoutFunc called function after delay
+ * @param {number} delayMillis delay in milliseconds
+ * @returns [event, cancel]
+ */
+export function throttleTimeout(timeoutFunc, delayMillis) {
+ let current = null;
+ return [
+ () => {
+ if (current === null) {
+ current = setTimeout(() => {
+ current = null;
+ timeoutFunc();
+ }, delayMillis);
+ }
+ },
+ () => {
+ if (current) {
+ clearTimeout(current);
+ current = null;
+ }
+ },
+ ];
+}
+
+export function extensionDirectory() {
+ const utilModulePath = /(.*)@file:\/\/(.*):\d+:\d+$/.exec(
+ new Error().stack.split('\n')[1]
+ )[2];
+ const extOrModuleDir = GLib.path_get_dirname(
+ GLib.path_get_dirname(utilModulePath)
+ );
+ // This file is either at /modules/util.js or /modules/sdt/util.js
+ return GLib.path_get_basename(extOrModuleDir) === 'modules'
+ ? GLib.path_get_dirname(extOrModuleDir)
+ : extOrModuleDir;
+}
+
+export function readFileAsync(pathOrFile, cancellable = null) {
+ return new Promise((resolve, reject) => {
+ try {
+ const file =
+ typeof pathOrFile === 'string'
+ ? Gio.File.new_for_path(pathOrFile)
+ : pathOrFile;
+ file.load_contents_async(cancellable, (f, res) => {
+ try {
+ const [, contents] = f.load_contents_finish(res);
+ const decoder = new TextDecoder('utf-8');
+ resolve(decoder.decode(contents));
+ } catch (err) {
+ reject(err);
+ }
+ });
+ } catch (err) {
+ reject(err);
+ }
+ });
+}
+
+export async function loadInterfaceXML(iface, cancellable = null) {
+ const readPromises = [
+ Gio.File.new_for_path(
+ `${extensionDirectory()}/dbus-interfaces/${iface}.xml`
+ ),
+ Gio.File.new_for_uri(
+ `resource:///org/gnome/shell/dbus-interfaces/${iface}.xml`
+ ),
+ ].map(async file => {
+ try {
+ return await readFileAsync(file, cancellable);
+ } catch (err) {
+ return '';
+ }
+ });
+ for await (const xml of readPromises) {
+ if (xml) return xml;
+ }
+ throw new Error(
+ `Failed to load D-Bus interface '${iface}'${
+ cancellable && cancellable.is_cancelled() ? ' (canceled)' : ''
+ }`
+ );
+}