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.
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.
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 patternextension-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 andtype
is the type of the value, likes
for string,i
for integer andb
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.
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 amy-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.
thats prerrty good, is there any starter template ? that will be great.