In this article, you will learn How to create a GNOME Shell extension. All examples are tested on GNOME Shell 3.36.1.
What is a GNOME Extension?
GNOME extensions can be installed on GNOME desktop and extend the GNOME shell functionality.
What Do You Need to Know Before Starting?
You need to know some JavaScript and CSS before starting.
Can I Write My Extension in Other Languages?
GJS (Javascript Bindings for GNOME) is using JavaScript. Technically, you can write your back-end in any language you want but to show the end result in GNOME Shell, you need to use JavaScript.
Read and Watch
This article is a textual document for this video series.
If you need to understand the examples better, you can also watch the video while you are reading.
Extension Folder and Files
This is the main GNOME Shell extension folder:
~/.local/share/gnome-shell/extensions/
Inside this folder, you need to create a folder. Folder name should be the same as UUID (You will learn about that soon).
Here, we named our folder example1@example.com.
Inside example1@example.com folder, create these files:
example1@example.com
├── metadata.json
├── extension.js
├── prefs.js [optional]
└── stylesheet.css [optional]
You can open these files with any text editor you like.
metadata.json
This file contains the extension information. You can create it like this:
{
"name" : "Example#1",
"description" : "Hello World",
"shell-version" : [
"3.36"
],
"url" : "",
"uuid" : "example1@example.com",
"version" : 1.0
}
name string
Extension Name description string
Extension Description shell-version array
Shell versions that Extension supports url string
GitLab or GitHub URL uuid string
Universally Unique Identifier. version float
Extension Version
extension.js
This is the main extension file and contains three main functions:
function init () {}
function enable () {}
function disable() {}
init
will be called first to initiate your extension. enable
will be called when you enable the extension. disable
will be called when you disable the extension.
prefs.js
This is the main preferences file that loads a GTK window as your extension settings. Without this file, your extension won't have any settings dialogue.
We will talk about this file later.
stylesheet.css
This file contains CSS classes to style your elements.
Hello World
In this example, I’ll add a button to the top panel that shows a very simple text.
Inside extension.js file:
const {St, Clutter} = imports.gi;
const Main = imports.ui.main;
let panelButton;
function init () {
panelButton = new St.Bin({
style_class : "panel-button",
});
let panelButtonText = new St.Label({
text : "Hello World",
y_align: Clutter.ActorAlign.CENTER,
});
panelButton.set_child(panelButtonText);
}
function enable () {
Main.panel._rightBox.insert_child_at_index(panelButton, 0);
}
function disable () {
Main.panel._rightBox.remove_child(panelButton);
}
-
We can have access to the GNOME shell elements such as panel by importing Main
.
-
We can have access to the Elements such as Bin container by Importing St.
-
We can have access to the Clutter constants by importing Clutter
.
-
In init function, we are simply creating a container with a Label
in it.
-
On enable
, we are adding the Bin container to the top panel (right side).
-
On disable
, we are removing the Bin container from top panel.
Enable Your Extension
To see your newly extension in the extension list or if you modified the code and want to see the result:
-
X11 Press alt-f2, type r
, press Enter to restart the GNOME shell.
-
Wayland Logout and re-login.
Now you can enable your extension and see the result.
Debug Your Extension
-
To Debug the Extension (extension.js), use this in terminal:
journalctl -f -o cat /usr/bin/gnome-shell
-
To Debug the Extension Preferences (prefs), use this in terminal:
journalctl -f -o cat /usr/bin/gnome-shell-extension-prefs
-
To log a message, use log
:
log('Message');
-
To log a message with stack trace, use logError
:
try {
throw new Error('Message');
} catch (e) {
logError(e, 'ExtensionErrorType');
}
-
To print your message in Stdout
, use Print
:
print('message');
-
To print your message in Stderr
, use Printerr
:
printerr('message');
-
To test, run gjs-console
in terminal:
gjs-console
Please note, this is a separate process from GNOME shell and you cannot access the live code here.
-
To test and inspect use looking glass by pressing alt-f2, type lg
and press enter. You can slow down the animation by running this (10 is slower than 1):
St.set_slow_down_factor(10)
Documentation
Some Useful Basics
Folder and File path:
const Me = imports.misc.extensionUtils.getCurrentExtension();
let extensionFolderPath = Me.path();
let folderPath = Me.dir.get_child('folder').get_path();
let folderExists = Me.dir.get_child('folder').query_exists(null);
let fileExists = Me.dir.get_child('myfile.js').query_exists(null);
Getting information from meta.json:
let extensionName = Me.metadata.name;
let extensionUUID = Me.metadata.uuid;
Import another js file:
const Me = imports.misc.extensionUtils.getCurrentExtension();
const OtherFile = Me.imports.otherfile;
let result = OtherFile.functionNameInsideOtherFile();
Send Notification:
const Main = imports.ui.main;
Main.notify('Message Title', 'Message Body');
Mainloop:
const Mainloop = imports.mainloop;
let timeout = Mainloop.timeout_add_seconds(2.5, () => {
});
Mainloop.source_remove(timeout);
Date and time with GLib:
const GLib = imports.gi.GLib;
let now = GLib.DateTime.new_now_local();
let nowString = now.format("%Y-%m-%d %H:%M:%S");
Gettext Translation
All string
s that user can see should be translated through gettext
.
Create these folders inside your extension folder:
example@example.com
└── locale
└── fr
└── LC_MESSAGES
const Me = imports.misc.extensionUtils.getCurrentExtension();
const Gettext = imports.gettext;
Gettext.bindtextdomain("example", Me.dir.get_child("locale").get_path());
Gettext.textdomain("example");
const _ = Gettext.gettext;
function init () {}
function enable () {
log(_("Hello My Friend"));
log(Gettext.ngettext("%d item", "%d items", 10).replace("%d", 10));
}
function disable () {}
-
Unix-like operation systems using gettext
to translate. Here, we use gettext
to translate.
-
We have created locale folder before. We can bind a text domain for that.
-
You can select the default text domain by textdomain()
.
-
When you use _ (gettext
), the translated string
will be returned. If the translation text doesn't exist in translation domain, the exact string
will be returned.
-
For translating a string
that can accept plural and singular form, use ngettext
.
Now you need to extract all translatable string
s from all js files. To create a sample file, you need to open terminal and use these commands:
cd PATH_TO_EXTENSION_FOLDER
xgettext --output=locale/example.pot *.js
Now you should have the example.pot file inside locale folder and you need to create a French translation from that sample file:
msginit --locale fr --input locale/example.pot --output locale/fr/LC_MESSAGES/example.po
Now you should have example.po file inside LC_MESSAGES folder. Open the file with a text editor and write your translated texts as msgstr
. Don't forget to use the placeholder for plural form.
Save the file and go to the terminal:
cd PATH_TO_LC_MESSAGES_FOLDER
msgfmt example.po --output-file=example.mo
As you can see, filename is the same as text domain.
Now you have .mo file which is the translation compiled file and all translation should work as expected.
Schema
GSettings
is an interface with back-end storage. You can consider it as database for your application.
If you open the dconf-editor
and browse org/gnome/shell/extensions, you can see extensions are saving some data there. To save your data in that path, you need to create schema xml file.
Inside extension folder, create schemas folder. Inside that folder, create a file. Name it org.gnome.shell.extensions.example.gschema.xml. The file name should start with gsettings path and end with gschema.xml.
example@example.com
└── schemas
└── org.gnome.shell.extensions.example.gschema.xml
Inside xml file:
="1.0"="UTF-8"
<schemalist>
<enum id="org.gnome.shell.extensions.example.enum-sample">
<value value="0" nick="TOP"/>
<value value="1" nick="BOTTOM"/>
<value value="2" nick="RIGHT"/>
<value value="3" nick="LEFT"/>
</enum>
<schema id="org.gnome.shell.extensions.example"
path="/org/gnome/shell/extensions/example/">
<key type="i" name="my-integer">
<default>100</default>
<summary>summary</summary>
<description>description</description>
</key>
<key type="d" name="my-double">
<default>0.25</default>
<summary>summary</summary>
<description>description</description>
</key>
<key type="b" name="my-boolean">
<default>false</default>
<summary>summary</summary>
<description>description</description>
</key>
<key type="s" name="my-string">
<default>"My String Value"</default>
<summary>summary</summary>
<description>description</description>
</key>
<key type="as" name="my-array">
<default>['first', 'second']</default>
<summary>summary</summary>
<description>description</description>
</key>
<key name="my-position"
enum="org.gnome.shell.extensions.example.enum-sample">
<default>'LEFT'</default>
<summary>summary</summary>
<description>description</description>
</key>
</schema>
</schemalist>
-
Schema path should start and end with slash.
-
Key indicates to gsettings
key.
-
To define a type for each key, you can use these:
-
i Integer
-
d Double
-
b Boolean
-
s String
-
as Array of String
-
For enum
, you need to create it with enum id first. The enum id should start with schema id and ends with enum name. use dash instead of white space for enum name. To define an enum
key, instead of using type use enum id.
To compile the xml file, open terminal in your extension folder and do this:
glib-compile-schemas schemas/
Now you should have compiled file, inside schemas folder.
example@example.com
└── schemas
└── org.gnome.shell.extensions.example.gschema.xml
└── gschemas.compiled
Now, inside extension.js file, you can have access to the schema like this:
const Gio = imports.gi.Gio;
const Me = imports.misc.extensionUtils.getCurrentExtension();
function getSettings () {
let GioSSS = Gio.SettingsSchemaSource;
let schemaSource = GioSSS.new_from_directory(
Me.dir.get_child("schemas").get_path(),
GioSSS.get_default(),
false
);
let schemaObj = schemaSource.lookup(
'org.gnome.shell.extensions.example', true);
if (!schemaObj) {
throw new Error('cannot find schemas');
}
return new Gio.Settings({ settings_schema : schemaObj });
}
function init () {}
function enable () {
let settings = getSettings();
log("my integer:" + settings.get_int('my-integer'));
log("my double:" + settings.get_double('my-double'));
log("my boolean:" + settings.get_boolean('my-boolean'));
log("my string:" + settings.get_string('my-string'));
let arr = settings.get_strv('my-array');
log("my array:" + arr[1]);
log("my position:" + settings.get_enum('my-position'));
}
function disable () {}
-
On getSettings()
function, we simply get gsettings
object with the file we have created before.
-
We can get the values
based on the key type. You can also set the value based on the key type.
-
For enum
value, you should set the real value, not the nick.
Prefs
Create prefs.js file inside your extension folder.
example@example.com
├── metadata.json
├── extension.js
└── prefs.js
Inside this file, you have two main functions:
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
function init () {}
function buildPrefsWidget () {
let widget = new MyPrefsWidget();
widget.show_all();
return widget;
}
const MyPrefsWidget = GObject.registerClass(
class MyPrefsWidget extends Gtk.Box {
_init (params) {
super._init(params);
this.margin = 20;
this.set_spacing(15);
this.set_orientation(Gtk.Orientation.VERTICAL);
this.connect('destroy', Gtk.main_quit);
let myLabel = new Gtk.Label({
label : "Translated Text"
});
let spinButton = new Gtk.SpinButton();
spinButton.set_sensitive(true);
spinButton.set_range(-60, 60);
spinButton.set_value(0);
spinButton.set_increments(1, 2);
spinButton.connect("value-changed", function (w) {
log(w.get_value_as_int());
});
let hBox = new Gtk.Box();
hBox.set_orientation(Gtk.Orientation.HORIZONTAL);
hBox.pack_start(myLabel, false, false, 0);
hBox.pack_end(spinButton, false, false, 0);
this.add(hBox);
}
});
-
On MyPrefsWidget
class, we simply create a box with a label and spin button.
-
On destroy
, it is important to quit the main.
-
widget.show_all()
shows all elements inside the main box.
-
With connect
, you can connect the signals to a GTK object to monitor events.
Now if you go to the GNOME extension app. You should be able to see the settings button and your preferences dialog should load correctly.
Open Prefs Dialog from Terminal
You can open the extension window from terminal with UUID:
gnome-extensions prefs example@example.com
Load Prefs with Glade File
Glade can make the design process easier for you. Create the glade file and save it inside your extension folder (prefs.ui):
example@example.com
├── metadata.json
├── extension.js
├── prefs.js
└── prefs.ui
Inside extension.js file:
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const Me = imports.misc.extensionUtils.getCurrentExtension();
function init () {}
function buildPrefsWidget () {
let widget = new MyPrefsWidget();
widget.show_all();
return widget;
}
const MyPrefsWidget = GObject.registerClass(
class MyPrefsWidget extends Gtk.ScrolledWindow {
_init (params) {
super._init(params);
let builder = new Gtk.Builder();
builder.set_translation_domain('example');
builder.add_from_file(Me.path + '/prefs.ui');
this.connect("destroy", Gtk.main_quit);
let SignalHandler = {
on_my_spinbutton_value_changed (w) {
log(w.get_value_as_int());
},
on_my_switch_state_set (w) {
log(w.get_active());
}
};
builder.connect_signals_full((builder, object, signal, handler) => {
object.connect(signal, SignalHandler[handler].bind(this));
});
this.add( builder.get_object('main_prefs') );
}
});
-
Instead of Gtk.Box
, we use Gtk.ScrolledWindow
as a wrapper.
-
To load a glade file, we use Gtk.Builder()
.
-
You can set the default translation domain for your glade file with builder.set_translation_domain()
.
-
To connect all of your signals to the handlers, you just need to use builder.connect_signals_full()
;
Performance Measurement
-
To measure the GNOME Shell performance, use Sysprof
with /usr/bin/gnome-shell process.
-
If you want to measure the performance of specific part of your code, you can use the code I wrote. Download the performance.js file and save it inside your extension folder.
Now you can import it to your project and measure the performance like this:
const Me = imports.misc.extensionUtils.getCurrentExtension();
const performance = Me.imports.performance;
function init () {}
function enable () {
Performance.start('Test1');
Performance.end();
}
function disable () {}
Create Another Panel
Here, I want to create a simple Bin container and add it to the stage.
Inside stylesheet.css file:
.bg-color {
background-color : gold;
}
We use bg-color
in our extension.js file to change the Bin color.
Inside extension.js file:
const Main = imports.ui.main;
const St = imports.gi.St;
let container;
function init () {
let pMonitor = Main.layoutManager.primaryMonitor;
container = new St.Bin({
style_class : 'bg-color',
reactive : true,
can_focus : true,
track_hover : true,
height : 30,
width : pMonitor.width,
});
container.set_position(0, pMonitor.height - 30);
container.connect("enter-event", () => {
log('entered');
});
container.connect("leave-event", () => {
log('left');
});
container.connect("button-press-event", () => {
log('clicked');
});
}
function enable () {
Main.layoutManager.addChrome(container, {
affectsInputRegion : true,
affectsStruts : true,
trackFullscreen : true,
});
}
function disable () {
Main.layoutManager.removeChrome(container);
}
-
To get the primary monitor resolution, you can use Main.layoutManager.primaryMonitor
.
-
To add the Bin container to the GNOME shell stage, you can use addChrome()
. You also have Main.uiGroup.add_child(container)
and Main.uiGroup.remove_child(container)
but you have more options with addChrome()
.
-
affectsInputRegion
allows the container be on top when another object is under it.
-
affectsStruts
can make the container just like top panel (snapping behavior, ...).
-
trackFullscreen
allows the container hide when a window goes to full-screen.
Animation
You can easily animate an object with ease.
Inside extension.js file:
const Main = imports.ui.main;
const St = imports.gi.St;
const Clutter = imports.gi.Clutter;
let container;
function init () {
let monitor = Main.layoutManager.primaryMonitor;
let size = 100;
container = new St.Bin({
style: 'background-color: gold',
reactive : true,
can_focus : true,
track_hover : true,
width: size,
height: size,
});
container.set_position(monitor.width-size, monitor.height-size);
container.connect("button-press-event", () => {
let [xPos, yPos] = container.get_position();
let newX = (xPos === 0) ? monitor.width-size : 0;
container.ease({
x: newX,
duration: 2000,
mode: Clutter.AnimationMode.EASE_OUT_BOUNCE,
onComplete: () => {
log('Animation is finished');
}
});
});
}
function enable () {
Main.layoutManager.addChrome(container, {
affectsInputRegion : true,
trackFullscreen : true,
});
}
function disable () {
Main.layoutManager.removeChrome(container);
}
-
You can use ease
to animate the container.
-
Use opacity
to change and animate the container opacity.
-
Use x
and y
to change the container position.
-
Use duration
to specify animation duration.
-
Use mode
to specify animation mode. You can find more animation modes in clutter documentation.
Panel Menu
To create a panel menu, you need to use PanelMenu
from ui
and add it with Main.panel.addToStatusArea()
to the top panel.
Inside extension.js file:
const Main = imports.ui.main;
const St = imports.gi.St;
const GObject = imports.gi.GObject;
const Gio = imports.gi.Gio;
const PanelMenu = imports.ui.panelMenu;
const PopupMenu = imports.ui.popupMenu;
const Me = imports.misc.extensionUtils.getCurrentExtension();
let myPopup;
const MyPopup = GObject.registerClass(
class MyPopup extends PanelMenu.Button {
_init () {
super._init(0);
let icon = new St.Icon({
gicon : Gio.icon_new_for_string( Me.dir.get_path() + '/icon.svg' ),
style_class : 'system-status-icon',
});
this.add_child(icon);
let pmItem = new PopupMenu.PopupMenuItem('Normal Menu Item');
pmItem.add_child(new St.Label({text : 'Label added to the end'}));
this.menu.addMenuItem(pmItem);
pmItem.connect('activate', () => {
log('clicked');
});
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
this.menu.addMenuItem(
new PopupMenu.PopupMenuItem(
"User cannot click on this item",
{reactive : false},
)
);
this.menu.connect('open-state-changed', (menu, open) => {
if (open) {
log('opened');
} else {
log('closed');
}
});
let subItem = new PopupMenu.PopupSubMenuMenuItem('sub menu item');
this.menu.addMenuItem(subItem);
subItem.menu.addMenuItem(new PopupMenu.PopupMenuItem('item 1'));
subItem.menu.addMenuItem(new PopupMenu.PopupMenuItem('item 2'), 0);
let popupMenuSection = new PopupMenu.PopupMenuSection();
popupMenuSection.actor.add_child(new PopupMenu.PopupMenuItem('section'));
this.menu.addMenuItem(popupMenuSection);
let popupImageMenuItem = new PopupMenu.PopupImageMenuItem(
'Menu Item with Icon',
'security-high-symbolic',
);
this.menu.addMenuItem(popupImageMenuItem);
}
});
function init() {
}
function enable() {
myPopup = new MyPopup();
Main.panel.addToStatusArea('myPopup', myPopup, 1);
}
function disable() {
myPopup.destroy();
}
-
You have two options for creating an icon:
-
You can create a menu item with PopupMenu.PopupMenuItem
. The menu text is a label itself. You can also add another child to the menu item.
-
To add the menu item, use addMenuItem
that you have inside PanelMenu.Button.menu
.
-
To create separator, use PopupMenu.PopupSeparatorMenuItem
.
-
Set reactive
to false
if you want to disable the menu item.
-
You can monitor the open and close menu event with open-state-changed
.
-
addMenuItem
accepts menu item order.
-
To create a section use PopupMenu.PopupMenuSection
.
-
To add an item with icon, use PopupMenu.PopupImageMenuItem
. The second parameter can be string
(icon name) or gicon.
Key Binding
You can register a shortcut key with your extension. To do that, you need to create schema
like this:
="1.0"="UTF-8"
<schemalist>
<schema id="org.gnome.shell.extensions.example9"
path="/org/gnome/shell/extensions/example9/">
<key type="as" name="my-shortcut">
<default><![CDATA[</default>
<summary>The shortcut key</summary>
<description>
You can create GTK entry in prefs and set it as CDATA.
</description>
</key>
</schema>
</schemalist>
-
We stored super+g
as array of string for our shortcut.
-
If you want to allow users to change the shortcut, you can get the shortcut key in prefs
.
Inside extension.js file:
const {Gio, Shell, Meta} = imports.gi;
const Main = imports.ui.main;
const Me = imports.misc.extensionUtils.getCurrentExtension();
function getSettings () {
let GioSSS = Gio.SettingsSchemaSource;
let schemaSource = GioSSS.new_from_directory(
Me.dir.get_child("schemas").get_path(),
GioSSS.get_default(),
false
);
let schemaObj = schemaSource.lookup(
'org.gnome.shell.extensions.example9', true);
if (!schemaObj) {
throw new Error('cannot find schemas');
}
return new Gio.Settings({ settings_schema : schemaObj });
}
function init () {}
function enable () {
let mode = Shell.ActionMode.ALL;
let flag = Meta.KeyBindingFlags.NONE;
let settings = getSettings();
Main.wm.addKeybinding("my-shortcut", settings, flag, mode, () => {
log('shortcut is working');
});
}
function disable () {
Main.wm.removeKeybinding("my-shortcut");
}
-
We get the settings from our schema
file.
-
To add a key binding, we need to send the settings
that holds the shortcut key.
-
With Action Mode, you can specify where the shortcut should work.
-
With Key Binding Flags, you can specify how the shortcut key should assign. Usually we use NONE here.
-
You need to remove key binding on extension remove.
Drag and Drop
Here, I want to create two containers. First one is draggable and the second one is droppable.
const {St, GObject} = imports.gi;
const Main = imports.ui.main;
const DND = imports.ui.dnd;
let container1, container2;
const MyContainer1 = GObject.registerClass(
class MyContainer1 extends St.Bin {
_init () {
super._init({
style : 'background-color : gold',
reactive : true,
can_focus : true,
track_hover : true,
width : 120,
height : 120,
x : 0,
y : 0,
});
this._delegate = this;
this._draggable = DND.makeDraggable(this, {
});
this._draggable.connect("drag-begin", () => {
log("DRAG BEGIN");
this._setDragMonitor(true);
});
this._draggable.connect("drag-end", () => {
log("DRAG END");
this._setDragMonitor(false);
});
this._draggable.connect("drag-cancelled", () => {
log("DRAG CANCELLED");
this._setDragMonitor(false);
});
this.connect("destroy", () => {
this._setDragMonitor(false);
});
}
_setDragMonitor (add) {
if (add) {
this._dragMonitor = {
dragMotion : this._onDragMotion.bind(this),
};
DND.addDragMonitor(this._dragMonitor);
} else if (this._dragMonitor) {
DND.removeDragMonitor(this._dragMonitor);
}
}
_onDragMotion (dragEvent) {
if (dragEvent.targetActor instanceof MyContainer2) {
return DND.DragMotionResult.MOVE_DROP;
}
return DND.DragMotionResult.CONTINUE;
}
_onDragDrop (dropEvent) {
return DND.DragDropResult.CONTINUE;
}
});
const MyContainer2 = GObject.registerClass(
class MyContainer2 extends St.Bin {
_init () {
super._init({
style : 'background-color : lime',
reactive : true,
can_focus : true,
track_hover : true,
width : 120,
height : 120,
x : 0,
y : 750,
});
this._delegate = this;
}
acceptDrop (source, actor, x, y, time) {
if (!source instanceof MyContainer1) {
return false;
}
source.get_parent().remove_child(source);
this.set_child(source);
log('Drop has been accepted');
return true;
}
});
function init () {
container1 = new MyContainer1();
container2 = new MyContainer2();
}
function enable () {
let chromeSettings = {
affectsInputRegion : true,
trackFullscreen : true,
};
Main.layoutManager.addChrome(container1, chromeSettings);
Main.layoutManager.addChrome(container2, chromeSettings);
}
function disable () {
Main.layoutManager.removeChrome(container1);
Main.layoutManager.removeChrome(container2);
}
-
You should use this._delegate
because DND needs it.
-
We have two types of drop: Copy and Move. In copy mode, it is good that you use restoreOnSuccess
because even at success, the dragged item goes back to its first place. On Move, if you don't remove the dragged item and you set restoreOnSuccess
to false
, it will be removed automatically.
-
For drag monitor, we have two main events. dragMotion
and dragDrop
.
-
dragMotion
should return a proper mouse cursor.
-
dragDrop
can accept the drop with success. On fail or success, you need to release the drag manually.
-
acceptDrop
returns Boolean.
-
I made this example as simple as possible but for adding or removing with chrome, you should check whether the container1
is in the container2
or not. In case it is inside container2
, you only need to add or remove container2
.
You can download the entire article (in MD Format) and all examples source code from my GitLab Repository.
Disclaimer: I’m not a GNOME developer. I just did this article to help the community.
History
- 20th June, 2020: Initial version