Overview

This is document is a thorough overview of what writing an extension entails. It describes the anatomy of a typical extension, how to build one from scratch, general debugging functions and logging, as well as some logistics like version compatibility.

It does not include instructions for modifying specific aspects of GNOME Shell, general JavaScript programming, GNOME API usage or other topics that are explained elsewhere.

Below are some links that may be helpful when developing a GNOME Shell extension:

What is an extension?

GNOME Shell's UI and extensions are written in GJS, which is JavaScript bindings for the GNOME C APIs. This includes libraries like Gtk, GLib/Gio, Clutter, GStreamer and many others. Just like how PyGObject is Python bindings for the same libraries.

JavaScript is a prototype-based language, which for us means that we can modify the UI and behaviour of GNOME Shell while it is running. This is what is known as "monkey-patching". For example, you could override the addMenuItem() function of the PopupMenu class and all existing or newly created PopupMenu classes and subclasses will immediately start using your override.

GNOME Shell extensions are effectively patches that are applied to GNOME Shell when they are enabled, and reverted when they are disabled. In fact, the only real difference between patches merged into GNOME Shell and an extension is when the patch is applied.

What makes an extension?

Whether you're downloading from a git repository (eg. GitHub, GitLab) or installing from https://extensions.gnome.org, extensions are distributed as Zip files with only two required files: metadata.json and extension.js. A more complete, zipped extension usually looks like this:

example@wiki.gnome.org.zip
    locale/
        de/
          LC_MESSAGES/
              example.mo
    schemas/
        gschemas.compiled
        org.gnome.shell.extensions.example.gschema.xml
    extension.js
    metadata.json
    prefs.js
    stylesheet.css

Once unpacked and installed the extension will be in one of two places:

// User Extension
~/.local/share/gnome-shell/extensions/example@wiki.gnome.org/
    extension.js
    metadata.json
    ...

// System Extension
/usr/share/gnome-shell/extensions/example@wiki.gnome.org/
    extension.js
    metadata.json
    ...

The topic of schemas/ (GSettings) is explained below in Extension Preferences and locale/ (Gettext translations) in Extension Translations.

metadata.json (Required)

metadata.json is a required file of every extension. It contains basic information about the extension including its name, a description, version and a few other things. Below is a complete example:

   1 {
   2     "uuid": "example@wiki.gnome.org",
   3     "name": "Example",
   4     "description": "This extension puts an icon in the panel with a simple dropdown menu.",
   5     "version": 1,
   6     "shell-version": [ "3.30", "3.32" ],
   7     "url": "https://gitlab.gnome.org/World/ShellExtensions/gnome-shell-extension-example"
   8 }

These fields should be pretty self-explanatory, with some simple rules:

uuid

uuid is a globally-unique identifier for your extension, made of two parts separated by @. An extension's files must be installed to a folder with the same name as uuid:

~/.local/share/gnome-shell/extensions/example@wiki.gnome.org/

The first part should be a simple string (possibly a variation of the extension name) like "click-to-focus" and the second part should be some namespace under your control such as username.github.io. Common examples are myextension@account.gmail.com and my-extension@username.github.io.

name

name should be a short, descriptive string like "Click To Focus", "Adblock" or "Shell Window Shrinker".

description

description should be a relatively short description of the extension's function. If you need to, you can insert line breaks and tabs by using the \n and \t escape sequences.

shell-version

shell-version is an array of GNOME Shell versions your extension supports and must include at least one version.

url

url is required for extensions submitted to https://extensions.gnome.org/ and usually points to a Github or GitLab repository. It should at least refer to a website where users can report issues and get help using the extension.

version

version is the version of your extension and should be a whole number like 1, not a semantic version like 1.1 or a string like "1".

settings-schema & gettext-domain

These two fields are optional and are use by the ExtensionUtils module which has two helper functions for initializing GSettings and Gettext translations. settings-schema should be a GSchema Id like org.gnome.shell.extensions.example and gettext-domain should be a unique domain for your extension's translations. You could use the same domain as your GSchema Id or the UUID of your extension like example@wiki.gnome.org.

extension.js (Required)

extension.js is a required file of every extension. It is the core of your extension and contains the function hooks init(), enable() and disable() used by GNOME Shell to load, enable and disable your extension.

   1 // Always have this as the first line of your file. Google for an explanation.
   2 'use strict';
   3 
   4 // This is a handy import we'll use to grab our extension's object
   5 const ExtensionUtils = imports.misc.extensionUtils;
   6 const Me = ExtensionUtils.getCurrentExtension();
   7 
   8 // Like `init()` below, code *here* in the top-level of your script is executed
   9 // when your extension is loaded. You MUST NOT make any changes to GNOME Shell
  10 // here and typically you should do nothing but assign variables.
  11 const SOME_CONSTANT = 42;
  12 
  13 
  14 // This function is called once when your extension is loaded, not enabled. This
  15 // is a good time to setup translations or anything else you only do once.
  16 //
  17 // You MUST NOT make any changes to GNOME Shell, connect any signals or add any
  18 // MainLoop sources here.
  19 function init() {
  20     log(`initializing ${Me.metadata.name} version ${Me.metadata.version}`);
  21 }
  22 
  23 // This function could be called after your extension is enabled, which could
  24 // be done from GNOME Tweaks, when you log in or when the screen is unlocked.
  25 //
  26 // This is when you setup any UI for your extension, change existing widgets,
  27 // connect signals or modify GNOME Shell's behaviour.
  28 function enable() {
  29     log(`enabling ${Me.metadata.name} version ${Me.metadata.version}`);
  30 }
  31 
  32 // This function could be called after your extension is uninstalled, disabled
  33 // in GNOME Tweaks, when you log out or when the screen locks.
  34 //
  35 // Anything you created, modifed or setup in enable() MUST be undone here. Not
  36 // doing so is the most common reason extensions are rejected during review!
  37 function disable() {
  38     log(`disabling ${Me.metadata.name} version ${Me.metadata.version}`);
  39 }

prefs.js

prefs.js is used to build a Gtk widget that will be inserted into a window and be used as the preferences dialog for your extension. If this file is not present, there will simply be no preferences button in GNOME Tweaks or on https://extensions.gnome.org/local/.

   1 'use strict';
   2 
   3 const GLib = imports.gi.GLib;
   4 const Gtk = imports.gi.Gtk;
   5 
   6 // It's common practice to keep GNOME API and JS imports in separate blocks
   7 const ExtensionUtils = imports.misc.extensionUtils;
   8 const Me = ExtensionUtils.getCurrentExtension();
   9 
  10 
  11 // Like `extension.js` this is used for any one-time setup like translations.
  12 function init() {
  13     log(`initializing ${Me.metadata.name} Preferences`);
  14 }
  15 
  16 
  17 // This function is called when the preferences window is first created to build
  18 // and return a Gtk widget. As an example we'll create and return a GtkLabel.
  19 function buildPrefsWidget() {
  20     // This could be any GtkWidget subclass, although usually you would choose
  21     // something like a GtkGrid, GtkBox or GtkNotebook
  22     let prefsWidget = new Gtk.Label({
  23         label: `${Me.metadata.name} version ${Me.metadata.version}`,
  24         visible: true
  25     });
  26 
  27     // At the time buildPrefsWidget() is called, the window is not yet prepared
  28     // so if you want to access the headerbar you need to use a small trick
  29     GLib.timeout_add(0, () => {
  30         let window = prefsWidget.get_toplevel();
  31         let headerBar = window.get_titlebar();
  32         headerBar.title = `${Me.metadata.name} Preferences`;
  33     });
  34 
  35     return prefsWidget;
  36 }

Something that's important to understand:

  • The code in extension.js is executed in the same process as gnome-shell

  • The code in prefs.js will be executed in a separate Gtk process

In extension.js you will have access to live code running in GNOME Shell, but fatal errors or mistakes in extension.js will affect the stablity of the desktop. It also means you will be using the Clutter and St toolkits, although you may still use utility functions and classes from Gtk.

In prefs.js you will not have access to, or the ability to modify code running in GNOME Shell, however fatal errors or mistakes in prefs.js will be contained within that process and won't affect the stability of the desktop. In this process you will be using the Gtk toolkit, although you may also use Clutter as well via Clutter-Gtk.

You can open the preferences dialog for your extension manually with gnome-extensions prefs:

$ gnome-extensions prefs example@wiki.gnome.org

stylesheet.css

stylesheet.css is CSS stylesheet which can apply custom styles to your St widgets in extension.js or GNOME Shell as a whole. For example, if you had the following widgets:

   1 // A standard StLabel
   2 let label = new St.Label({
   3     text: 'LabelText',
   4     style_class: 'example-style'
   5 });
   6 
   7 // An StLabel subclass with `CssName` set to "ExampleLabel"
   8 var ExampleLabel = GObject.registerClass({
   9     GTypeName: 'ExampleLabel',
  10     CssName: 'ExampleLabel'
  11 }, class ExampleLabel extends St.Label {
  12 });
  13 
  14 let exampleLabel = new ExampleLabel({
  15     text: 'Label Text'
  16 });

You could have this in your stylesheet.css:

   1 /* This will change the color of all StLabel elements */
   2 StLabel {
   3     color: red;
   4 }
   5 
   6 /* This will change the color of all elements with the "example-style" class */
   7 .example-style {
   8     color: green;
   9 }
  10 
  11 /* This will change the color of StLabel elements with the "example-style" class */
  12 StLabel.example-style {
  13     color: blue;
  14 }
  15 
  16 /* This will change the color of your StLabel subclass with the custom CssName */
  17 ExampleLabel {
  18     color: yellow;
  19 }

Imports & Modules

It's common for larger extensions or extensions with discrete components to separate code into modules. You can put code to be imported into .js files and import them in extension.js, prefs.js or each other.

   1 'use strict';
   2 
   3 // Any imports this extension needs itself must also be imported here
   4 const ExtensionUtils = imports.misc.extensionUtils;
   5 const Me = ExtensionUtils.getCurrentExtension();
   6 
   7 // Mose importantly, variables declared with `let` or `const` are not exported
   8 const LOCAL_CONSTANT = 42;
   9 let localVariable = 'a value';
  10 
  11 // This includes function expressions and classes declared with `class`
  12 let _privateFunction = function() {};
  13 
  14 // TIP: Private members are often prefixed with `_` in JavaScript, which is clue
  15 // to other developers that these should only be used internally and may change
  16 class _PrivateClass {
  17     constructor() {
  18         this._initted = true;
  19     }
  20 }
  21 
  22 
  23 // Function declarations WILL be available as properties of the module
  24 function exportedFunction(a, b) {
  25     return a + b;
  26 }
  27 
  28 // Use `var` to assign any other members you want available as part the module
  29 var EXPORTED_VARIABLE = 42;
  30 
  31 var exportedFunction2 = function(...args) {
  32     return exportedFunction(...args);
  33 }
  34 
  35 var ExportedClass = class ExportedClass extends _PrivateClass {
  36     construct(params) {
  37         super();
  38 
  39         Object.assign(this, params);
  40     }
  41 };

If placed in example@wiki.gnome.org/exampleLib.js the script above would be available as Me.imports.extensionLib. If it was in a subdirectory, such as example@wiki.gnome.org/modules/exampleLib.js, you would access it as Me.imports.modules.exampleLib.

   1 // GJS's Built-in Modules are in the top-level
   2 // See: https://gitlab.gnome.org/GNOME/gjs/wikis/Modules
   3 const Gettext = imports.gettext;
   4 const Cairo = imports.cairo;
   5 
   6 // GNOME APIs are under the `gi` namespace (except Cairo)
   7 // See: http://devdocs.baznga.org/
   8 const GLib = imports.gi.GLib;
   9 const Gtk = imports.gi.Gtk;
  10 
  11 // GNOME Shell imports
  12 const Main = imports.ui.main;
  13 const ExtensionUtils = imports.misc.extensionUtils;
  14 const Me = ExtensionUtils.getCurrentExtension();
  15 
  16 // You can import your modules using the extension object we imported as `Me`.
  17 const ExampleLib = Me.imports.exampleLib;
  18 
  19 let myObject = new ExampleLib.ExportedClass();
  20 ExampleLib.exportedFunction(0, ExampleLib.EXPORTED_VARIABLE);

Many of the elements in GNOME Shell like panel buttons, popup menus and notifications are built from reusable classes and functions. These common elements are the closest GNOME Shell has in terms of stable public API. Here are a few links to some commonly used modules.

You can browse around in the js/ui/ folder or any other JavaScript file under js/ for more code to be reused. Notice the path structure in the links above and how they compare to the imports below:

   1 const ExtensionUtils = imports.misc.extensionUtils;
   2 const ModalDialog = imports.ui.modalDialog;
   3 const PanelMenu = imports.ui.panelMenu;
   4 const PopupMenu = imports.ui.popupMenu;

Basic Debugging

Some distributions may require you to be part of a systemd user group to access logs. On systems that are not using systemd, logs may be written to ~/.xsession-errors.

Now is a good time to cover basic debugging and logging, which is an important part of developing any software. GJS has a number of built in global functions, although not all of them are useful for extensions.

Logging

   1 // Log a string, usually to `journalctl`
   2 log('a message');
   3 
   4 // Log an Error() with a stack trace and optional prefix
   5 try {
   6     throw new Error('An error occurred');
   7 } catch (e) {
   8     logError(e, 'ExtensionError');
   9 }
  10 
  11 // Print a message to stdout
  12 print('a message');
  13 
  14 // Print a message to stderr
  15 printerr('An error occured');

When writing extensions, print() and printerr() are not particularly useful since we won't have easy access to gnome-shell's stdin and stderr pipes. You should generally use log() and logError() and watch the log in a new terminal with journalctl:

$ journalctl -f -o cat /usr/bin/gnome-shell

GJS Console

Similar to Python, GJS also has a console you can use to test things out. However, you will not be able to access live code running in the gnome-shell process or import JS modules from GNOME Shell, since this a separate process.

$ gjs-console
gjs> log('a message');
Gjs-Message: 06:46:03.487: JS LOG: a message

gjs> try {
....     throw new Error('An error occurred');
.... } catch (e) {
....     logError(e, 'ConsoleError');
.... }

(gjs-console:9133): Gjs-WARNING **: 06:47:06.311: JS ERROR: ConsoleError: Error: An error occurred
@typein:2:16
@<stdin>:1:34

Recovering from Fatal Errors

Despite the fact that extensions are written in JavaScript, the code is executed in the same process as gnome-shell so fatal programmer errors can crash GNOME Shell in a few situations. If your extension crashes GNOME Shell as a result of the init() or enable() hooks being called, this can leave you unable to log into GNOME Shell.

If you find yourself in this situation, you may be able to correct the problem from a TTY:

  1. Switch to a free TTY and log in
    • You can do so, for example, by pressing Ctrl + Alt + F4. You may have to cycle through the F# keys.

  2. Start journalctl as above

    • $ journalctl -f -o cat /usr/bin/gnome-shell
  3. Switch back to GDM and log in
    • After your log in fails, switch back to the TTY running journalctl and see if you can determine the problem in your code. If you can, you may be able to correct the problem using nano or vim from the command-line.

If you fail to diagnose the problem, or you find it easier to review your code in a GUI editor, you can simply move your extension directory up one directory. This will prevent your extension from being loaded, without losing any of your code:

$ mv ~/.local/share/gnome-shell/extensions/example@wiki.gnome.org ~/.local/share/gnome-shell/

Version Compatibility

While writing your extension you will probably reference GNOME Shell's JavaScript to reuse and modify code. On each GitLab page you can use the dropdown menu on the left-side to view a particular version or the "History" button on the right-side to view a list of changes.

In most programming languages there is a concept of "public" and "private" APIs. Public members are what programmers using library will use and remain stable, while private members are inaccessible and unstable which allows the library authors to make changes without breaking the public API. However, in JavaScript there is no reasonable way to make members inaccessible, or truly private.

In GNOME Shell there are a number of classes and functions that could be considered public and rarely change, but there's a good chance that you will want to modify private members anyways. One benefit of monkey-patching and using a prototyping language, is that it allows you to change low-level code and behaviour of GNOME Shell. The consequence is that when private or low-level code changes your extension may break.

To handle this, you can check the version of GNOME Shell that is running and adjust your code dynamically. This allows you to ensure your extension will work across versions when private members, or in rare cases public members, change the function signatures or class implementations.

   1 const GObject = imports.gi.GObject;
   2 
   3 const Config = imports.misc.config;
   4 const PanelMenu = imports.ui.panelMenu;
   5 
   6 // Outputs a string like : "3.30.0"
   7 log(Config.PACKAGE_VERSION);
   8 
   9 // Getting the minor version as an integer (probably the most relevant)
  10 let shellMinorVersion = parseInt(Config.PACKAGE_VERSION.split('.')[1]);
  11 
  12 // Our PanelMenu.Button subclass
  13 var ExampleIndicator = class ExampleIndicator extends PanelMenu.Button {
  14     _init(constructor_args) {
  15         // Chaining up to the super-class
  16         super._init(...constructor_args);
  17 
  18         // do some custom setup
  19     }
  20 
  21     customMethod() {
  22         // peform some custom operation
  23     }
  24 }
  25 
  26 // In gnome-shell >= 3.32 this class and several others became GObject
  27 // subclasses. We can account for this change in a backwards-compatible way
  28 // simply by re-wrapping our subclass in `GObject.registerClass()`
  29 if (shellMinorVersion > 30) {
  30     ExampleIndicator = GObject.registerClass(
  31         {GTypeName: 'ExampleIndicator'},
  32         ExampleIndicator
  33     );
  34 }

Extension Creation

GNOME Shell ships with a program you can use to create a skeleton extension by running gnome-extensions create. Using -i it will walk you through picking a name, description and uuid before installing it to ~/.local/share/gnome-shell/extensions/ and opening extension.js in an editor.

For educational purposes we're going to build an extension from scratch instead. To get clean view of how your extension functions, you should restart GNOME Shell after making changes to the code. For this reason, most extension development happens in Xorg/X11 sessions rather than Wayland, which requires you to logout and login to restart .

To restart GNOME Shell in X11, pressing Alt+F2 to open the Run Dialog and enter restart (or just r).

To run new extensions on Wayland you can run a nested gnome-shell using dbus-run-session -- gnome-shell --nested --wayland.

Creating the Extension

Start by creating an extension directory, then open the two required files in gedit or another editor:

$ mkdir -p ~/.local/share/gnome-shell/extensions/example@wiki.gnome.org
$ cd ~/.local/share/gnome-shell/extensions/example@wiki.gnome.org
$ gedit extension.js metadata.json &

Populate extension.js and metadata.json with the basic requirements, remembering that uuid MUST match the directory name of your extension:

metadata.json

   1 {
   2     "uuid": "example@wiki.gnome.org",
   3     "name": "Example",
   4     "description": "This extension puts an icon in the panel with a simple dropdown menu.",
   5     "version": 1,
   6     "shell-version": [ "3.30", "3.32" ],
   7     "url": "https://gitlab.gnome.org/World/ShellExtensions/gnome-shell-extension-example"
   8 }

extension.js

   1 'use strict';
   2 
   3 const ExtensionUtils = imports.misc.extensionUtils;
   4 const Me = ExtensionUtils.getCurrentExtension();
   5 
   6 
   7 function init() {
   8     log(`initializing ${Me.metadata.name} version ${Me.metadata.version}`);
   9 }
  10 
  11 
  12 function enable() {
  13     log(`enabling ${Me.metadata.name} version ${Me.metadata.version}`);
  14 }
  15 
  16 
  17 function disable() {
  18     log(`disabling ${Me.metadata.name} version ${Me.metadata.version}`);
  19 }

Enabling the Extension

Firstly, we want to ensure we're watching the journal for any errors or mistakes we might have made. As described above, you can run this in a terminal to watch the output of GNOME Shell and extensions:

$ journalctl -f -o cat /usr/bin/gnome-shell

Next we'll enable the extension using gnome-extensions enable:

$ gnome-extensions enable example@wiki.gnome.org

Now restart GNOME Shell by pressing Alt+F2 to open the Run Dialog and enter restart (or just r) and when it's finished you should see something like the following in the log:

GNOME Shell started at Sun Dec 23 2018 07:14:35 GMT-0800 (PST)
initializing Example version 1
enabling Example version 1

On wayland you can see the new extension running a nested gnome-shell dbus-run-session -- gnome-shell --nested --wayland.

Adding UI Elements

Let's start by adding a button to the panel with a menu:

   1 'use strict';
   2 
   3 const Gio = imports.gi.Gio;
   4 const GLib = imports.gi.GLib;
   5 const GObject = imports.gi.GObject;
   6 const St = imports.gi.St;
   7 
   8 const ExtensionUtils = imports.misc.extensionUtils;
   9 const Me = ExtensionUtils.getCurrentExtension();
  10 const Main = imports.ui.main;
  11 const PanelMenu = imports.ui.panelMenu;
  12 
  13 // For compatibility checks, as described above
  14 const Config = imports.misc.config;
  15 const SHELL_MINOR = parseInt(Config.PACKAGE_VERSION.split('.')[1]);
  16 
  17 
  18 // We'll extend the Button class from Panel Menu so we can do some setup in
  19 // the init() function.
  20 var ExampleIndicator = class ExampleIndicator extends PanelMenu.Button {
  21 
  22     _init() {
  23         super._init(0.0, `${Me.metadata.name} Indicator`, false);
  24 
  25         // Pick an icon
  26         let icon = new St.Icon({
  27             gicon: new Gio.ThemedIcon({name: 'face-laugh-symbolic'}),
  28             style_class: 'system-status-icon'
  29         });
  30         this.actor.add_child(icon);
  31 
  32         // Add a menu item
  33         this.menu.addAction('Menu Item', this.menuAction, null);
  34     }
  35 
  36     menuAction() {
  37         log('Menu item activated');
  38     }
  39 }
  40 
  41 // Compatibility with gnome-shell >= 3.32
  42 if (SHELL_MINOR > 30) {
  43     ExampleIndicator = GObject.registerClass(
  44         {GTypeName: 'ExampleIndicator'},
  45         ExampleIndicator
  46     );
  47 }
  48 
  49 // We're going to declare `indicator` in the scope of the whole script so it can
  50 // be accessed in both `enable()` and `disable()`
  51 var indicator = null;
  52 
  53 
  54 function init() {
  55     log(`initializing ${Me.metadata.name} version ${Me.metadata.version}`);
  56 }
  57 
  58 
  59 function enable() {
  60     log(`enabling ${Me.metadata.name} version ${Me.metadata.version}`);
  61 
  62     indicator = new ExampleIndicator();
  63 
  64     // The `main` import is an example of file that is mostly live instances of
  65     // objects, rather than reusable code. `Main.panel` is the actual panel you
  66     // see at the top of the screen.
  67     Main.panel.addToStatusArea(`${Me.metadata.name} Indicator`, indicator);
  68 }
  69 
  70 
  71 function disable() {
  72     log(`disabling ${Me.metadata.name} version ${Me.metadata.version}`);
  73 
  74     // REMINDER: It's required for extensions to clean up after themselves when
  75     // they are disabled. This is required for approval during review!
  76     if (indicator !== null) {
  77         indicator.destroy();
  78         indicator = null;
  79     }
  80 }

Now save extension.js and restart GNOME Shell to see the button with it's menu in the panel.

Modifying Behaviour

The most common way to modify the behaviour of GNOME Shell is to intercept or override signal handlers, which is the primary way events and user interaction are controlled. It's possible to modify the behaviour of elements like notifications, workspaces, the dash and almost any other element of GNOME Shell.

Describing the implementation of any particular element is out of the scope of this guide, so as a simple example we'll steal the scroll-event from the volume indicator for our panel button:

   1 // Grab the Volume Indicator from the User Menu
   2 const VolumeIndicator = Main.panel.statusArea.aggregateMenu._volume;
   3 
   4 
   5 var ExampleIndicator = class ExampleIndicator extends PanelMenu.Button {
   6 
   7     _init() {
   8         super._init(0.0, `${Me.metadata.name} Indicator`, false);
   9 
  10         let icon = new St.Icon({
  11             gicon: new Gio.ThemedIcon({name: 'face-laugh-symbolic'}),
  12             style_class: 'system-status-icon'
  13         });
  14         this.actor.add_child(icon);
  15 
  16         this.menu.addAction('Menu Item', this.menuAction, null);
  17 
  18         // Prevent the volume indicator from emitting the ::scroll-event signal
  19         VolumeIndicator.reactive = false;
  20 
  21         // Connect the callback to our button's signal
  22         //
  23         // NOTE: use `Function.bind()` to bind the scope (ie. `this`) to the
  24         // proper context. In this example, when `_onScrollEvent()` is invoked
  25         // `this` will be the `VolumeIndicator` instance the function expects.
  26         this._onScrollEventId = this.actor.connect(
  27           'scroll-event',
  28           VolumeIndicator._onScrollEvent.bind(VolumeIndicator)
  29         );
  30     }
  31 
  32     destroy() {
  33         // Disconnect from the scroll-event signal
  34         //
  35         // NOTE: When we called `GObject.connect()` above, it returned an
  36         // integer for the connection handler. We saved that so that we can use
  37         // it now to disconnect the callback.
  38         this.actor.disconnect(this._onScrollEventId);
  39 
  40         // Reset the volume indicator reactivity
  41         VolumeIndicator.reactive = true;
  42 
  43         super.destroy();
  44     }
  45 }

Now save extension.js and restart GNOME Shell to test the volume control. Did it fail?

If you're using GNOME Shell 3.36 or newer, the volume indicator code has changed and the above snippet will not work. This is a good example of how modifying private code can be problematic, if you do not keep your extension updated for newer releases. You can update the code as shown below to support (only) the newer releases:

   1 // Grab the Volume Indicator from the User Menu
   2 const VolumeIndicator = Main.panel.statusArea.aggregateMenu._volume;
   3 
   4 // The PanelMenu.Button class is now a GObject-derived class!
   5 var ExampleIndicator = GObject.registerClass({
   6     GTypeName: 'ExampleIndicator',
   7 }, class ExampleIndicator extends PanelMenu.Button {
   8 
   9     _init() {
  10         super._init(0.0, `${Me.metadata.name} Indicator`, false);
  11 
  12         let icon = new St.Icon({
  13             gicon: new Gio.ThemedIcon({name: 'face-laugh-symbolic'}),
  14             style_class: 'system-status-icon'
  15         });
  16         this.actor.add_child(icon);
  17         this.menu.addAction('Menu Item', this.menuAction, null);
  18 
  19         VolumeIndicator.reactive = false;
  20 
  21         // In GNOME Shell 3.36+ the volume indicator now uses a vfunc (sort of
  22         // a default signal callback), so we must use that instead
  23         this._onScrollEventId = this.actor.connect(
  24           'scroll-event',
  25            VolumeIndicator.vfunc_scroll_event.bind(VolumeIndicator)
  26         );
  27     }
  28 
  29 
  30     // Because PanelMenu.Button is a ClutterActor, overriding the destroy()
  31     // method directly is bad idea. Instead PanelMenu.Button connects to
  32     // the signal, so we can override that callback and chain-up.
  33     _onDestroy() {
  34         // Disconnect from the scroll-event signal as before
  35         this.actor.disconnect(this._onScrollEventId);
  36         VolumeIndicator.reactive = true;
  37 
  38         // Chaining-up to PanelMenu.Button's callback
  39         super._onDestroy();
  40     }
  41 });

A more maintainable approach is not to rely on private code at all, but to instead recreate the functionality of _onScrollEvent() so that if the callback name changed your code would continue to work.

Modifying the UI

Since we are working within an active process, we can also modify the properties of existing elements in the UI. Let's expand the button's menu to include some other elements from the panel:

   1 var ExampleIndicator = class ExampleIndicator extends PanelMenu.Button {
   2 
   3     _init() {
   4         super._init(0.0, `${Me.metadata.name} Indicator`, false);
   5 
   6         // Pick an icon
   7         let icon = new St.Icon({
   8             gicon: new Gio.ThemedIcon({name: 'face-laugh-symbolic'}),
   9             style_class: 'system-status-icon'
  10         });
  11         this.actor.add_child(icon);
  12 
  13         // Keep a record of the original visibility of each panel item
  14         this.states = {};
  15 
  16         // Add a menu item for each item in the panel
  17         for (let name in Main.panel.statusArea) {
  18             // Remember this item's original visibility
  19             this.states[name] = Main.panel.statusArea[name].actor.visible;
  20 
  21             this.menu.addAction(
  22                 `Toggle "${name}"`,
  23                 this.togglePanelItem.bind(null, name),
  24                 null
  25             );
  26         }
  27     }
  28 
  29     /**
  30      * togglePanelItem:
  31      * @param {string} name - name of the panel item
  32      *
  33      * Don't be a jerk to your future self; document your code!
  34      */
  35     togglePanelItem(name) {
  36         log(`${name} menu item activated`);
  37 
  38         try {
  39             let statusItem = Main.panel.statusArea[name];
  40 
  41             // Many classes in GNOME Shell are actually native classes (non-GObject)
  42             // with a ClutterActor (GObject) as the property `actor`. St is an
  43             // extension of Clutter so these may also be StWidgets.
  44             statusItem.actor.visible = !statusItem.actor.visible;
  45         } catch (e) {
  46             logError(e, 'togglePanelItem');
  47         }
  48     }
  49 
  50     // We'll override the destroy() function to revert any changes we make
  51     destroy() {
  52         // Restore the visibility of the panel items
  53         for (let [name, visibility] of Object.entries(this.states)) {
  54             Main.panel.statusArea[name].actor.visible = visibility;
  55         }
  56 
  57         // Chain-up to the super-class after we've done our own cleanup
  58         super.destroy();
  59     }
  60 }

Now save extension.js and restart GNOME Shell again to see the menu allowing you toggle the visibility of panel items.

Extension Preferences

Our preferences dialog will be written in Gtk, which gives us a lot of options for how we present settings to the user. You may consider looking through the GNOME Human Interface Guidelines for ideas or guidance. Keep in mind these are only guidelines and you *should* depart from them when necessary to make the most intuitive interface you can.

GSettings

Programmer errors with GSettings are fatal in *all* languages and will cause the application to crash!

Normally this means your application will quit and fail to start until you correct the problem. Since your extension is part of the gnome-shell process, this can prevent you from logging in. See Recovering from Fatal Errors above.

The first thing to do is create a subdirectory for your settings schema and an empty schema file:

$ mkdir schemas/
$ gedit schemas/org.gnome.shell.extensions.example.gschema.xml

Then open the file in your text editor and create a schema describing the settings for your extension:

   1 <?xml version="1.0" encoding="UTF-8"?>
   2 <schemalist>
   3   <schema id="org.gnome.shell.extensions.example" path="/org/gnome/shell/extensions/example/">
   4     <!-- The key type "a{sb}" describes a dictionary of string-boolean pairs -->
   5     <!-- See also: https://developer.gnome.org/glib/stable/gvariant-format-strings.html -->
   6     <key name="panel-states" type="a{sb}">
   7       <default>{}</default>
   8     </key>
   9     <key name="show-indicator" type="b">
  10       <default>true</default>
  11     </key>
  12   </schema>
  13 </schemalist>

Generally, you are advised not to use "GNOME" in your application's name or ID to avoid implying that your application is officially endorsed by the GNOME Foundation. In the case of GSchema IDs however, it is convention to use the above id and path form so that all GSettings for extensions can be found in a common place.

Once you are done defining you schema, save the file and compile it into it's binary form:

$ glib-compile-schemas schemas/
$ ls schemas
example.gschema.xml  gschemas.compiled

Now that our GSettings schema is compiled and ready to be used, we'll integrate it into our extension. We'll return to the example above that let's us change the visibility of panel items:

   1 var ExampleIndicator = class ExampleIndicator extends PanelMenu.Button {
   2 
   3     _init() {
   4         super._init(0.0, `${Me.metadata.name} Indicator`, false);
   5 
   6         // Get the GSchema source so we can lookup our settings
   7         let gschema = Gio.SettingsSchemaSource.new_from_directory(
   8             Me.dir.get_child('schemas').get_path(),
   9             Gio.SettingsSchemaSource.get_default(),
  10             false
  11         );
  12 
  13         this.settings = new Gio.Settings({
  14             settings_schema: gschema.lookup('org.gnome.shell.extensions.example', true)
  15         });
  16 
  17         // Bind our indicator visibility to the GSettings value
  18         //
  19         // NOTE: Binding properties only works with GProperties (properties
  20         // registered on a GObject class), not native JavaScript properties
  21         this.settings.bind(
  22             'show-indicator',
  23             this.actor,
  24             'visible',
  25             Gio.SettingsBindFlags.DEFAULT
  26         );
  27 
  28         // Watch the settings for changes
  29         this._onPanelStatesChangedId = this.settings.connect(
  30             'changed::panel-states',
  31             this._onPanelStatesChanged.bind(this)
  32         );
  33 
  34         // Keep record of the original state of each item
  35         this.states = {};
  36 
  37         // Read the saved states
  38         let variant = this.settings.get_value('panel-states');
  39 
  40         // Unpack the GSettings GVariant
  41         //
  42         // NOTE: `GSettings.get_value()` returns a GVariant, which is a
  43         // multi-type container for packed values. GJS has two helper functions:
  44         //
  45         //  * `GVariant.unpack()`
  46         //     This function will do a shallow unpacking of any variant type,
  47         //     useful for simple types like "s" (string) or "u" (uint32/Number).
  48         //
  49         //  * `GVariant.deep_unpack()`
  50         //     A deep, but non-recursive unpacking, such that our variant type
  51         //     "a{sb}" will be unpacked to a JS Object of `{ string: boolean }`.
  52         //     `GVariant.unpack()` would return `{ string: GVariant }`.
  53         this.saved = variant.deep_unpack();
  54 
  55         // Pick an icon
  56         let icon = new St.Icon({
  57             gicon: new Gio.ThemedIcon({name: 'face-laugh-symbolic'}),
  58             style_class: 'system-status-icon'
  59         });
  60         this.actor.add_child(icon);
  61 
  62         // Add a menu item for each item in the panel
  63         for (let name in Main.panel.statusArea) {
  64             // Record this item's original visibility
  65             this.states[name] = Main.panel.statusArea[name].actor.visible;
  66 
  67             // Restore our settings
  68             if (name in this.saved) {
  69                 log(`Restoring state of ${name}`);
  70                 Main.panel.statusArea[name].actor.visible = this.saved[name];
  71             }
  72 
  73             this.menu.addAction(
  74                 `Toggle "${name}"`,
  75                 this.togglePanelItem.bind(this, name),
  76                 null
  77             );
  78         }
  79     }
  80 
  81     _onPanelStatesChanged(settings, key) {
  82         // Read the new settings
  83         this.saved = this.settings.get_value('panel-states').deep_unpack();
  84 
  85         // Restore or reset the panel items
  86         for (let name in this.states) {
  87             // If we have a saved state, set that
  88             if (name in this.saved) {
  89                 Main.panel.statusArea[name].actor.visible = this.saved[name];
  90 
  91             // Otherwise restore the original state
  92             } else {
  93                 Main.panel.statusArea[name].actor.visible = this.states[name];
  94             }
  95         }
  96     }
  97 
  98     togglePanelItem(name) {
  99         log(`${name} menu item activated`);
 100 
 101         let statusItem = Main.panel.statusArea[name];
 102         statusItem.actor.visible = !statusItem.actor.visible;
 103 
 104         // Store our saved state
 105         this.saved[name] = statusItem.actor.visible;
 106     }
 107 
 108     destroy() {
 109         // Stop watching the settings for changes
 110         this.settings.disconnect(this._onSettingsChangedId);
 111 
 112         // Store the panel settings in GSettings
 113         this.settings.set_value(
 114             'panel-states',
 115             new GLib.Variant('a{sb}', this.saved)
 116         );
 117 
 118         // Restore the visibility of the panel items
 119         for (let [name, visibility] of Object.entries(this.states)) {
 120             Main.panel.statusArea[name].actor.visible = visibility;
 121         }
 122 
 123         super.destroy();
 124     }
 125 }

Now save extension.js and restart GNOME Shell again. Change the visibility of one of the panel items and then disable the extension:

$ gnome-extensions disable example@wiki.gnome.org

The panel button from the extension should disappear, and the hidden panel item should reappear. Renable the extension and your settings should be restored:

$ gnome-extensions enable example@wiki.gnome.org

Preferences Window

Now that we have GSettings for our extension, we will give the use some control by creating a simple preference dialog. Start by creating the prefs.js file and opening it in your text editor:

$ gedit prefs.js

Then we'll create a simple grid with a title, label and button for resetting our saved settings:

   1 'use strict';
   2 
   3 const Gio = imports.gi.Gio;
   4 const Gtk = imports.gi.Gtk;
   5 
   6 const ExtensionUtils = imports.misc.extensionUtils;
   7 const Me = ExtensionUtils.getCurrentExtension();
   8 
   9 
  10 function init() {
  11 }
  12 
  13 function buildPrefsWidget() {
  14 
  15     // Copy the same GSettings code from `extension.js`
  16     let gschema = Gio.SettingsSchemaSource.new_from_directory(
  17         Me.dir.get_child('schemas').get_path(),
  18         Gio.SettingsSchemaSource.get_default(),
  19         false
  20     );
  21 
  22     this.settings = new Gio.Settings({
  23         settings_schema: gschema.lookup('org.gnome.shell.extensions.example', true)
  24     });
  25 
  26     // Create a parent widget that we'll return from this function
  27     let prefsWidget = new Gtk.Grid({
  28         margin: 18,
  29         column_spacing: 12,
  30         row_spacing: 12,
  31         visible: true
  32     });
  33 
  34     // Add a simple title and add it to the prefsWidget
  35     let title = new Gtk.Label({
  36         // As described in "Extension Translations", the following template
  37         // lit
  38         // prefs.js:88: warning: RegExp literal terminated too early
  39         //label: `<b>${Me.metadata.name} Extension Preferences</b>`,
  40         label: '<b>' + Me.metadata.name + ' Extension Preferences</b>',
  41         halign: Gtk.Align.START,
  42         use_markup: true,
  43         visible: true
  44     });
  45     prefsWidget.attach(title, 0, 0, 2, 1);
  46 
  47     // Create a label to describe our button and add it to the prefsWidget
  48     let buttonLabel = new Gtk.Label({
  49         label: 'Reset Panel Items:',
  50         halign: Gtk.Align.START,
  51         visible: true
  52     });
  53     prefsWidget.attach(buttonLabel, 0, 1, 1, 1);
  54 
  55     // Create a 'Reset' button and add it to the prefsWidget
  56     let button = new Gtk.Button({
  57         label: 'Reset Panel',
  58         visible: true
  59     });
  60     prefsWidget.attach(button, 1, 1, 1, 1);
  61 
  62     // Connect the ::clicked signal to reset the stored settings
  63     button.connect('clicked', (button) => this.settings.reset('panel-states'));
  64 
  65     // Create a label & switch for `show-indicator`
  66     let toggleLabel = new Gtk.Label({
  67         label: 'Show Extension Indicator:',
  68         halign: Gtk.Align.START,
  69         visible: true
  70     });
  71     prefsWidget.attach(toggleLabel, 0, 2, 1, 1);
  72 
  73     let toggle = new Gtk.Switch({
  74         active: this.settings.get_boolean ('show-indicator'),
  75         halign: Gtk.Align.END,
  76         visible: true
  77     });
  78     prefsWidget.attach(toggle, 1, 2, 1, 1);
  79 
  80     // Bind the switch to the `show-indicator` key
  81     this.settings.bind(
  82         'show-indicator',
  83         toggle,
  84         'active',
  85         Gio.SettingsBindFlags.DEFAULT
  86     );
  87 
  88     // Return our widget which will be added to the window
  89     return prefsWidget;
  90 }

To test the new preferences dialog, you can launch it directly from the command line:

$ gnome-extensions prefs example@wiki.gnome.org

The extension should be kept up to date with any changes that happen, because of the signal handler in extension.js watching for changes.

Extension Translations

Explaining how Gettext translations work in detail is out of the scope of this document, however we'll cover some basics and how it's used in GJS and GNOME Shell extensions. More information and guides to translating can be found on the GNOME Translation Project wiki.

Gettext Module

GJS includes a built-in module for Gettext, which is typically initialized like this:

   1 const Gettext = imports.gettext;
   2 
   3 const ExtensionUtils = imports.misc.extensionUtils;
   4 const Me = ExtensionUtils.getCurrentExtension();
   5 
   6 // Bind gettext to your extension's "domain". This is generally a short, unique
   7 // string like a package name. You could use your extension UUID.
   8 Gettext.textdomain('example');
   9 
  10 // Bind the text domain to the translations in your `locale/` directory
  11 Gettext.bindtextdomain('example', Me.dir.get_child('locale').get_path());
  12 
  13 // The most common function is `gettext()`, by convention aliased to `_()`.
  14 const _ = Gettext.gettext;

Once initialized, you use Gettext's functions to mark your strings as translatable:

   1 // This function marks a string to be translated while parsing with gettext and
   2 // returns the correct translation as a string during execution.
   3 let singular = new St.Label({
   4     text: _('Example')
   5 });
   6 
   7 // The other function you are likely to use is `ngettext()` which is used for
   8 // pluralized translations.
   9 let amount = 2;
  10 
  11 let plural = new St.Label({
  12     text: Gettext.ngettext('%d Example', '%d Examples', amount).format(amount)
  13 });

Preparing Translations

The more complex your extension the more likely you are to use a build system that includes conveniences for Gettext and translations. For educational purposes we'll go through some simple examples that will probably work for most extensions. Start by creating a po directory for the template and raw translations as well as a locale directory for the compiled translations:

$ mkdir ~/.local/share/gnome-shell/extensions/example@wiki.gnome.org/po/
$ mkdir ~/.local/share/gnome-shell/extensions/example@wiki.gnome.org/locale/

POT File

The pot file is generated by xgettext by scanning your source code for strings marked as translatable. This is the template translators will use to create translations for their language.

$ ls
extension.js  locale  metadata.json  po  prefs.js  schemas  stylesheet.css
$ xgettext --from-code=UTF-8 --output=po/example.pot *.js

Running xgettext on the example code above would generate the template file below:

   1 # SOME DESCRIPTIVE TITLE.
   2 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
   3 # This file is distributed under the same license as the PACKAGE package.
   4 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
   5 #
   6 #, fuzzy
   7 msgid ""
   8 msgstr ""
   9 "Project-Id-Version: PACKAGE VERSION\n"
  10 "Report-Msgid-Bugs-To: \n"
  11 "POT-Creation-Date: 2019-04-08 21:03-0700\n"
  12 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
  13 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
  14 "Language-Team: LANGUAGE <LL@li.org>\n"
  15 "Language: \n"
  16 "MIME-Version: 1.0\n"
  17 "Content-Type: text/plain; charset=CHARSET\n"
  18 "Content-Transfer-Encoding: 8bit\n"
  19 "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
  20 
  21 #: test.js:21
  22 msgid "Example"
  23 msgstr ""
  24 
  25 #: test.js:29
  26 #, javascript-format
  27 msgid "%d Example"
  28 msgid_plural "%d Examples"
  29 msgstr[0] ""
  30 msgstr[1] ""

PO Files

Each .po file contains the strings of messages.pot translated into a particular language. Generally, you will make your POT file available and translators will submit the translated .po file for their language. These can be created easily with a program like GTranslator or Poedit and submitted by Pull/Merge Request.

As a simple example, the following will create a near duplicate of the above. The translator takes this file and fills in each msgstr with the French translation of the msgid:

$ msginit --no-translator \
          --input=po/example.pot \
          --output-file=po/fr.po --locale=fr
$ ls po/
example.pot  fr.po

MO Files

The .pot and .po files are the "source code" of translations and generally not distributed with a packed extension. Instead, these files are compiled into .mo files which Gettext will use to fetch translated strings for different languages.

$ mkdir -p locale/fr/LC_MESSAGES/
$ msgfmt po/fr.po -o locale/fr/LC_MESSAGES/example.mo
$ ls locale/fr/LC_MESSAGES/
example.mo

Gettext and JavaScript Template Literals

It is worth explaining why we use String.prototype.format() function and not JavaScript's template literals with translatable strings. Due to an open bug in Gettext, JavaScript template literals are not yet supported in Gettext.

In fact, slashes in template literals anywhere in your file can break Gettext when parsing with xgettext:

   1 // This may cause strange problems when `xgettext` is parsing the file
   2 // eg. "foo.js:5: warning: RegExp literal terminated too early"
   3 let path = `${directory}/${filename}.${extension}`;
   4 
   5 // Instead, use string concatenation...
   6 let path = directory + '/' + filename + '.' + extension;
   7 
   8 // ...or GLib.build_filenamev()
   9 const GLib = imports.gi.GLib;
  10 
  11 let path = GLib.build_filenamev([directory, `${filename}.${extension}`]);

GJS ships with a format module (imports.format) which has three functions: vprintf(), printf() and format(). In GNOME Shell format() is applied to the String prototype, so that it can be used like so:

   1 // If you're using GJS in a standalone application, you should do this yourself
   2 String.prototype.format = imports.format.format;
   3 
   4 // Two examples that both output: A string with a digit "5" and and string "test"
   5 let num = 5;
   6 let str = 'test';
   7 
   8 // With String.prototype.format()
   9 'A string with a digit "%d" and string "%s"'.format(num, str);
  10 
  11 // With native template literals
  12 'A string with a digit "${num}" and string "${str}"';

Extension Utils

Long ago, Giovanni Campagna (aka gcampax) wrote a small helper script for Gettext translations and GSettings called convenience.js. This script was used so widely by extension authors that they were merged in GNOME Shell in version 3.32.

initTranslations()

initTranslations() is a simple helper that will set the text domain and bind it to the locale/ directory in your extension's folder. It therefore expects your extension to have it's translations in this directory. If this directory does not exist, it will assume your extension has been installed as a system extension and bind the text domain to the system locale directory.

   1 'use strict';
   2 
   3 const Gettext = imports.gettext;
   4 
   5 const ExtensionUtils = imports.misc.extensionUtils;
   6 
   7 // ...code for `extension.js` or `prefs.js`...
   8 
   9 function init() {
  10     // If `gettext-domain` is defined in `metadata.json` you can simply call
  11     // this function without an argument.
  12     ExtensionUtils.initTranslations();
  13 
  14     // Otherwise, you should call it with your "domain" as described above
  15     ExtensionUtils.initTranslations('example');
  16 }

getSettings()

getSettings() is another simple helper function that will create a GSettingsSchemaSource and use it to lookup settings schema before trying to create a GSettings object for it, thus avoiding a possible crash. This function expects that your schema is compiled and in the schemas/ directory in your extension's folder. If this directory does not exist, it will assume your extension has been installed as a system extension and use the default system source to try and lookup your settings.

   1 const ExtensionUtils = imports.misc.extensionUtils;
   2 
   3 // If `settings-schema` is defined in `metadata.json` you can simply call this
   4 // this function without an argument.
   5 let settings = ExtensionUtils.getSettings();
   6 
   7 // Otherwise, you should call it with your GSchema ID as described above
   8 let settings = ExtensionUtils.getSettings('org.gnome.shell.extensions.example');
   9 
  10 // You can also use this function to safely create a GSettings object for other
  11 // schemas without knowing whether they exist or not.
  12 let nautilusSettings = null;
  13 
  14 try {
  15     nautilusSettings = ExtensionUtils.getSettings('org.gnome.nautilus');
  16 } catch (e) {
  17     logError(e, 'Failed to load Nautilus settings');
  18 }

Extension Distribution

As mentioned earlier, wherever you get extensions they are distributed as Zip files.

Creating a Zip file

Simply pack up the contents of your extension directory, excluding any files that your extension doesn't need to run:

$ zip -r example@wiki.gnome.org.zip . --exclude=po/\*
  adding: extension.js (deflated 66%)
  adding: prefs.js (deflated 65%)
  adding: stylesheet.css (deflated 63%)
  adding: metadata.json (deflated 37%)
  adding: schemas/ (stored 0%)
  adding: schemas/org.gnome.shell.extensions.example.gschema.xml (deflated 44%)
  adding: schemas/gschemas.compiled (deflated 36%)
  adding: locale/ (stored 0%)
  adding: locale/fr/ (stored 0%)
  adding: locale/fr/LC_MESSAGES/ (stored 0%)
  adding: locale/fr/LC_MESSAGES/example.mo (deflated 25%)

Submitting to extensions.gnome.org

GNOME Shell extensions are patches applied during runtime that can affect the stability and security of the desktop. For this reason all extensions submitted for distribution from https://extensions.gnome.org/ are thoroughly reviewed. The requirements and tips for getting you extension approved are described on the Review page.

Once you have reviewed the requirements for approval and your extension is zipped, visit https://extensions.gnome.org/upload/ to submit it for review.

Attic/GnomeShell/Extensions/Writing (last edited 2022-03-01 15:32:50 by ScottDiemer)