Skip to content

Instantly share code, notes, and snippets.

@acristoffers
Last active February 4, 2024 08:26
Show Gist options
  • Save acristoffers/66b2630d9101497ba6bebc6d8ec81340 to your computer and use it in GitHub Desktop.
Save acristoffers/66b2630d9101497ba6bebc6d8ec81340 to your computer and use it in GitHub Desktop.

GNOME extension with TypeScript (for autocomplete)

One of the downsides of developing GNOME extensions is not having autocomplete in your editor, which makes life really hard when you don't know where to look for documentation and don't know much about GLib and Gnome Shell. To solve that, we can create our project using TypeScript and the types defined in the ts-for-gir project.

But before that, let's talk about expectations. I'm not going to show how to configure any specific editor. This setup is editor-agnostic and if you have a decently configured editor, it should work. I'm using Neovim with LSP and the typescript-language-server and didn't have to touch any configuration on my editor. If you're using VSCode with a TypeScript extension, you probably will get it working automatically too once the setup is done. If you're using another editor, specially without LSP support, you're on your own.

I'll try to guide and explain every step and file, so it is beginner friendly and helpful for new extension developers. It is not a replacement for https://gjs.guide/extensions, though.

It is also going to be a GNOME 45 extension. To target GNOME 44 and older, you need to modify the output module format, and I don't know yet how to target them. Note however, that the changes in 45 go beyong just the import system, so you may need a separate codebase for pre-45 anyways.

So let's start.

Project setup

This is the basic structure of an extension:

my-extension@example.com
├── extension.js
├── prefs.js
├── metadata.json
└── schemas
  ├── gschemas.compiled
  └── org.gnome.shell.extensions.my-extension.gschema.xml

The important bits are:

  • The name of the extension folder follows the pattern extension-name@domain inside its gnome-shell's installation folder. In your computer you can name it whatever you want.
  • extension.js is the extension's code.
  • prefs.js (not required) is the code for the preferences pane in the Extension Manager app.
  • metadata.json contains many information, such as the extension's name, description and version.
  • schemas (not required) is a directory that contains the extension's schemas, that is, the information about what settings/data it can persist.

Attention: You cannot use GNOME or other trademarks in the name of the extension.

The things that don't change

To get started, you create a folder and put the metadata.json inside it:

{
    "name": "My TypeScript Extension",
    "description": "An extension made with TypeScript",
    "uuid": "my-extension@example.com",
    "url": "https://github.com/example/my-extension",
    "settings-schema": "org.gnome.shell.extensions.my-extension",
    "shell-version": [
        "45"
    ]
}

Breaking it down:

  • name: visible in the store, so it's the user-friendly name of your extension.
  • description: also visible in the store, also user-friendly.
  • uuid: the extension's unique identifier in the system and in the store. Follows the pattern extension-name@domain.
  • url: the url of the project.
  • settings-schema: the same uuid you put in the schema file. If you're not using a schema, you don't need this.
  • shell-version: a list of versions your extension supports. Make sure to test since an extension written for a version may not work with another. For example, extensions made for GNOME 42 will probably work on GNOME 43 and 44, but GNOME 45 introduces breaking changes in the import mechanism so it will not work there.

See that your extension's version is not there? It is managed automatically by the GNOME store, so you don't have to care about it.

Let's assume you want to use a schema to persist settings. Schemas are a Gio thing and some documentation is available at https://docs.gtk.org/gio/class.Settings.html. But we don't care about the implementation details, only how to declare a schema and use it to store and retrieve data, which we will end up doing. So let's start by declaring the schema.

Create the schema folder and, inside it, a file with the name org.gnome.shell.extensions.my-extension.gschema.xml. The name is important otherwise it won't work.

<?xml version="1.0" encoding="utf-8"?>
<schemalist>
  <schema id="org.gnome.shell.extensions.my-extension" path="/org/gnome/shell/extensions/my-extension/">
    <key name="padding-inner" type="i">
      <default>8</default>
      <summary>Inner padding</summary>
      <description>Padding between windows</description>
    </key>
    <key name="animate" type="b">
      <default>true</default>
      <summary>Animation</summary>
      <description>Whether to animate window movement/resizing</description>
    </key>
  </schema>
</schemalist>

Breaking it down:

  • The schema id and path have to follow the patterns you see there. Change only your extension name.
  • The key tags define what values are available to be stored/read. name is used in code to refer to this value and type is the type of the value, like s for string, i for integer and b for boolean.

The other tags/attributes are rather self-explanatory. Creating a schema like this will allow you to persist two values, padding-inner and animate, as well as declare their default values.

But the schema have to be compiled, so every time we change this xml file, we need to run this command:

glib-compile-schemas schemas

If the command glib-compile-schemas is not available, try installing a package with a name like glib or gio using your distro's package manager.

The command will create the file gschemas.compiled inside the schemas folder.

With this, the files that are the same no matter whether you are using JavaScript or TypeScript are in place. Let's start with the divergent path.

Setting up TypeScript and girs

First, let's recall our goal: to have a TypeScript extension that will generate JavaScript code which will be accepted in reviews and won't make the reviewer hate you. It's not just getting the autocomplete to work, as that is rather not hard, so read until the end. At the end, a simple make pack will generate a file you can send for review.

So let's start by declaring our package.json. This is necessary because the tooling used will require it and some values need to be correct or the build won't work. Start with this:

{
  "name": "my-extension",
  "version": "0.0.0",
  "description": "A TypeScript GNOME Extension",
  "main": "extension.js",
  "type": "module",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/example/my-extension.git"
  },
  "author": "Álan Crístoffer e Sousa <acristoffers@startmail.com>",
  "license": "LGPL-3.0-or-later",
  "bugs": {
    "url": "https://github.com/example/my-extension/issues"
  },
  "homepage": "https://github.com/example/my-extension#readme",
  "sideEffects": false
}

Just change the project and personal information to match your own. The version is completely irrelevant and you can use for internal reasons if you wish.

Install the dependencies:

npm install --save-dev \
    eslint \
    eslint-plugin-jsdoc \
    typescript
npm install @girs/gjs @girs/gnome-shell

Now you have all the necessary dependencies for completion and compilation. We now need to configure TypeScript.

Create a tsconfig.json file with the following contents:

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "pretty": true,
    "sourceMap": false,
    "strict": true,
    "target": "ES2022",
    "lib": [
      "ES2022"
    ],
  },
  "include": [
    "ambient.d.ts",
    "extension.ts",
    "prefs.ts"
  ],
}

This file references the "ambient.d.ts" file. We need to create it too:

import "@girs/gjs";
import "@girs/gjs/dom";
import "@girs/gnome-shell/ambient";
import "@girs/gnome-shell/extensions/global";

With this, autocomplete should already start working.

We are now only missing the actual extension code.

Create a extension.ts file:

import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';

export default class MyExtension extends Extension {
  gsettings?: Gio.Settings
  animationsEnabled: boolean = true

  enable() {
    this.gsettings = this.getSettings();
    this.animationsEnabled = this.gsettings!.get_value('padding-inner').deepUnpack() ?? 8
  }

  disable() {
    this.gsettings = undefined;
  }
}

The basic structure of an extension is what is shown in the example: a class which is default-exported extending Extension and implementing enable() and disable(). That is the bare minimum of an extension. You should not make a constructor and destructor, instead you initialize in enable() and free everything in disable(). In the example, I get the settings object that allows you to read/write the values defined in the schema. How does it know which schema to use? It gets the schema defined in metadata.json by default.

Lets take a look at the preferences pane. Create a file prefs.ts with the contents:

import Gtk from 'gi://Gtk';
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import { ExtensionPreferences, gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';

export default class GnomeRectanglePreferences extends ExtensionPreferences {
  _settings?: Gio.Settings

  fillPreferencesWindow(window: Adw.PreferencesWindow) {
    this._settings = this.getSettings();

    const page = new Adw.PreferencesPage({
      title: _('General'),
      icon_name: 'dialog-information-symbolic',
    });

    const animationGroup = new Adw.PreferencesGroup({
      title: _('Animation'),
      description: _('Configure move/resize animation'),
    });
    page.add(animationGroup);

    const animationEnabled = new Adw.SwitchRow({
      title: _('Enabled'),
      subtitle: _('Wether to animate windows'),
    });
    animationGroup.add(animationEnabled);

    const paddingGroup = new Adw.PreferencesGroup({
      title: _('Paddings'),
      description: _('Configure the padding between windows'),
    });
    page.add(paddingGroup);

    const paddingInner = new Adw.SpinRow({
      title: _('Inner'),
      subtitle: _('Padding between windows'),
      adjustment: new Gtk.Adjustment({
        lower: 0,
        upper: 1000,
        step_increment: 1
      })
    });
    paddingGroup.add(paddingInner);

    window.add(page)

    this._settings!.bind('animate', animationEnabled, 'active', Gio.SettingsBindFlags.DEFAULT);
    this._settings!.bind('padding-inner', paddingInner, 'value', Gio.SettingsBindFlags.DEFAULT);
  }
}

This one is a bit more evolved. The default structure of a preferences file is a default-exported class that extends ExtensionPreferences containing a fillPreferencesWindow(window: Adw.PreferencesWindow) function (there are other possibilities, this one is the simples IMO). You can now just fill the window if your panes and widgets. In the example, there are two groups: Animation and Paddings, which containing one setting: Enabled and Inner. Both groups are inside the General page, which appears as a tab in the window. The values of the widgets are bound to the values of the settings in the last lines of the function, so they are loaded to the values saved and any modification get reflected in the settings automatically.

Lastly, to have an easy way to compile and bundle it all into a zip you can upload for review, create a file name Makefile with the following contents:

all: dist/extension.js

node_modules:
	npm install

dist/extension.js dist/prefs.js: node_modules
	tsc --build tsconfig.json

pack: dist/extension.js
	@cp -r schemas dist/
	@cp metadata.json LICENSE dist/
	@(cd dist && zip ../gnome-rectangle.zip -9r .)

clean:
	@rm -rf dist node_modules gnome-rectangle.zip

Important bits:

  • @ at the start of a line makes it so the make command does not print the command line it is running.
  • make pack will generate a my-extension.zip that you can send for review or unzip inside ~/.local/share/gnome-shell/extensions/my-extension@example.com to run (logging out and back in is necessary for it to be recognized).

The technique in this post was made much shorter and more straightforward thanks to the feedback of Sebastian Wiesner.

@acristoffers
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment