gnome-shell/js/extensions/sharedInternals.js
Florian Müllner af5dd7ddd1 extensions/shared: Don't cache detected URL
The translation functions we export from extension utils must
work with all extensions, not only the first that calls one
of the functions.

That means that we are back to examining a backtrace for every
function call unless an extension defined its own translation
functions with `import.meta.url`.

Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2887>
2023-08-14 11:46:57 +00:00

279 lines
8.4 KiB
JavaScript

import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import {bindtextdomain} from 'gettext';
import * as Config from '../misc/config.js';
export class ExtensionBase {
#gettextDomain;
/**
* Look up an extension by URL (usually 'import.meta.url')
*
* @param {string} url - a file:// URL
*/
static lookupByURL(url) {
if (!url.startsWith('file://'))
return null;
// Keep the last '/' from 'file://' to force an absolute path
let path = url.slice(6);
// Walk up the directory tree, looking for an extension with
// the same UUID as a directory name.
do {
path = GLib.path_get_dirname(path);
const dirName = GLib.path_get_basename(path);
const extension = this.lookupByUUID(dirName);
if (extension !== null)
return extension;
} while (path !== '/');
return null;
}
/**
* Look up an extension by UUID
*
* @param {string} _uuid
*/
static lookupByUUID(_uuid) {
throw new GObject.NotImplementedError();
}
/**
* @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;
const domain = this.metadata['gettext-domain'];
if (domain)
this.initTranslations(domain);
}
/**
* @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`);
}
}
export class GettextWrapper {
#url;
#extensionClass;
constructor(extensionClass, url) {
this.#url = url;
this.#extensionClass = extensionClass;
}
#detectUrl() {
const basePath = '/gnome-shell/extensions/';
// Search for an occurrence of an extension stack frame
// Start at 1 because 0 is the stack frame of this function
const [, ...stack] = new Error().stack.split('\n');
const extensionLine = stack.find(
line => line.includes(basePath));
if (!extensionLine)
return null;
// The exact stack line differs depending on where the function
// was called (function or module scope), and whether it's called
// from a module or legacy import (file:// URI vs. plain path).
//
// We don't have to care about the exact composition, all we need
// is a string that can be traversed as path and contains the UUID
const path = extensionLine.slice(extensionLine.indexOf(basePath));
return `file://${path}`;
}
#lookupExtension(funcName) {
const url = this.#url ?? this.#detectUrl();
const extension = this.#extensionClass.lookupByURL(url);
if (!extension)
throw new Error(`${funcName} can only be called from extensions`);
return extension;
}
#gettext(str) {
const extension = this.#lookupExtension('gettext');
return extension.gettext(str);
}
#ngettext(str, strPlural, n) {
const extension = this.#lookupExtension('ngettext');
return extension.gettext(str, strPlural, n);
}
#pgettext(context, str) {
const extension = this.#lookupExtension('pgettext');
return extension.pgettext(context, str);
}
defineTranslationFunctions() {
return {
/**
* Translate `str` using the extension's gettext domain
*
* @param {string} str - the string to translate
*
* @returns {string} the translated string
*/
gettext: this.#gettext.bind(this),
/**
* 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: this.#ngettext.bind(this),
/**
* 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: this.#pgettext.bind(this),
};
}
}