diff --git a/js/misc/extensionUtils.js b/js/misc/extensionUtils.js index dc227801f8734337ed2bb3371bf8b64eb034b445..2d47a38108bed9fc7c3b8060c55bdf93d037891f 100644 --- a/js/misc/extensionUtils.js +++ b/js/misc/extensionUtils.js @@ -36,6 +36,7 @@ const SERIALIZED_PROPERTIES = [ 'hasPrefs', 'hasUpdate', 'canChange', + 'sessionModes', ]; /** @@ -52,9 +53,7 @@ export function serializeExtension(extension) { obj[prop] = extension[prop]; }); - let res = {}; - for (let key in obj) { - let val = obj[key]; + function packValue(val) { let type; switch (typeof val) { case 'string': @@ -66,13 +65,28 @@ export function serializeExtension(extension) { case 'boolean': type = 'b'; break; + case 'object': + if (Array.isArray(val)) { + type = 'av'; + val = val.map(v => packValue(v)); + } else { + type = 'a{sv}'; + let res = {}; + for (let key in val) { + let packed = packValue(val[key]); + if (packed) + res[key] = packed; + } + val = res; + } + break; default: - continue; + return null; } - res[key] = GLib.Variant.new(type, val); + return GLib.Variant.new(type, val); } - return res; + return packValue(obj).deepUnpack(); } /** @@ -84,7 +98,7 @@ export function serializeExtension(extension) { export function deserializeExtension(variant) { let res = {metadata: {}}; for (let prop in variant) { - let val = variant[prop].unpack(); + let val = variant[prop].recursiveUnpack(); if (SERIALIZED_PROPERTIES.includes(prop)) res[prop] = val; else @@ -95,3 +109,64 @@ export function deserializeExtension(variant) { res.dir = Gio.File.new_for_path(res.path); return res; } + +/** + * Load extension metadata from directory + * + * @param {string} uuid of the extension + * @param {GioFile} dir to load metadata from + * @returns {object} + */ +export function loadExtensionMetadata(uuid, dir) { + const dirName = dir.get_basename(); + if (dirName !== uuid) + throw new Error(`Directory name "${dirName}" does not match UUID "${uuid}"`); + + const metadataFile = dir.get_child('metadata.json'); + if (!metadataFile.query_exists(null)) + throw new Error('Missing metadata.json'); + + let metadataContents, success_; + try { + [success_, metadataContents] = metadataFile.load_contents(null); + metadataContents = new TextDecoder().decode(metadataContents); + } catch (e) { + throw new Error(`Failed to load metadata.json: ${e}`); + } + let meta; + try { + meta = JSON.parse(metadataContents); + } catch (e) { + throw new Error(`Failed to parse metadata.json: ${e}`); + } + + const requiredProperties = [{ + prop: 'uuid', + typeName: 'string', + }, { + prop: 'name', + typeName: 'string', + }, { + prop: 'description', + typeName: 'string', + }, { + prop: 'shell-version', + typeName: 'string array', + typeCheck: v => Array.isArray(v) && v.length > 0 && v.every(e => typeof e === 'string'), + }]; + for (let i = 0; i < requiredProperties.length; i++) { + const { + prop, typeName, typeCheck = v => typeof v === typeName, + } = requiredProperties[i]; + + if (!meta[prop]) + throw new Error(`missing "${prop}" property in metadata.json`); + if (!typeCheck(meta[prop])) + throw new Error(`property "${prop}" is not of type ${typeName}`); + } + + if (uuid !== meta.uuid) + throw new Error(`UUID "${meta.uuid}" from metadata.json does not match directory name "${uuid}"`); + + return meta; +} diff --git a/js/ui/extensionSystem.js b/js/ui/extensionSystem.js index 9b5b944b6b477f79afd975e2cb9b3b802648bfb6..ece089bfd8e1962258065d5db588eb69e3e7035f 100644 --- a/js/ui/extensionSystem.js +++ b/js/ui/extensionSystem.js @@ -8,7 +8,9 @@ import * as Signals from '../misc/signals.js'; import * as Config from '../misc/config.js'; import * as ExtensionDownloader from './extensionDownloader.js'; import {formatError} from '../misc/errorUtils.js'; -import {ExtensionState, ExtensionType} from '../misc/extensionUtils.js'; +import { + ExtensionState, ExtensionType, loadExtensionMetadata +} from '../misc/extensionUtils.js'; import * as FileUtils from '../misc/fileUtils.js'; import * as Main from './main.js'; import * as MessageTray from './messageTray.js'; @@ -377,55 +379,10 @@ export class ExtensionManager extends Signals.EventEmitter { } createExtensionObject(uuid, dir, type) { - let metadataFile = dir.get_child('metadata.json'); - if (!metadataFile.query_exists(null)) - throw new Error('Missing metadata.json'); - - let metadataContents, success_; - try { - [success_, metadataContents] = metadataFile.load_contents(null); - metadataContents = new TextDecoder().decode(metadataContents); - } catch (e) { - throw new Error(`Failed to load metadata.json: ${e}`); - } - let meta; - try { - meta = JSON.parse(metadataContents); - } catch (e) { - throw new Error(`Failed to parse metadata.json: ${e}`); - } - - const requiredProperties = [{ - prop: 'uuid', - typeName: 'string', - }, { - prop: 'name', - typeName: 'string', - }, { - prop: 'description', - typeName: 'string', - }, { - prop: 'shell-version', - typeName: 'string array', - typeCheck: v => Array.isArray(v) && v.length > 0 && v.every(e => typeof e === 'string'), - }]; - for (let i = 0; i < requiredProperties.length; i++) { - const { - prop, typeName, typeCheck = v => typeof v === typeName, - } = requiredProperties[i]; - - if (!meta[prop]) - throw new Error(`missing "${prop}" property in metadata.json`); - if (!typeCheck(meta[prop])) - throw new Error(`property "${prop}" is not of type ${typeName}`); - } - - if (uuid !== meta.uuid) - throw new Error(`uuid "${meta.uuid}" from metadata.json does not match directory name "${uuid}"`); - - let extension = { - metadata: meta, - uuid: meta.uuid, + const metadata = loadExtensionMetadata(uuid, dir); + const extension = { + metadata, + uuid, type, dir, path: dir.get_path(), @@ -434,7 +391,7 @@ export class ExtensionManager extends Signals.EventEmitter { enabled: this._enabledExtensions.includes(uuid), hasUpdate: false, canChange: false, - sessionModes: meta['session-modes'] ? meta['session-modes'] : ['user'], + sessionModes: metadata['session-modes'] ?? ['user'], }; this._extensions.set(uuid, extension); diff --git a/tests/meson.build b/tests/meson.build index 2b59303f264638bfb3fe589d797b12a2341033ca..a8f49a7ab5ff02fde8e6faedcb73b7681df05bb3 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -20,6 +20,7 @@ unit_testenv.append('GI_TYPELIB_PATH', shell_typelib_path, separator: ':') unit_testenv.append('GI_TYPELIB_PATH', st_typelib_path, separator: ':') unit_tests = [ + 'extensionUtils', 'highlighter', 'injectionManager', 'insertSorted', diff --git a/tests/unit/extensionUtils.js b/tests/unit/extensionUtils.js new file mode 100644 index 0000000000000000000000000000000000000000..b54797fc2d7aae322065c65d75346d3b32ac9e7c --- /dev/null +++ b/tests/unit/extensionUtils.js @@ -0,0 +1,141 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +import 'resource:///org/gnome/shell/ui/environment.js'; +import * as ExtensionUtils from 'resource:///org/gnome/shell/misc/extensionUtils.js'; + +const fixturesDir = Gio.File.new_for_uri(`${import.meta.url}/../fixtures/extensions`); + +describe('loadExtensionMetadata()', () => { + const {loadExtensionMetadata} = ExtensionUtils; + + it('fails if directory name does not match requested UUID', () => { + const dir = fixturesDir.get_child('valid'); + expect(() => loadExtensionMetadata('invalid', dir)) + .toThrowError(/does not match UUID/); + }); + + it('fails if metadata.json is missing', () => { + const dir = fixturesDir.get_child('empty'); + expect(() => loadExtensionMetadata('empty', dir)) + .toThrowError(/Missing metadata/); + }); + + it('fails if metadata.json is not valid JSON', () => { + const dir = fixturesDir.get_child('invalid'); + expect(() => loadExtensionMetadata('invalid', dir)) + .toThrowError(/parse metadata/); + }); + + it('fails if metadata.json misses "uuid" property', () => { + const dir = fixturesDir.get_child('missing-uuid'); + expect(() => loadExtensionMetadata('missing-uuid', dir)) + .toThrowError(/missing "uuid"/); + }); + + it('fails if metadata.json misses "name" property', () => { + const dir = fixturesDir.get_child('missing-name'); + expect(() => loadExtensionMetadata('missing-name', dir)) + .toThrowError(/missing "name"/); + }); + + it('fails if metadata.json misses "description" property', () => { + const dir = fixturesDir.get_child('missing-description'); + expect(() => loadExtensionMetadata('missing-description', dir)) + .toThrowError(/missing "description"/); + }); + + it('fails if metadata.json misses "shell-version" property', () => { + const dir = fixturesDir.get_child('missing-shell-version'); + expect(() => loadExtensionMetadata('missing-shell-version', dir)) + .toThrowError(/missing "shell-version"/); + }); + + it('fails if metadata.json "uuid" property is not a string', () => { + const dir = fixturesDir.get_child('invalid-uuid'); + expect(() => loadExtensionMetadata('invalid-uuid', dir)) + .toThrowError(/"uuid" is not of type/); + }); + + it('fails if metadata.json "shell-version" property is not an array', () => { + const dir = fixturesDir.get_child('invalid-shell-version1'); + expect(() => loadExtensionMetadata('invalid-shell-version1', dir)) + .toThrowError(/"shell-version" is not of type/); + }); + + it('fails if metadata.json "shell-version" property does not contain strings', () => { + const dir = fixturesDir.get_child('invalid-shell-version2'); + expect(() => loadExtensionMetadata('invalid-shell-version2', dir)) + .toThrowError(/"shell-version" is not of type/); + }); + + it('fails if metadata.json "uuid" property does not match directory name', () => { + const dir = fixturesDir.get_child('wrong-uuid'); + expect(() => loadExtensionMetadata('wrong-uuid', dir)) + .toThrowError(/does not match directory/); + }); + + it('loads valid metadata.json', () => { + const dir = fixturesDir.get_child('valid'); + expect(() => loadExtensionMetadata('valid', dir)).not.toThrow(); + }); +}); + +describe('serializeExtension()', () => { + const { + loadExtensionMetadata, serializeExtension, deserializeExtension, ExtensionType, + } = ExtensionUtils; + + // based on ExtensionManager + function createExtensionObject(uuid, dir, type) { + const metadata = loadExtensionMetadata(uuid, dir); + const extension = { + metadata, + uuid, + type, + dir, + path: dir.get_path(), + error: '', + hasPrefs: dir.get_child('prefs.js').query_exists(null), + enabled: false, + hasUpdate: false, + canChange: false, + sessionModes: metadata['session-modes'] ?? ['user'], + }; + return extension; + } + const uuid = 'valid'; + const ext = createExtensionObject(uuid, + fixturesDir.get_child(uuid), ExtensionType.PER_USER); + let serialized; + + beforeAll(() => { + jasmine.addCustomEqualityTester((file1, file2) => { + if (file1 instanceof Gio.File && file2 instanceof Gio.File) + return file1.equal(file2); + return undefined; + }); + }); + + it('produces output that can be used as variant of the expected type', () => { + expect(() => { + serialized = serializeExtension(ext); + }).not.toThrow(); + + let v; + expect(() => { + v = new GLib.Variant('a{sv}', serialized); + }).not.toThrow(); + + expect(v.is_of_type(new GLib.VariantType('a{sv}'))).toBeTrue(); + }); + + it('produces output that can be deserialized', () => { + let deserialized; + expect(() => { + deserialized = deserializeExtension(serialized); + }).not.toThrow(); + + expect(deserialized).toEqual(ext); + }); +}); diff --git a/tests/unit/fixtures/extensions/empty/.gitignore b/tests/unit/fixtures/extensions/empty/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/unit/fixtures/extensions/invalid-shell-version1/metadata.json b/tests/unit/fixtures/extensions/invalid-shell-version1/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..8730957c0b4dd0400a061d8b00d5e1e1ded7fe54 --- /dev/null +++ b/tests/unit/fixtures/extensions/invalid-shell-version1/metadata.json @@ -0,0 +1,6 @@ +{ + "uuid": "invalid-shell-version1", + "name": "Some Name", + "description": "Some Description", + "shell-version": "45" +} diff --git a/tests/unit/fixtures/extensions/invalid-shell-version2/metadata.json b/tests/unit/fixtures/extensions/invalid-shell-version2/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..db786d9e98b15ce11cda8d88fffba0d66350afd0 --- /dev/null +++ b/tests/unit/fixtures/extensions/invalid-shell-version2/metadata.json @@ -0,0 +1,9 @@ +{ + "uuid": "invalid-shell-version2", + "name": "Some Name", + "description": "Some Description", + "shell-version": [ + 45, + 46 + ] +} diff --git a/tests/unit/fixtures/extensions/invalid-uuid/metadata.json b/tests/unit/fixtures/extensions/invalid-uuid/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..684601c5c6a60ba6a51e5a067ecdbc1255dd53a7 --- /dev/null +++ b/tests/unit/fixtures/extensions/invalid-uuid/metadata.json @@ -0,0 +1,9 @@ +{ + "uuid": {}, + "name": "Some Name", + "description": "Some Description", + "shell-version": [ + "45", + "46" + ] +} diff --git a/tests/unit/fixtures/extensions/invalid/metadata.json b/tests/unit/fixtures/extensions/invalid/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/unit/fixtures/extensions/missing-description/metadata.json b/tests/unit/fixtures/extensions/missing-description/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..15c7871f2e6f5c2ff2dda5494e2ba70b71088316 --- /dev/null +++ b/tests/unit/fixtures/extensions/missing-description/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "missing-description", + "name": "Some Name", + "shell-version": [ + "45", + "46" + ] +} diff --git a/tests/unit/fixtures/extensions/missing-name/metadata.json b/tests/unit/fixtures/extensions/missing-name/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..eb4ff5daed03fa6662f32dd2aeaca26c7150f046 --- /dev/null +++ b/tests/unit/fixtures/extensions/missing-name/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "missing-name", + "description": "Some Description", + "shell-version": [ + "45", + "46" + ] +} diff --git a/tests/unit/fixtures/extensions/missing-shell-version/metadata.json b/tests/unit/fixtures/extensions/missing-shell-version/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..b3f288f03fc4e7c7b488e5ce3a468f37588dca64 --- /dev/null +++ b/tests/unit/fixtures/extensions/missing-shell-version/metadata.json @@ -0,0 +1,5 @@ +{ + "uuid": "missing-shell-version", + "name": "Some Name", + "description": "Some Description" +} diff --git a/tests/unit/fixtures/extensions/missing-uuid/metadata.json b/tests/unit/fixtures/extensions/missing-uuid/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..dce5f6c31b28cc4083a9b77b6aa64a180eca4574 --- /dev/null +++ b/tests/unit/fixtures/extensions/missing-uuid/metadata.json @@ -0,0 +1,8 @@ +{ + "name": "Some Name", + "description": "Some Description", + "shell-version": [ + "45", + "46" + ] +} diff --git a/tests/unit/fixtures/extensions/valid/metadata.json b/tests/unit/fixtures/extensions/valid/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..3252aa17d61f11898eb8688cbec9fa5cd566bd7e --- /dev/null +++ b/tests/unit/fixtures/extensions/valid/metadata.json @@ -0,0 +1,9 @@ +{ + "uuid": "valid", + "name": "Some Name", + "description": "Some Description", + "shell-version": [ + "45", + "46" + ] +} diff --git a/tests/unit/fixtures/extensions/wrong-uuid/metadata.json b/tests/unit/fixtures/extensions/wrong-uuid/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..c65ab861581c5e6b27b4dbd11694d3bd621ec843 --- /dev/null +++ b/tests/unit/fixtures/extensions/wrong-uuid/metadata.json @@ -0,0 +1,9 @@ +{ + "uuid": "some-uuid", + "name": "Some Name", + "description": "Some Description", + "shell-version": [ + "45", + "46" + ] +}