Getting started with GNOME Shell extension development

Getting started with GNOME Shell extension development

GNOME Shell is the graphical shell of the GNOME desktop environment: the default interface of the popular Fedora Linux distribution, and one of great prominence in the larger ecosystem.

It allows the use of extensions to modify its behavior, which some contest as hacks, saying they monkey-patch the live shell engine, run unchecked by discrete APIs or permissions, and constantly break after updates due to their scopeless nature.

In response, I offer this sage wisdom: Let us not forgo what is for what could be. Instead, let's get hacking 🔧

First steps

GNOME Shell includes a handy tool for scaffolding a new extension. Get to it with haste using the following command:

gnome-extensions create --interactive

You'll be walked through a few prompts to initialize an extension project and the main extension.js file should be automatically opened in your editor. If it's not, you can find the extension in the ~/.local/share/gnome-shell/extensions directory with the UUID given in the interactive creation process.

You can find more information about this process here.

The framework

GNOME Shell extensions are written in JavaScript and the extension.js file is home to your extension's code.

💡
As of GNOME Shell 45, extensions are ECMAScript modules. This primarily affects module imports, so the following is only applicable to extensions targeting GNOME Shell 45 or higher.

The file is populated with class-based scaffolding like this:

import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';

export default class MyExtension extends Extension {
    constructor(metadata) {
        super(metadata);
    }

    enable() {
        this._settings = this.getSettings();
    }

    disable() {
        this._settings = null;
    }
}

This simple framework determines the lifecycle of your extension:

  • Extending the Extension class allows access to functions such as this.getSettings()

  • The constructor method is a standard JavaScript feature used to initialize an instance of a class

    • The parent class, Extension, must also be constructed using super(metadata)
  • The enable and disable class methods are called when your extension is enabled and disabled in the GNOME Extensions app, respectively

    • enable is responsible for integrating into GNOME Shell to establish your extension's functionality

    • disable is responsible for removing this integration so your extension no longer interferes

For more information about the changes to module imports made in GNOME Shell 45, see this guide to porting extensions.

What's in an extension?

As mentioned before, GNOME Shell extensions are essentially scopeless—in other words, they have boundless capabilities. The Guide to JavaScript for Gnome cites that extensions can:

  • Use Mutter to control displays, workspaces and windows

  • Use Clutter & St to create new UI elements

  • Use JavaScript modules to create new UI elements

  • Use any other library supporting GObject-Introspection

  • Access and modify any internal GNOME Shell code

Good luck with all that! My experience creating extensions is much more limited, having built an extension that only performs basic window management. Let's look into it as a narrow example of extension development.

An extension case study

My extension is called Fifty Fifty and its code is available here. Its primary function is to resize two windows to fit side-by-side. This is easily accomplished with the following code:

function placeWindow(window, r) {
  window.unmaximize(Meta.MaximizeFlags.HORIZONTAL)
  window.unmaximize(Meta.MaximizeFlags.VERTICAL)
  window.move_resize_frame(false, r.x, r.y, r.width, r.height)
}

The complexity comes from determining which windows to tile. This depends on the history of focused windows on a workspace, which (as far as I can tell) is not stored in a manner accessible to extensions. The bulk of the extension's code is related to connecting and disconnecting signals to track and record the focus history.

This occurs in the extension's enable method, like this:

enable() {
    this.globalSignals[0] = global.workspace_manager.connect(
      "workspace-added",
      (_, index) => this._onWorkspaceAdded(index)
    )
    this.globalSignals[1] = global.workspace_manager.connect(
      "workspace-removed",
      (_, index) => this._onWorkspaceRemoved(index)
    )

    for (let i = 0; i < global.workspace_manager.n_workspaces; i++) {
      this._onWorkspaceAdded(i)
    }

    // We'll look at the remaining code later...
}

The signal connection IDs must be saved to remove them in the disable method.

In essence, this code sets up signals to react to the addition and removal of workspaces, which triggers the connection and disconnection of more signals responsible for reacting to the addition and removal of windows to this workspace.

Here's the _onWorkspaceAdded method:

_onWorkspaceAdded(index) {
    const workspace = global.workspace_manager
        .get_workspace_by_index(index)
    const data = { focusHistory: [], signals: [] }

    data.signals[0] = 
        workspace.connect("window-added", (_, window) =>
        this._onWindowAdded(window)
    )
    data.signals[1] = 
        workspace.connect("window-removed", (_, window) =>
        this._onWindowRemoved(window)
    )
    this.workspaces.set(workspace, data)

    workspace.list_windows()
        .forEach((window) => this._onWindowAdded(window))
}

Again, the signal IDs are stored in the data.signals array to be later disconnected in the _onWorkspaceRemove method.

Ultimately, it's the _onWindowAdded method that establishes the focus history tracking:

_onWindowAdded(window) {
    if (!window || window.get_window_type() !== Meta.WindowType.NORMAL)
        return

    this.windowSignals.set(
      window,
      window.connect("focus", () => this.updateFocusHistory(window))
    )

    if (window.has_focus()) this.updateFocusHistory(window)
}

Now, all of this orchestration would be useless without some means of activating the fifty-fifty tiling.

This is accomplished by defining a keybinding in the aforementioned enable method:

Main.wm.addKeybinding("toggle", settings, flag, mode, () => {
    const workspace = global.workspace_manager.get_active_workspace()
    const windows = this.workspaces
      .get(workspace)
      .focusHistory.slice(0, 2)
      .reverse()

    if (windows.length === 1) {
      placeWindow(windows[0], generateRects(windows[0], 0).workspace)
      return
    }

    let cachedRects = []
    let i = 0

    const fullscreen = windows.reduce((state, window) => {
      const r = generateRects(window, i)
      cachedRects[i++] = r
      return state && rectsEqual(window.get_frame_rect(), r.window)
    }, true)

    i = 0
    windows.forEach((window) => {
      const r = cachedRects[i++]
      placeWindow(window, fullscreen ? r.workspace : r.window)
      window.raise()
    })
})

The logic here gives this extension some dynamic functionality depending on the tiled and focused state of the two most recently focused windows:

  • If at least one of the two most recently focused windows is not tiled, tile both with the focused window on the left.

  • If the focused window is tiled on the right, swap it with the window on the left.

  • If the focused window is tiled on the left, maximize the two most recently focused windows.

  • If only one window has focus history, maximize it.

Schema files

The above keybinding is made accessible to GNOME Shell via a GSettings schema file.

Create such a file in the schemas directory of your extension project:

mkdir schemas
touch schemas/org.gnome.shell.extensions.example.gschema.xml

In the above filename, example should reflect the UUID specified at extension initialization. In the case of my extension, the UUID is fiftyfifty@jcrd.github.io so the schema filename is org.gnome.shell.extensions.fiftyfifty.gschema.xml.

The schema itself looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<schemalist>
    <schema id="org.gnome.shell.extensions.fiftyfifty"
            path="/org/gnome/shell/extensions/fiftyfifty/">
        <key type="as" name="toggle">
            <default><![CDATA[['<Super>f']]]></default>
            <summary>Toggle split</summary>
        </key>
    </schema>
</schemalist>

As you can see, the <Super>f keybinding is assigned to the toggle function.

For GNOME Shell to use the schema, it must first be compiled with this command:

glib-compile-schemas schemas/

Details about these schemas and changing them via a preferences window can be found here.

Using extensions

Now, with the extension in a functional state, there are two approaches to testing it:

  • Start a nested instance of GNOME Shell with this command:

      dbus-run-session -- gnome-shell --nested --wayland
    
  • Restart your active instance of GNOME Shell by logging out and back in

After either of these steps, the extension should be registered by GNOME Shell and can be enabled with the following:

gnome-extensions enable fiftyfifty@jcrd.github.io

There we go. Not even the sky's the limit when it comes to extension functionality, so don't be afraid to shoot for the moon and hack away!

What's next?

The Extensions section of the Guide to JavaScript for Gnome is an invaluable resource for extension development, containing much of this information and more.

If you build something useful, submit your extension for distribution on extensions.gnome.org after checking the review guidelines.

Explore the gnome-shell-extension topic on GitHub for some inspiration 🍀