extensions: Add Extension/Preferences base classes

Extensions now must export a class that conforms to a particular
interface both for the actual extension as well as for prefs:

enable()/disable() methods for the former, fillPreferencesWindow()
for the latter.

This is quite similar to the previous method-based entry points,
but it also gives us a more structured way of providing convenience
API in form of base classes.

Do that in form of Extension and ExtensionPreferences classes on
top of a common ExtensionBase base class.

getSettings(), initTranslations() and the gettext wrappers are
now methods of the common base, while openPreferences() moves
to the Extension class.

Based on an original suggestion from Evan Welsh.

Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2838>
This commit is contained in:
Florian Müllner 2023-07-01 02:11:37 +02:00
parent 6a34b2636d
commit 3e4fd4b67a
3 changed files with 173 additions and 92 deletions

View File

@ -1,24 +1,21 @@
import {getCurrentExtension, setExtensionManager} from './sharedInternals.js';
import {ExtensionBase, setExtensionManager} from './sharedInternals.js';
export {
getSettings,
initTranslations,
gettext,
ngettext,
pgettext
} from './sharedInternals.js';
export {gettext, ngettext, pgettext} from './sharedInternals.js';
const {extensionManager} = imports.ui.main;
setExtensionManager(extensionManager);
/**
* Open the preference dialog of the current extension
*/
export function openPrefs() {
const extension = getCurrentExtension();
export class Extension extends ExtensionBase {
enable() {
}
if (!extension)
throw new Error('openPrefs() can only be called from extensions');
disable() {
}
extensionManager.openExtensionPrefs(extension.uuid, '', {});
/**
* Open the extension's preferences window
*/
openPreferences() {
extensionManager.openExtensionPrefs(this.uuid, '', {});
}
}

View File

@ -1,12 +1,19 @@
import {setExtensionManager} from './sharedInternals.js';
import GObject from 'gi://GObject';
import {ExtensionBase, setExtensionManager} from './sharedInternals.js';
import {extensionManager} from '../extensionsService.js';
setExtensionManager(extensionManager);
export {
getSettings,
initTranslations,
gettext,
ngettext,
pgettext
} from './sharedInternals.js';
export {gettext, ngettext, pgettext} from './sharedInternals.js';
export class ExtensionPreferences extends ExtensionBase {
/**
* Fill the preferences window with preferences.
*
* @param {Adw.PreferencesWindow} _window - the preferences window
*/
fillPreferencesWindow(_window) {
throw new GObject.NotImplementedError();
}
}

View File

@ -1,7 +1,7 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import * as Gettext from 'gettext';
import {bindtextdomain} from 'gettext';
const Config = imports.misc.config;
@ -17,13 +17,154 @@ export function setExtensionManager(extensionManager) {
_extensionManager = extensionManager;
}
export class ExtensionBase {
#gettextDomain;
/**
* @param {object} metadata - metadata passed in when loading the extension
*/
constructor(metadata) {
if (this.constructor === ExtensionBase)
throw new Error('ExtensionBase cannot be used directly.');
if (!metadata)
throw new Error(`${this.constructor.name} did not pass metadata to parent`);
this.metadata = metadata;
}
/**
* @type {string}
*/
get uuid() {
return this.metadata['uuid'];
}
/**
* @type {Gio.File}
*/
get dir() {
return this.metadata['dir'];
}
/**
* @type {string}
*/
get path() {
return this.metadata['path'];
}
/**
* Get a GSettings object for schema, using schema files in
* extensionsdir/schemas. If schema is omitted, it is taken
* from metadata['settings-schema'].
*
* @param {string=} schema - the GSettings schema id
*
* @returns {Gio.Settings}
*/
getSettings(schema) {
schema ||= this.metadata['settings-schema'];
// Expect USER extensions to have a schemas/ subfolder, otherwise assume a
// SYSTEM extension that has been installed in the same prefix as the shell
const schemaDir = this.dir.get_child('schemas');
const defaultSource = Gio.SettingsSchemaSource.get_default();
let schemaSource;
if (schemaDir.query_exists(null)) {
schemaSource = Gio.SettingsSchemaSource.new_from_directory(
schemaDir.get_path(), defaultSource, false);
} else {
schemaSource = defaultSource;
}
const schemaObj = schemaSource.lookup(schema, true);
if (!schemaObj)
throw new Error(`Schema ${schema} could not be found for extension ${this.uuid}. Please check your installation`);
return new Gio.Settings({settings_schema: schemaObj});
}
/**
* Initialize Gettext to load translations from extensionsdir/locale. If
* domain is not provided, it will be taken from metadata['gettext-domain']
*
* @param {string=} domain - the gettext domain to use
*/
initTranslations(domain) {
domain ||= this.metadata['gettext-domain'];
if (!domain)
throw new Error('initTranslations() was called without providing a valid domain');
// Expect USER extensions to have a locale/ subfolder, otherwise assume a
// SYSTEM extension that has been installed in the same prefix as the shell
const localeDir = this.dir.get_child('locale');
if (localeDir.query_exists(null))
bindtextdomain(domain, localeDir.get_path());
else
bindtextdomain(domain, Config.LOCALEDIR);
this.#gettextDomain = domain;
}
/**
* Translate `str` using the extension's gettext domain
*
* @param {string} str - the string to translate
*
* @returns {string} the translated string
*/
gettext(str) {
this.#checkGettextDomain('gettext');
return GLib.dgettext(this.#gettextDomain, str);
}
/**
* Translate `str` and choose plural form using the extension's
* gettext domain
*
* @param {string} str - the string to translate
* @param {string} strPlural - the plural form of the string
* @param {number} n - the quantity for which translation is needed
*
* @returns {string} the translated string
*/
ngettext(str, strPlural, n) {
this.#checkGettextDomain('ngettext');
return GLib.dngettext(this.#gettextDomain, str, strPlural, n);
}
/**
* Translate `str` in the context of `context` using the extension's
* gettext domain
*
* @param {string} context - context to disambiguate `str`
* @param {string} str - the string to translate
*
* @returns {string} the translated string
*/
pgettext(context, str) {
this.#checkGettextDomain('pgettext');
return GLib.dpgettext2(this.#gettextDomain, context, str);
}
/**
* @param {string} func
*/
#checkGettextDomain(func) {
if (!this.#gettextDomain)
throw new Error(`${func}() is used without calling initTranslations() first`);
}
}
/**
* getCurrentExtension:
* @private
*
* @returns {?object} - The current extension, or null if not called from
* an extension.
*/
export function getCurrentExtension() {
function getCurrentExtension() {
const basePath = '/gnome-shell/extensions/';
// Search for an occurrence of an extension stack frame
@ -51,37 +192,12 @@ export function getCurrentExtension() {
const dirName = GLib.path_get_basename(path);
const extension = _extensionManager.lookup(dirName);
if (extension !== undefined)
return extension;
return extension.stateObj;
} while (path !== '/');
return null;
}
/**
* Initialize Gettext to load translations from extensionsdir/locale.
* If @domain is not provided, it will be taken from metadata['gettext-domain']
*
* @param {string=} domain - the gettext domain to use
*/
export function initTranslations(domain) {
let extension = getCurrentExtension();
if (!extension)
throw new Error('initTranslations() can only be called from extensions');
domain ||= extension.metadata['gettext-domain'];
// Expect USER extensions to have a locale/ subfolder, otherwise assume a
// SYSTEM extension that has been installed in the same prefix as the shell
let localeDir = extension.dir.get_child('locale');
if (localeDir.query_exists(null))
Gettext.bindtextdomain(domain, localeDir.get_path());
else
Gettext.bindtextdomain(domain, Config.LOCALEDIR);
Object.assign(extension, Gettext.domain(domain));
}
/**
* Translate @str using the extension's gettext domain
*
@ -131,44 +247,5 @@ function callExtensionGettextFunc(func, ...args) {
if (!extension)
throw new Error(`${func}() can only be called from extensions`);
if (!extension[func])
throw new Error(`${func}() is used without calling initTranslations() first`);
return extension[func](...args);
}
/**
* Builds and returns a GSettings schema for @schema, using schema files
* in extensionsdir/schemas. If @schema is omitted, it is taken from
* metadata['settings-schema'].
*
* @param {string=} schema - the GSettings schema id
* @returns {Gio.Settings} - a new settings object for @schema
*/
export function getSettings(schema) {
let extension = getCurrentExtension();
if (!extension)
throw new Error('getSettings() can only be called from extensions');
schema ||= extension.metadata['settings-schema'];
const GioSSS = Gio.SettingsSchemaSource;
// Expect USER extensions to have a schemas/ subfolder, otherwise assume a
// SYSTEM extension that has been installed in the same prefix as the shell
let schemaDir = extension.dir.get_child('schemas');
let schemaSource;
if (schemaDir.query_exists(null)) {
schemaSource = GioSSS.new_from_directory(
schemaDir.get_path(), GioSSS.get_default(), false);
} else {
schemaSource = GioSSS.get_default();
}
let schemaObj = schemaSource.lookup(schema, true);
if (!schemaObj)
throw new Error(`Schema ${schema} could not be found for extension ${extension.metadata.uuid}. Please check your installation`);
return new Gio.Settings({settings_schema: schemaObj});
}