diff options
| author | nippy <you@example.com> | 2026-04-18 13:49:56 +0200 |
|---|---|---|
| committer | nippy <you@example.com> | 2026-04-18 13:49:56 +0200 |
| commit | 5d94c0a7d44a2255b81815a52a7056a94a39842d (patch) | |
| tree | 759bdea9645c6a62f9e1e4c001f7d81cccd120d2 /raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules | |
| parent | e79cdf210b267f21a186255ce1a4d50029439d54 (diff) | |
| download | RaveOS-PKGBUILD-5d94c0a7d44a2255b81815a52a7056a94a39842d.tar.gz RaveOS-PKGBUILD-5d94c0a7d44a2255b81815a52a7056a94a39842d.zip | |
update Raveos themes
Diffstat (limited to 'raveos-theme/gnome/theme-data/extensions/installed/ShutdownTimer@deminder/modules')
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)' : '' + }` + ); +} |