diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml
index 6efbe723a..eb4e365ae 100644
--- a/js/js-resources.gresource.xml
+++ b/js/js-resources.gresource.xml
@@ -27,6 +27,7 @@
misc/params.js
misc/parentalControlsManager.js
misc/permissionStore.js
+ misc/signalTracker.js
misc/smartcardManager.js
misc/systemActions.js
misc/util.js
diff --git a/js/misc/signalTracker.js b/js/misc/signalTracker.js
new file mode 100644
index 000000000..936681475
--- /dev/null
+++ b/js/misc/signalTracker.js
@@ -0,0 +1,214 @@
+/* exported addObjectSignalMethods */
+const { GObject } = imports.gi;
+
+class SignalManager {
+ /**
+ * @returns {SignalManager} - the SignalManager singleton
+ */
+ static getDefault() {
+ if (!this._singleton)
+ this._singleton = new SignalManager();
+ return this._singleton;
+ }
+
+ constructor() {
+ this._signalTrackers = new Map();
+ }
+
+ /**
+ * @param {Object} obj - object to get signal tracker for
+ * @returns {SignalTracker} - the signal tracker for object
+ */
+ getSignalTracker(obj) {
+ if (!this._signalTrackers.has(obj))
+ this._signalTrackers.set(obj, new SignalTracker(obj));
+ return this._signalTrackers.get(obj);
+ }
+}
+
+class SignalTracker {
+ /**
+ * @param {Object=} owner - object that owns the tracker
+ */
+ constructor(owner) {
+ if (this._hasDestroySignal(owner))
+ this._ownerDestroyId = owner.connect('destroy', () => this.clear());
+
+ this._owner = owner;
+ this._map = new Map();
+ }
+
+ /**
+ * @private
+ * @param {Object} obj - an object
+ * @returns {bool} - true if obj has a 'destroy' GObject signal
+ */
+ _hasDestroySignal(obj) {
+ return obj instanceof GObject.Object &&
+ GObject.signal_lookup('destroy', obj);
+ }
+
+ /**
+ * @typedef SignalData
+ * @property {number[]} ownerSignals - a list of handler IDs
+ * @property {number} destroyId - destroy handler ID of tracked object
+ */
+
+ /**
+ * @private
+ * @param {Object} obj - a tracked object
+ * @returns {SignalData} - signal data for object
+ */
+ _getSignalData(obj) {
+ if (!this._map.has(obj))
+ this._map.set(obj, { ownerSignals: [], destroyId: 0 });
+ return this._map.get(obj);
+ }
+
+ /**
+ * @private
+ * @param {GObject.Object} obj - tracked widget
+ */
+ _trackDestroy(obj) {
+ const signalData = this._getSignalData(obj);
+ if (signalData.destroyId)
+ return;
+ signalData.destroyId = obj.connect('destroy', () => this.untrack(obj));
+ }
+
+ _disconnectSignal(obj, id) {
+ const proto = obj instanceof GObject.Object
+ ? GObject.Object.prototype
+ : Object.getPrototypeOf(obj);
+ proto['disconnect'].call(obj, id);
+ }
+
+ /**
+ * @param {Object} obj - tracked object
+ * @param {...number} handlerIds - tracked handler IDs
+ * @returns {void}
+ */
+ track(obj, ...handlerIds) {
+ if (this._hasDestroySignal(obj))
+ this._trackDestroy(obj);
+
+ this._getSignalData(obj).ownerSignals.push(...handlerIds);
+ }
+
+ /**
+ * @param {Object} obj - tracked object instance
+ * @returns {void}
+ */
+ untrack(obj) {
+ const { ownerSignals, destroyId } = this._getSignalData(obj);
+ this._map.delete(obj);
+
+ ownerSignals.forEach(id => this._disconnectSignal(this._owner, id));
+ if (destroyId)
+ this._disconnectSignal(obj, destroyId);
+ }
+
+ /**
+ * @returns {void}
+ */
+ clear() {
+ [...this._map.keys()].forEach(obj => this.untrack(obj));
+ }
+
+ /**
+ * @returns {void}
+ */
+ destroy() {
+ this.clear();
+
+ if (this._ownerDestroyId)
+ this._disconnectSignal(this._owner, this._ownerDestroyId);
+
+ delete this._ownerDestroyId;
+ delete this._owner;
+ }
+}
+
+/**
+ * Connect one or more signals, and associate the handlers
+ * with a tracked object.
+ *
+ * All handlers for a particular object can be disconnected
+ * by calling disconnectObject(). If object is a {Clutter.widget},
+ * this is done automatically when the widget is destroyed.
+ *
+ * @param {object} thisObj - the emitter object
+ * @param {...any} args - a sequence of signal-name/handler pairs
+ * with an optional flags value, followed by an object to track
+ * @returns {void}
+ */
+function connectObject(thisObj, ...args) {
+ const getParams = argArray => {
+ const [signalName, handler, arg, ...rest] = argArray;
+ if (typeof arg !== 'number')
+ return [signalName, handler, 0, arg, ...rest];
+
+ const flags = arg;
+ if (flags > GObject.ConnectFlags.SWAPPED)
+ throw new Error(`Invalid flag value ${flags}`);
+ if (flags === GObject.ConnectFlags.SWAPPED)
+ throw new Error('Swapped signals are not supported');
+ return [signalName, handler, flags, ...rest];
+ };
+
+ const connectSignal = (emitter, signalName, handler, flags) => {
+ const isGObject = emitter instanceof GObject.Object;
+ const func = flags === GObject.ConnectFlags.AFTER && isGObject
+ ? 'connect_after'
+ : 'connect';
+ const emitterProto = isGObject
+ ? GObject.Object.prototype
+ : Object.getPrototypeOf(emitter);
+ return emitterProto[func].call(emitter, signalName, handler);
+ };
+
+ const signalIds = [];
+ while (args.length > 1) {
+ const [signalName, handler, flags, ...rest] = getParams(args);
+ signalIds.push(connectSignal(thisObj, signalName, handler, flags));
+ args = rest;
+ }
+
+ let [obj] = args;
+ if (!obj)
+ obj = globalThis;
+
+ const tracker = SignalManager.getDefault().getSignalTracker(thisObj);
+ tracker.track(obj, ...signalIds);
+}
+
+/**
+ * Disconnect all signals that were connected for
+ * the specified tracked object
+ *
+ * @param {Object} thisObj - the emitter object
+ * @param {Object} obj - the tracked object
+ * @returns {void}
+ */
+function disconnectObject(thisObj, obj) {
+ SignalManager.getDefault().getSignalTracker(thisObj).untrack(obj);
+}
+
+/**
+ * Add connectObject()/disconnectObject() methods
+ * to prototype. The prototype must have the connect()
+ * and disconnect() signal methods.
+ *
+ * @param {prototype} proto - a prototype
+ */
+function addObjectSignalMethods(proto) {
+ proto['connectObject'] = function (...args) {
+ connectObject(this, ...args);
+ };
+ proto['connect_object'] = proto['connectObject'];
+
+ proto['disconnectObject'] = function (obj) {
+ disconnectObject(this, obj);
+ };
+ proto['disconnect_object'] = proto['disconnectObject'];
+}
diff --git a/js/ui/environment.js b/js/ui/environment.js
index 099cf10f6..2d3baf722 100644
--- a/js/ui/environment.js
+++ b/js/ui/environment.js
@@ -26,7 +26,9 @@ try {
const { Clutter, Gio, GLib, GObject, Meta, Polkit, Shell, St } = imports.gi;
const Gettext = imports.gettext;
+const Signals = imports.signals;
const System = imports.system;
+const SignalTracker = imports.misc.signalTracker;
Gio._promisify(Gio.DataInputStream.prototype, 'fill_async');
Gio._promisify(Gio.DataInputStream.prototype, 'read_line_async');
@@ -324,6 +326,25 @@ function init() {
GObject.gtypeNameBasedOnJSPath = true;
+ GObject.Object.prototype.connectObject = function (...args) {
+ SignalTracker.connectObject(this, ...args);
+ };
+ GObject.Object.prototype.connect_object = function (...args) {
+ SignalTracker.connectObject(this, ...args);
+ };
+ GObject.Object.prototype.disconnectObject = function (...args) {
+ SignalTracker.disconnectObject(this, ...args);
+ };
+ GObject.Object.prototype.disconnect_object = function (...args) {
+ SignalTracker.disconnectObject(this, ...args);
+ };
+
+ const _addSignalMethods = Signals.addSignalMethods;
+ Signals.addSignalMethods = function (prototype) {
+ _addSignalMethods(prototype);
+ SignalTracker.addObjectSignalMethods(prototype);
+ };
+
// Miscellaneous monkeypatching
_patchContainerClass(St.BoxLayout);
diff --git a/tests/meson.build b/tests/meson.build
index 50f8313e9..9d3925d36 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -16,6 +16,7 @@ tests = [
'jsParse',
'markup',
'params',
+ 'signalTracker',
'url',
'versionCompare',
]
diff --git a/tests/unit/signalTracker.js b/tests/unit/signalTracker.js
new file mode 100644
index 000000000..f13327ec1
--- /dev/null
+++ b/tests/unit/signalTracker.js
@@ -0,0 +1,79 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+
+// Test cases for version comparison
+
+const { GObject } = imports.gi;
+
+const JsUnit = imports.jsUnit;
+const Signals = imports.signals;
+
+const Environment = imports.ui.environment;
+Environment.init();
+
+const Destroyable = GObject.registerClass({
+ Signals: { 'destroy': {} },
+}, class Destroyable extends GObject.Object {});
+
+class PlainEmitter {}
+Signals.addSignalMethods(PlainEmitter.prototype);
+
+const GObjectEmitter = GObject.registerClass({
+ Signals: { 'signal': {} },
+}, class GObjectEmitter extends Destroyable {});
+
+const emitter1 = new PlainEmitter();
+const emitter2 = new GObjectEmitter();
+
+const tracked1 = new Destroyable();
+const tracked2 = {};
+
+let count = 0;
+const handler = () => count++;
+
+emitter1.connectObject('signal', handler, tracked1);
+emitter2.connectObject('signal', handler, tracked1);
+
+emitter1.connectObject('signal', handler, tracked2);
+emitter2.connectObject('signal', handler, tracked2);
+
+JsUnit.assertEquals(count, 0);
+
+emitter1.emit('signal');
+emitter2.emit('signal');
+
+JsUnit.assertEquals(count, 4);
+
+tracked1.emit('destroy');
+
+emitter1.emit('signal');
+emitter2.emit('signal');
+
+JsUnit.assertEquals(count, 6);
+
+emitter1.disconnectObject(tracked2);
+emitter2.emit('destroy');
+
+emitter1.emit('signal');
+emitter2.emit('signal');
+
+JsUnit.assertEquals(count, 6);
+
+emitter1.connectObject(
+ 'signal', handler,
+ 'signal', handler, GObject.ConnectFlags.AFTER,
+ tracked1);
+emitter2.connectObject(
+ 'signal', handler,
+ 'signal', handler, GObject.ConnectFlags.AFTER,
+ tracked1);
+
+emitter1.emit('signal');
+emitter2.emit('signal');
+
+JsUnit.assertEquals(count, 10);
+
+tracked1.emit('destroy');
+emitter1.emit('signal');
+emitter2.emit('signal');
+
+JsUnit.assertEquals(count, 10);