diff --git a/data/dbus-interfaces/org.freedesktop.background.Monitor.xml b/data/dbus-interfaces/org.freedesktop.background.Monitor.xml
new file mode 100644
index 000000000..1a2028a4e
--- /dev/null
+++ b/data/dbus-interfaces/org.freedesktop.background.Monitor.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/data/gnome-shell-dbus-interfaces.gresource.xml b/data/gnome-shell-dbus-interfaces.gresource.xml
index 6682c462d..31d1f1266 100644
--- a/data/gnome-shell-dbus-interfaces.gresource.xml
+++ b/data/gnome-shell-dbus-interfaces.gresource.xml
@@ -6,6 +6,7 @@
net.reactivated.Fprint.Device.xml
net.reactivated.Fprint.Manager.xml
org.freedesktop.Application.xml
+ org.freedesktop.background.Monitor.xml
org.freedesktop.bolt1.Device.xml
org.freedesktop.bolt1.Manager.xml
org.freedesktop.DBus.xml
diff --git a/data/gnome-shell-icons.gresource.xml b/data/gnome-shell-icons.gresource.xml
index d808c9be6..fb33b22dc 100644
--- a/data/gnome-shell-icons.gresource.xml
+++ b/data/gnome-shell-icons.gresource.xml
@@ -18,6 +18,7 @@
scalable/actions/screenshot-ui-show-pointer-symbolic.svg
scalable/actions/screenshot-ui-window-symbolic.svg
scalable/actions/screenshot-recorded-symbolic.svg
+ scalable/status/background-app-ghost-symbolic.svg
scalable/status/keyboard-caps-lock-symbolic.svg
scalable/status/keyboard-enter-symbolic.svg
scalable/status/keyboard-hide-symbolic.svg
diff --git a/data/icons/scalable/status/background-app-ghost-symbolic.svg b/data/icons/scalable/status/background-app-ghost-symbolic.svg
new file mode 100644
index 000000000..213b6010f
--- /dev/null
+++ b/data/icons/scalable/status/background-app-ghost-symbolic.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/data/theme/gnome-shell-sass/widgets/_quick-settings.scss b/data/theme/gnome-shell-sass/widgets/_quick-settings.scss
index 0064aeed3..6a4c42d9a 100644
--- a/data/theme/gnome-shell-sass/widgets/_quick-settings.scss
+++ b/data/theme/gnome-shell-sass/widgets/_quick-settings.scss
@@ -149,3 +149,27 @@
}
.device-subtitle { color: transparentize($fg_color, 0.5); }
+
+// background apps
+
+.background-apps-quick-toggle {
+ min-height: 40px;
+ background-color: transparent;
+
+ & StIcon { icon-size: $base_icon_size !important; }
+}
+
+.background-app-item {
+ & .title { @extend %heading; }
+ & .subtitle { @extend %caption; }
+ & .popup-menu-icon {
+ icon-size: $large_icon_size !important;
+ -st-icon-style: regular !important;
+ }
+ & .close-button {
+ @extend .icon-button;
+ padding: $base_padding;
+ }
+
+ &.popup-inactive-menu-item { color: $fg_color; }
+}
diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml
index 4213081d2..ba481aafa 100644
--- a/js/js-resources.gresource.xml
+++ b/js/js-resources.gresource.xml
@@ -135,6 +135,7 @@
ui/status/accessibility.js
ui/status/autoRotate.js
+ ui/status/backgroundApps.js
ui/status/brightness.js
ui/status/darkMode.js
ui/status/dwellClick.js
diff --git a/js/ui/panel.js b/js/ui/panel.js
index 0dd62561f..19a28dedc 100644
--- a/js/ui/panel.js
+++ b/js/ui/panel.js
@@ -366,6 +366,7 @@ class QuickSettings extends PanelMenu.Button {
this._rfkill = new imports.ui.status.rfkill.Indicator();
this._autoRotate = new imports.ui.status.autoRotate.Indicator();
this._unsafeMode = new UnsafeModeIndicator();
+ this._backgroundApps = new imports.ui.status.backgroundApps.Indicator();
this._indicators.add_child(this._brightness);
this._indicators.add_child(this._remoteAccess);
@@ -401,6 +402,8 @@ class QuickSettings extends PanelMenu.Button {
this._addItems(this._rfkill.quickSettingsItems);
this._addItems(this._autoRotate.quickSettingsItems);
this._addItems(this._unsafeMode.quickSettingsItems);
+
+ this._addItems(this._backgroundApps.quickSettingsItems, N_QUICK_SETTINGS_COLUMNS);
}
_addItems(items, colSpan = 1) {
diff --git a/js/ui/status/backgroundApps.js b/js/ui/status/backgroundApps.js
new file mode 100644
index 000000000..606fa3348
--- /dev/null
+++ b/js/ui/status/backgroundApps.js
@@ -0,0 +1,225 @@
+/* exported Indicator */
+const {Clutter, Gio, GLib, GObject, Shell, St} = imports.gi;
+
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+const Util = imports.misc.util;
+
+const {QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+const {loadInterfaceXML} = imports.misc.dbusUtils;
+
+const DBUS_NAME = 'org.freedesktop.background.Monitor';
+const DBUS_OBJECT_PATH = '/org/freedesktop/background/monitor';
+
+const BackgroundMonitorIface = loadInterfaceXML('org.freedesktop.background.Monitor');
+const BackgroundMonitorProxy = Gio.DBusProxy.makeProxyWrapper(BackgroundMonitorIface);
+
+Gio._promisify(Gio.DBusConnection.prototype, 'call');
+
+var BackgroundAppMenuItem = GObject.registerClass({
+ Properties: {
+ 'app': GObject.ParamSpec.object('app', '', '',
+ GObject.ParamFlags.READWRITE,
+ Shell.App),
+ 'instance': GObject.ParamSpec.int64('instance', '', '',
+ GObject.ParamFlags.READWRITE,
+ -1, GLib.MAXINT64_BIGINT, -1),
+ 'message': GObject.ParamSpec.string('message', '', '',
+ GObject.ParamFlags.READWRITE,
+ null),
+ },
+}, class BackgroundAppMenuItem extends PopupMenu.PopupImageMenuItem {
+ _init(app, params = {}) {
+ const message = params.message;
+ delete params.message;
+
+ const instance = params.instance;
+ delete params.instance;
+
+ super._init(app.get_name(), app.get_icon(), {
+ activate: false,
+ reactive: false,
+ ...params,
+ });
+
+ this.set({message, instance});
+
+ 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._ornamentLabel, null);
+
+ 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);
+
+ closeButton.connect('clicked', () => this._quitApp().catch(logError));
+ }
+
+ async _quitApp() {
+ const appId = this.app.get_id().replace(/\.desktop$/, '');
+
+ 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', this.instance]);
+ } 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._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;
+
+ const nBackgroundApps = backgroundApps?.length ?? 0;
+ this.title = nBackgroundApps === 0
+ ? _('No Background Apps')
+ : ngettext(
+ '%d Background App',
+ '%d Background Apps',
+ nBackgroundApps).format(nBackgroundApps);
+
+ this._appsSection.removeAll();
+
+ if (!backgroundApps)
+ return;
+
+ backgroundApps
+ .map(backgroundApp => {
+ const appId = backgroundApp.app_id.deepUnpack();
+ const app = this._appSystem.lookup_app(`${appId}.desktop`);
+ const message = backgroundApp.message?.deepUnpack();
+ const instance = backgroundApp.instance.deepUnpack();
+
+ return {app, message, instance};
+ })
+ .sort((a, b) => {
+ return a.app.get_name().localeCompare(b.app.get_name());
+ })
+ .forEach(backgroundApp => {
+ const {app, message, instance} = backgroundApp;
+
+ const item = new BackgroundAppMenuItem(app,
+ {instance, message});
+ this._appsSection.addMenuItem(item);
+ });
+ }
+
+ vfunc_clicked() {
+ this.menu.open();
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this.quickSettingsItems.push(new BackgroundAppsToggle());
+ }
+});
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 5ceecebcb..88409eb95 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -58,6 +58,7 @@ js/ui/shellEntry.js
js/ui/shellMountOperation.js
js/ui/status/accessibility.js
js/ui/status/autoRotate.js
+js/ui/status/backgroundApps.js
js/ui/status/bluetooth.js
js/ui/status/brightness.js
js/ui/status/darkMode.js