extensionUtils: Add InjectionManager

It is fairly common for extensions to monkey-patch existing
classes. Add a small helper class that makes this a tad bit
more convenient.

Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2809>
This commit is contained in:
Florian Müllner 2023-06-23 19:53:05 +02:00
parent 7c4b1d4ae6
commit f70a75a905
3 changed files with 162 additions and 0 deletions

View File

@ -29,3 +29,71 @@ export class Extension extends ExtensionBase {
export const {
gettext, ngettext, pgettext,
} = Extension.defineTranslationFunctions();
export class InjectionManager {
#savedMethods = new Map();
/**
* @callback CreateOverrideFunc
* @param {Function?} originalMethod - the original method if it exists
* @returns {Function} - a function to be used as override
*/
/**
* Modify, replace or inject a method
*
* @param {object} prototype - the object (or prototype) that is modified
* @param {string} methodName - the name of the overwritten method
* @param {CreateOverrideFunc} createOverrideFunc - function to call to create the override
*/
overrideMethod(prototype, methodName, createOverrideFunc) {
const originalMethod = this._saveMethod(prototype, methodName);
prototype[methodName] = createOverrideFunc(originalMethod);
}
/**
* Restore the original method
*
* @param {object} prototype - the object (or prototype) that is modified
* @param {string} methodName - the name of the method to restore
*/
restoreMethod(prototype, methodName) {
const savedProtoMethods = this.#savedMethods.get(prototype);
if (!savedProtoMethods)
return;
const originalMethod = savedProtoMethods.get(methodName);
if (originalMethod === undefined)
delete prototype[methodName];
else
prototype[methodName] = originalMethod;
savedProtoMethods.delete(methodName);
if (savedProtoMethods.size === 0)
this.#savedMethods.delete(prototype);
}
/**
* Restore all original methods and clear overrides
*/
clear() {
for (const [proto, map] of this.#savedMethods) {
map.forEach(
(_, methodName) => this.restoreMethod(proto, methodName));
}
console.assert(this.#savedMethods.size === 0,
`${this.#savedMethods.size} overrides left after clear()`);
}
_saveMethod(prototype, methodName) {
let savedProtoMethods = this.#savedMethods.get(prototype);
if (!savedProtoMethods) {
savedProtoMethods = new Map();
this.#savedMethods.set(prototype, savedProtoMethods);
}
const originalMethod = prototype[methodName];
savedProtoMethods.set(methodName, originalMethod);
return originalMethod;
}
}

View File

@ -20,6 +20,7 @@ dbus_runner = configure_file(
unit_tests = [
'highlighter',
'injectionManager',
'insertSorted',
'jsParse',
'markup',

View File

@ -0,0 +1,93 @@
const JsUnit = imports.jsUnit;
import '../../js/ui/environment.js';
import {InjectionManager} from '../../js/extensions/extension.js';
class Object1 {
count = 0;
getNumber() {
return 42;
}
getCount() {
return ++this.count;
}
}
/**
* @param {object} object to modify
*/
function addInjections(object) {
// extend original method
injectionManager.overrideMethod(
object, 'getNumber', originalMethod => {
return function () {
// eslint-disable-next-line no-invalid-this
const num = originalMethod.call(this);
return 2 * num;
};
});
// override original method
injectionManager.overrideMethod(
object, 'getCount', () => {
return function () {
return 42;
};
});
// inject new method
injectionManager.overrideMethod(
object, 'getOtherNumber', () => {
return function () {
return 42;
};
});
}
const injectionManager = new InjectionManager();
let obj;
// Prototype injections
addInjections(Object1.prototype);
obj = new Object1();
// new obj is modified
JsUnit.assertEquals(obj.getNumber(), 84);
JsUnit.assertEquals(obj.getCount(), 42);
JsUnit.assertEquals(obj.count, 0);
JsUnit.assertEquals(obj.getOtherNumber(), 42);
injectionManager.clear();
obj = new Object1();
// new obj is unmodified
JsUnit.assertEquals(obj.getNumber(), 42);
JsUnit.assertEquals(obj.getCount(), obj.count);
JsUnit.assert(obj.count > 0);
JsUnit.assertRaises(() => obj.getOtherNumber());
// instance injections
addInjections(obj);
// obj is now modified
JsUnit.assertEquals(obj.getNumber(), 84);
JsUnit.assertEquals(obj.getCount(), 42);
JsUnit.assertEquals(obj.count, 1);
JsUnit.assertEquals(obj.getOtherNumber(), 42);
injectionManager.restoreMethod(obj, 'getNumber');
JsUnit.assertEquals(obj.getNumber(), 42);
injectionManager.clear();
// obj is unmodified again
JsUnit.assertEquals(obj.getNumber(), 42);
JsUnit.assertEquals(obj.getCount(), obj.count);
JsUnit.assert(obj.count > 0);
JsUnit.assertRaises(() => obj.getOtherNumber());