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);