2bc4215d1d
While extremely rare, flatpak apps are not guaranteed to provide a .desktop file. We don't have anything to represent the app in that case, but at least we shouldn't break when trying to access properties on null, so filter out these entries. Closes https://gitlab.gnome.org/GNOME/gnome-shell/-/issues/6913 Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2938>
270 lines
8.5 KiB
JavaScript
270 lines
8.5 KiB
JavaScript
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());
|
|
}
|
|
});
|