import Clutter from 'gi://Clutter'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Shell from 'gi://Shell'; import St from 'gi://St'; import * as Main from '../main.js'; import * as PopupMenu from '../popupMenu.js'; import * as Util from '../../misc/util.js'; import {Spinner} from '../animation.js'; import {QuickToggle, SystemIndicator} from '../quickSettings.js'; import {loadInterfaceXML} from '../../misc/dbusUtils.js'; const DBUS_NAME = 'org.freedesktop.background.Monitor'; const DBUS_OBJECT_PATH = '/org/freedesktop/background/monitor'; const SPINNER_TIMEOUT = 5; // seconds const BackgroundMonitorIface = loadInterfaceXML('org.freedesktop.background.Monitor'); const BackgroundMonitorProxy = Gio.DBusProxy.makeProxyWrapper(BackgroundMonitorIface); Gio._promisify(Gio.DBusConnection.prototype, 'call'); const BackgroundAppMenuItem = GObject.registerClass({ Properties: { 'app': GObject.ParamSpec.object('app', '', '', GObject.ParamFlags.READWRITE, Shell.App), 'message': GObject.ParamSpec.string('message', '', '', GObject.ParamFlags.READWRITE, null), }, }, class BackgroundAppMenuItem extends PopupMenu.PopupImageMenuItem { _init(app, params = {}) { const message = params.message; delete params.message; super._init(app.get_name(), app.get_icon(), { ...params, }); this.set({message}); this.add_style_class_name('background-app-item'); this.label.add_style_class_name('title'); this.app = app; const box = new St.BoxLayout({ vertical: true, x_expand: true, x_align: Clutter.ActorAlign.START, y_align: Clutter.ActorAlign.CENTER, }); this.add_child(box); this.remove_child(this.label); box.add_child(this.label); const messageLabel = new St.Label({ y_expand: true, y_align: Clutter.ActorAlign.CENTER, style_class: 'subtitle', }); box.add_child(messageLabel); this.bind_property('message', messageLabel, 'text', GObject.BindingFlags.SYNC_CREATE); this.bind_property_full('message', messageLabel, 'visible', GObject.BindingFlags.SYNC_CREATE, (bind, source) => [true, source !== null], null); this.set_child_above_sibling(this._ornamentIcon, null); this._spinner = new Spinner(16, {hideOnStop: true}); this._spinner.add_style_class_name('spinner'); this.add_child(this._spinner); const closeButton = new St.Button({ iconName: 'window-close-symbolic', styleClass: 'close-button', x_expand: true, y_expand: false, x_align: Clutter.ActorAlign.END, y_align: Clutter.ActorAlign.CENTER, }); this.add_child(closeButton); this._spinner.bind_property('visible', closeButton, 'visible', GObject.BindingFlags.INVERT_BOOLEAN); closeButton.connect('clicked', () => this._quitApp().catch(logError)); this.connect('activate', () => { Main.overview.hide(); Main.panel.closeQuickSettings(); this.app.activate(); }); this.connect('destroy', () => this._onDestroy()); } _onDestroy() { if (this._spinnerTimeoutId) GLib.source_remove(this._spinnerTimeoutId); delete this._spinnerTimeoutId; } async _quitApp() { const appId = this.app.get_id().replace(/\.desktop$/, ''); this._spinner.play(); this._spinnerTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, SPINNER_TIMEOUT, () => { // Assume the quit request has failed, stop the spinner this._spinner.stop(); delete this._spinnerTimeoutId; return GLib.SOURCE_REMOVE; }); try { await Gio.DBus.session.call( appId, `/${appId.replaceAll('.', '/')}`, 'org.freedesktop.Application', 'ActivateAction', new GLib.Variant('(sava{sv})', ['quit', [], {}]), null, Gio.DBusCallFlags.NONE, -1, null); } catch (_error) { try { Util.trySpawn(['flatpak', 'kill', appId]); } catch (pidError) { logError(pidError, 'Failed to kill application'); } } } }); const BackgroundAppsToggle = GObject.registerClass( class BackgroundAppsToggle extends QuickToggle { _init() { super._init({ visible: false, hasMenu: true, // The background apps toggle looks like a flat menu, but doesn't // have a separate menu button. Fake it with an arrow icon. iconName: 'go-next-symbolic', }); this.add_style_class_name('background-apps-quick-toggle'); this._box.set_child_above_sibling(this._icon, null); this._appSystem = Shell.AppSystem.get_default(); this.menu.setHeader( 'background-app-ghost-symbolic', C_('title', 'Background Apps')); new BackgroundMonitorProxy( Gio.DBus.session, DBUS_NAME, DBUS_OBJECT_PATH, proxy => { this._proxy = proxy; proxy?.connect('g-properties-changed', () => this._sync()); this._sync(); }, null, Gio.DBusProxyFlags.DO_NOT_AUTO_START); this._listTitle = new PopupMenu.PopupMenuItem( _('Apps known to be running without a window'), {reactive: false}); this._listTitle.label.clutter_text.set({ line_wrap: true, }); this.menu.addMenuItem(this._listTitle); this._appsSection = new PopupMenu.PopupMenuSection(); this.menu.addMenuItem(this._appsSection); this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); this.menu.addSettingsAction(_('App Settings'), 'gnome-applications-panel.desktop'); this.connect('popup-menu', () => this.menu.open()); this.menu.connect('open-state-changed', () => this._syncVisibility()); Main.sessionMode.connect('updated', () => this._syncVisibility()); } _syncVisibility() { const {isLocked} = Main.sessionMode; const nBackgroundApps = this._proxy?.BackgroundApps?.length; // We cannot hide the quick toggle while the menu is open, otherwise // the menu position goes bogus. We can't show it in locked sessions // either this.visible = !isLocked && (this.menu.isOpen || nBackgroundApps > 0); } _sync() { this._syncVisibility(); if (!this._proxy) return; const {BackgroundApps: backgroundApps} = this._proxy; this._appsSection.removeAll(); const items = new Map(); (backgroundApps ?? []) .map(backgroundApp => { const appId = backgroundApp.app_id.deepUnpack(); const app = this._appSystem.lookup_app(`${appId}.desktop`); const message = backgroundApp.message?.deepUnpack(); return {app, message}; }) .filter(item => !!item.app) .sort((a, b) => { return a.app.get_name().localeCompare(b.app.get_name()); }) .forEach(backgroundApp => { const {app, message} = backgroundApp; let item = items.get(app); if (!item) { item = new BackgroundAppMenuItem(app); items.set(app, item); this._appsSection.addMenuItem(item); } if (message) item.set({message}); }); const nBackgroundApps = items.size; this.title = nBackgroundApps === 0 ? _('No Background Apps') : ngettext( '%d Background App', '%d Background Apps', nBackgroundApps).format(nBackgroundApps); this._listTitle.visible = nBackgroundApps > 0; } vfunc_clicked() { this.menu.open(); } }); export const Indicator = GObject.registerClass( class Indicator extends SystemIndicator { _init() { super._init(); this.quickSettingsItems.push(new BackgroundAppsToggle()); } });