How to write a GNOME application in Lua

Lua is my favourite programming language, and GNOME is my favourite desktop application shell. Though using Lua to write GNOME apps is the natural course of action for me, GNOME doesn’t explicitly support it and provides little guidance on how to accomplish this goal. The readily accessible information out there is enough to get started, but I found it quite lacking in terms of putting a final product together.

Naturally, this is the subject of this blog post — I’ll instruct you on how to get it all up and running using Flatpak, the recommended way to build, package, and distribute GNOME apps.

Contents

  1. Rationale
  2. What You’ll Need
  3. The App Code
  4. The Supporting Code
  5. Building the Binary
  6. Preparing the Flatpak
  7. Polishing the App
  8. Next Steps

1. Rationale

GNOME has a wealth of official bindings and a very mature way to put together apps by means of its own IDE, Builder. A particularly excellent convenience is its built-in ability to instantly clone code from a selection of GNOME apps, and begin experimenting right away. I has good support for popular languages like C++ and Python, as well as a neat language called Vala which is built specifically around the semantics of the GObject system on which GNOME is built. In concert with the official guides, it’s very easy to get the ball rolling with developing your own application for GNOME.

Why then, does this guide exist?

Simply put, I am not built for developing apps in this way.

I struggle a lot with working in the ways prescribed by the beginner tutorials, especially because these tutorials require a top-down approach to problem solving. The developer is expected to start from a very abstract understanding of what they want to build, fill in some code templates, then implement whichever functions need to be implemented. I’ve no doubt it’s a great fit for many developers, but it’s not for me.

My approach to problem solving can be described more as bottom-up. I start from simple and concrete building blocks to build things which are more complex. From quaint beginnings I eventually build up to the abstraction I want to represent. The end result is the same, but the process is sort of inverted.

My preferred problem solving methodology also explains my choice in programming language. Lua ships with a minuscule standard library, and its actual semantics are quite simple. While it leaves out many conveniences which are expected in other languages, I am never left guessing how to accomplish something in Lua. As someone who finds it distracting to have too many features available, these aspects of the language are very appealing.

That said, this guide will not forgo all tooling recommended by the GNOME project. The last step of building the example app will be to set up a sandbox through Flatpak. Aside from being GNOME’s recommendation, Flatpak is also just an excellent solution for consistent dependency management. Unlike other solutions that are best described as “just ship the developer’s entire machine”, Flatpak is much more lightweight and focused pretty specifically on delivering apps for the Linux desktop. More to the point — Flatpak is going to let us easily and painlessly use libraries and toolkits that aren’t readily available through one’s Linux distro.

This tutorial is aimed for aspiring GNOME app developers who, like myself, struggle to work within the programming frameworks recommended by GNOME. By the end of this guide, you’ll have a GNOME app written in Lua which compiles to a single executable binary. The result is a skeleton of an app, ready to be expanded however necessary to make your vision a reality. You’ll even start learning how to extend Lua with native C code, allowing you to write system-level functionality without needing to deal with the verbose and burdensome C bindings to the GNOME libraries.

In the spirit of bottom-up thinking, this tutorial is quite detailed, explaining what is done at every step of the way. The goal is to explain in enough detail that not a single line of code feels like some arcane magic that you can’t hope to comprehend, without overloading you with too much detail that it becomes difficult to follow.

2. What You’ll Need

The first thing you’ll need is a computer running Linux. Ideally, you’ll want to be running GNOME as your desktop environment, but if you’re seeking to write GNOME apps, you’re probably already doing that.

The only dependency at the system level is Flatpak Builder. On the Linux distro I run —Debian 12 — it is available in a package named flatpak-builder, and is likely named similarly under other distros. Flatpak Builder will use Flatpak to manage the actual dependencies of the app you develop, including dependencies at the Lua level. A direct consequence for Lua fans is that LuaRocks, the de facto package management system usedy by a majority of Lua projects, is entirely optional. This example will not be using it.

Start by opening a console window and installing Flatpak Builder,

		
On Debian/Ubuntu
# apt install flatpak-builder

On Fedora/Red Hat
# dnf install flatpak-builder
	

If you’re on another distro, you’ll probably already know how to find and install this.

The flatpak-builder package should also pull in Flatpak if it is somehow not already installed. Next, you’ll want to use Flatpak to download a few of the necessities.

		
Enable downloads from Flathub, if not already enabled.
$ flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo

Install the GNOME 46 runtime and SDK
$ flatpak install org.gnome.Platform/x86_64/46
$ flatpak install org.gnome.Sdk/x86_64/46
	

With the GNOME SDK in our hands, it’s time to write our app.

3. The App Code

Create a new folder somewhere on your computer to hold all the code from this tutorial. There will be five files in total.

Open your text editor of choice and create a new file named example.lua in the folder you made.

example.lua
		
package.cpath = "/app/lib/lua/5.4/?.so;" .. package.cpath
package.path = "/app/share/lua/5.4/?.lua;" .. package.path

local lgi = require "lgi"

local Adw = lgi.require "Adw"
local Gtk = lgi.require "Gtk"
	

This code imports LGI into the application and binds to Adwaita and Gtk. We name Adwaita as “Adw” to match how it’s referred to in the library’s API documentation.

The first two lines are a necessary consequence of using Flatpak. Without these lines, Lua would not be able to find LGI as it won’t be installed in the expected places. They also assume you’re using Lua 5.4, the latest version of the language as of writing this post.

example.lua
		
...
local lgi = require "lgi"

local Adw = lgi.require "Adw"
local Gtk = lgi.require "Gtk"

local app = Adw.Application {
	application_id = "com.example.LuaGnome",
}
	

This added snippet creates a new Adw.Application object and sets its ID to our example. GNOME apps need an application object in order to properly start up and display their interface windows — without it, any window we create will immediately close.

On the subject of windows, let’s get to that code.

example.lua
		
...

local app = Adw.Application {
	application_id = "com.example.LuaGnome",
}

local function new_application_window()
	local label = Gtk.Label {
		label = "Powered by Lua, Adwaita, and Flatpak",
	}
	label:add_css_class "dim-label"

	local status_page = Adw.StatusPage {
		icon_name = "weather-clear-night-symbolic",
		title = "Hello, world!",
		child = label,
		margin_top = 6,
		margin_bottom = 6,
		margin_start = 24,
		margin_end = 24,
	}
end
	

This function, new_application_window, will create a window populated with all of the widgets we need, and return it.

We start by defining the contents of our window: a simple status page containing the perennial programmer classic “Hello, world!” and some extra text namedropping a few of the tools that power this app. The reason for picking that icon in particular is that it depicts a moon, a common image in many projects powered by Lua (given that it’s named after “lua”, the Portuguese word for “moon”). The margin properties are inherited from Gtk.Widget which all Gtk and Adwaita widgets are descended. Setting the margin properties to these values allow the label text to feel just a little less cramped.

example.lua
		
		...
		margin_top = 6,
		margin_bottom = 6,
		margin_start = 24,
		margin_end = 24,
	}

	local headerbar = Adw.HeaderBar {
		title_widget = Adw.WindowTitle.new("", ""),
	}

	local tbview = Adw.ToolbarView {
		content = status_page,
		top_bar_style = "FLAT",
	}
	tbview:add_top_bar(headerbar)
end
	

The next code snippet creates a ToolbarView. This widget will be the direct child of the application’s window. This widget includes the ability to add top and bottom bars to a view, and in the context of a Window this is how the top bar is populated. Because the resulting window is going to be small, the style of the top bar is set to FLAT.

example.lua
		
	...
	local tbview = Adw.ToolbarView {
		content = status_page,
		top_bar_style = "FLAT",
	}
	tbview:add_top_bar(headerbar)

	local window = Adw.ApplicationWindow {
		application = app,
		content = tbview,
	}
	return window
end
	

An ApplicationWindow is a special kind of window that is explicitly bound to a running application. Without binding, this top-level window will disappear as soon as it’s shown. The toolbar view is set as the content to be shown in the window, and the window is returned to the caller.

With the return statement, our new_application_window function is complete and ready to be used. In fact, our application needs just a little bit more code before it’s ready.

example.lua
		
	...
	return window
end

function app:on_activate()
	if app.active_window then
		app.active_window:present()
	end
end

function app:on_startup()
	new_application_window():present()
end

app:run()
	

GNOME application objects need to bind to two specific signals in order to run. The first is the activation signal, emitted whenever some action should cause the window to gain focus, such as clicking on a notification (which is outside the scope of this tutorial). The second signal is startup, which is emitted when the user application first begins running. This is where you should create your new window. Finally, the very last line of the application sets everything in motion by actually starting the app.

At this point, your code should look like this:

example.lua
		
package.cpath = "/app/lib/lua/5.4/?.so;" .. package.cpath
package.path = "/app/share/lua/5.4/?.lua;" .. package.path

local lgi = require "lgi"

local Adw = lgi.require "Adw"
local Gtk = lgi.require "Gtk"

local app = Adw.Application {
	application_id = "com.example.LuaGnome",
}

local function new_application_window()
	local label = Gtk.Label {
		label = "Powered by Lua, Adwaita, and Flatpak",
	}
	label:add_css_class "dim-label"

	local status_page = Adw.StatusPage {
		icon_name = "weather-clear-night-symbolic",
		title = "Hello, world!",
		child = label,
		margin_top = 6,
		margin_bottom = 6,
		margin_start = 24,
		margin_end = 24,
	}

	local headerbar = Adw.HeaderBar {
		title_widget = Adw.WindowTitle.new("", ""),
	}

	local tbview = Adw.ToolbarView {
		content = status_page,
		top_bar_style = "FLAT",
	}
	tbview:add_top_bar(headerbar)

	local window = Adw.ApplicationWindow {
		application = app,
		content = tbview,
	}
	return window
end

function app:on_activate()
	if app.active_window then
		app.active_window:present()
	end
end

function app:on_startup()
	new_application_window():present()
end

app:run()
	

Now, at this point, the app itself is finished. However, given that it’s in the form of a simple Lua script, the app isn’t anywhere near robust. Were you to distribute it now, it’d sit on the user’s computer as a text file that could be changed on a whim, possibly breaking your app in the process.

Thankfully, Lua was built with this problem in mind.

4. The Supporting Code

What we want is a way to compile the Lua code so that it’s bundled inside an executable binary. This requires writing a little bit of code in a compiled language. Thankfully for us, Lua ships with bindings to C. Using this binding lets us write a very minimal C program that does nothing but call into our Lua code, which will be embedded into the C program once it’s compiled.

It’s okay if you don’t know C, this example is the absolute minimum necessary to get it working.

Create a new file named example.c in the same folder as example.lua.

example.c
		
#include <lua.h>

#include <lauxlib.h>
#include <lualib.h>

extern char _binary_example_bytecode_start[];
extern char _binary_example_bytecode_end[];
	

We start by including Lua inside our C code as well as two of its supporting C libraries, all of which are part of Lua itself.

The two variables, _binary_example_bytecode_start and _binary_example_bytecode_end mark the start and end of the embedded Lua code, which will be compiled down to bytecode. These two variables gain a value once the program is compiled and our Lua app gets embedded into it.

example.c
		
...
extern char _binary_example_bytecode_start[];
extern char _binary_example_bytecode_end[];

int
main()
{
	lua_State *L;
	int lua_result;
	size_t example_bytecode_length;

	L = luaL_newstate();
	luaL_openlibs(L);
}
	

We begin the main function with two variables. L is a pointer to a Lua environment, the one in which our application will run. We use luaL_newstate and luaL_openlibs to create the new Lua state and import all Lua’s standard libraries, leaving us with a Lua environment exactly like what one would expect to find in the Lua command-line REPL. The second variable, lua_result, holds the return value of the function that loads our Lua app. The value of lua_result will indicate whether an error ocurred.

The third variable, example_bytecode_length, is the number of bytes occupied by the embedded Lua bytecode of our app. Lua needs to know this number in order to load bytecode from C, so lets use the start and end points of our embedded bytecode to calculate the length.

example.c
		
	...
	L = luaL_newstate();
	luaL_openlibs(L);

	example_bytecode_length =
		((size_t)_binary_example_bytecode_end)
		- ((size_t)_binary_example_bytecode_start);
}
	

This very simple subtraction is the trickiest line of this entire tutorial. An explanation of computer memory and pointers is already well beyond the scope of this tutorial, so I’ll simply try to justify this line.

The (size_t) prefixing both variables is a type cast. In this case, pointers to specific memory addresses are instead treated as actual numbers, meaning the numerical address for the end of the Lua code is subtracted by the numerical address for the start of the Lua code. As the name of our variable suggests, the result of this operation is a count of the exact number of bytes contained within this range.

Now that we have the length of our embedded bytecode, we can actually load our program into Lua.

example.c
		
	...
	example_bytecode_length =
		((size_t)_binary_example_bytecode_end)
		- ((size_t)_binary_example_bytecode_start);

	lua_result = luaL_loadbuffer(
		L,
		_binary_example_bytecode_start,
		example_bytecode_length,
		"LuaGnome");
	if (lua_result != LUA_OK) {
		fprintf(stderr, "Failed to load.\n");
		return lua_result;
	}
}
	

Like all functions in Lua’s C library, lua_loadbuffer’s first argument is the Lua state that the bytecode should be loaded in. The next two arguments are where to start loading, and how many bytes to load, the latter of which we just calculated. The last argument is a string indicating what to name the Lua chunk, which we’ll just call “example” like everything else here.

The block following the load is simply error handling. If the value assigned to lua_result (the one returned by lua_loadbuffer) is anything but LUA_OK, then we know an error ocurred here, so the program returns early. If this doesn’t execute, then the program execution can continue.

example.c
		
	...
	if (lua_result != LUA_OK) {
		fprintf(stderr, "Failed to load.\n");
		return lua_result;
	}

	lua_call(L, 0, 0);

	return 0;
}
	

Finally, the last two lines of the program. The function lua_call with these arguments takes whatever is at the top of the Lua stack and calls it as a function. The previous call to luaL_loadbuffer just happens to leave a function at the top of Lua’s stack — this is how we call it. This function call won’t finish until our Lua program finishes, which would be when the user closes the application window.

The last line will return from the main function with a value of 0, which in C means the program successfully ran to completion.

At this point, your C code should look like this:

example.c
		
#include <lua.h>

#include <lauxlib.h>
#include <lualib.h>

extern char _binary_example_bytecode_start[];
extern char _binary_example_bytecode_end[];

int
main()
{
	lua_State *L;
	int lua_result;
	size_t example_bytecode_length;

	L = luaL_newstate();
	luaL_openlibs(L);

	example_bytecode_length =
		((size_t)_binary_example_bytecode_end)
		- ((size_t)_binary_example_bytecode_start);

	lua_result = luaL_loadbuffer(
		L,
		_binary_example_bytecode_start,
		example_bytecode_length,
		"LuaGnome");
	if (lua_result != LUA_OK) {
		fprintf(stderr, "Failed to load.\n");
		return lua_result;
	}

	lua_call(L, 0, 0);

	return 0;
}
	

Five little function calls (and a little bit of math) is all it takes to run an embedded Lua program from C, which brings us to the process of actually compiling Lua bytecode and embedding it into a binary.

5. Building the Binary

To actually compile our app, we’re going to use a classic build manager called Make. Because we’ll be compiling this application in a sandbox environment, we won’t need any build system designed to account for complicated dependencies, so good ol' Make is the perfect fit.

Open a new file in your text editor, name it Makefile, and save it to the same folder we’ve been working in.

Makefile
		
LSRCS = example.lua
CSRCS = example.c
OBJS = example_bytecode.o
BIN = example

PREFIX = /app
LIBS = -llua -ldl -lm
CFLAGS = -L $(PREFIX)/lib -Wl,-E $(LIBS)
	

We start by defining a handful of variables. They are, in order:

Now that we’ve listed our files and compiler directives, we need to define build targets.

Makefile
		
...
PREFIX = /app
LIBS = -llua -ldl -lm
CFLAGS = -L $(PREFIX)/lib -Wl,-E $(LIBS)

$(BIN): $(CSRCS) $(OBJS)
	cc -o $@ $^ $(CFLAGS)

%_bytecode.o: %.bytecode
	ld -r -b binary -o $@ $^

%.bytecode: %.lua
	luac -o $@ -- $^
	

In the order listed, these recipes are for:

These instructions will create an executable named example which should execute our Lua code as embedded into the executable.

The next step is to make the executable available to the system by installing it in the right directory.

Makefile
		
...
%.bytecode: %.lua
	luac -o $@ -- $^

.PHONY: install

install: $(BIN)
	install -D -m 0755 -t $(PREFIX)/bin $(BIN)
	

This install step will copy the executable into the directory expected by the Flatpak sandbox.

At this point, your Makefile should look like this:

Makefile
		
LSRCS = example.lua
CSRCS = example.c
OBJS = example_bytecode.o
BIN = example

PREFIX = /app
LIBS = -llua -ldl -lm
CFLAGS = -L $(PREFIX)/lib -Wl,-E $(LIBS)

$(BIN): $(CSRCS) $(OBJS)
	cc -o $@ $^ $(CFLAGS)

%_bytecode.o: %.bytecode
	ld -r -b binary -o $@ $^

%.bytecode: %.lua
	luac -o $@ -- $^

.PHONY: install

install: $(BIN)
	install -D -m 0755 -t $(PREFIX)/bin $^
	

The last remaining step is to prepare the Flatpak sandbox environment so the app can actually be built and run.

6. Preparing the Flatpak

Over the course of this tutorial, I’ve made specific mention of lines which are important for Flatpak compatibility. While it is possible to combine Lua, LGI, and C to make GNOME apps outside of Flatpak, the result is quite limited. LGI would only be able to use the versions of Adwaita and Gtk provided by your Linux distro. In my case of Debian 12, both are already several versions behind from the current versions. Were I to provide packages for an app developed targeting the Debian version of these libraries, the package would need additional porting work to be usable on other distros. Building and distributing via Flatpak is how we avoid this problem.

This step requires the dependencies that were set up at the end of section #2 of this tutorial. Be sure you’ve installed the GNOME SDK through Flatpak!

Create a new file in your editor named com.example.LuaGnome.json in the same working directory as before.

com.example.LuaGnome.json
		
{
	"app-id": "com.example.LuaGnome",
	"runtime": "org.gnome.Platform",
	"runtime-version": "46",
	"sdk": "org.gnome.Sdk",
	"command": "example",
	

These fields are all needed for Flatpak. If you’re using a different version of the GNOME platform, you’ll need to change the 46 here to match. The value of “command” must be the same as the executable produced by the Makefile, which is example.

Flatpaks require special permissions to be able to access parts of the underlying system.

com.example.LuaGnome.json
		
	...
	"command": "example",
	"finish-args": [
		"--device=dri",
		"--share=ipc",
		"--socket=fallback-x11",
		"--socket=wayland"
	],
	

Now, we’re finally ready to define modules needed to build and run our app.

com.example.LuaGnome.json
		
		...
		"--socket=wayland"
	],
	"modules": [{
		"name": "lua",
		"buildsystem": "simple",
		"build-commands": [
			"make",
			"make install INSTALL_TOP=/app"
		],
		"sources": [{
			"type": "archive",
			"url": "https://lua.org/ftp/lua-5.4.7.tar.gz",
			"sha256": "9fbf5e28ef86c69858f6d3d34eccc32e911c1a28b4120ff3e84aaa70cfbf1e30"
		}]
	},
	

This tells Flatpak to download the source code to Lua 5.4.7, as specified in the sources field, and install it to /app, which Flatpak needs, as specified in the build-commands field.

The steps to downloading LGI are a bit more involved, however.

com.example.LuaGnome.json
		
			...
			"sha256": "9fbf5e28ef86c69858f6d3d34eccc32e911c1a28b4120ff3e84aaa70cfbf1e30"
		}]
	},
	{
		"name": "lgi",
		"buildsystem": "simple",
		"build-commands": [
			"make -C lgi LUA_VERSION=5.4",
			"make -C lgi install LUA_VERSION=5.4 PREFIX=/app"
		],
		"sources": [{
			"type": "archive",
			"url": "https://github.com/lgi-devs/lgi/archive/e06ad94c8a1c84e3cdb80cee293450a280dfcbc7.zip",
			"sha256": "003984a7a33236cc2368bf8c87905b6867d1292f844df236051a7b45f134d272"
		}]
	},
	

The build command here specifies explicitly that we are building and installing LGI for Lua 5.4, otherwise it would try to build and install against the much older Lua 5.1.

With LGI in place, this Flatpak is only missing our app.

com.example.LuaGnome.json
		
			...
			"sha256": "003984a7a33236cc2368bf8c87905b6867d1292f844df236051a7b45f134d272"
		}]
	},
	{
		"name": "example",
		"buildsystem": "simple",
		"build-commands": [
			"make",
			"make install"
		],
		"sources": [{
			"type": "dir",
			"path": "."
		}]
	}]
}
	

And now, it all falls into place. The “.” for path is the current directory, where we’ve been working thus far. The build commands tell Flatpak to build and install our app per the Makefile, after which point our app will finally be available to run.

The completed Flatpak manifest should look like this:

com.example.LuaGnome.json
		
{
	"app-id": "com.example.LuaGnome",
	"runtime": "org.gnome.Platform",
	"runtime-version": "46",
	"sdk": "org.gnome.Sdk",
	"command": "example",
	"finish-args": [
		"--device=dri",
		"--share=ipc",
		"--socket=fallback-x11",
		"--socket=wayland"
	],
	"modules": [{
		"name": "lua",
		"buildsystem": "simple",
		"build-commands": [
			"make",
			"make install INSTALL_TOP=/app"
		],
		"sources": [{
			"type": "archive",
			"url": "https://lua.org/ftp/lua-5.4.7.tar.gz",
			"sha256": "9fbf5e28ef86c69858f6d3d34eccc32e911c1a28b4120ff3e84aaa70cfbf1e30"
		}]
	}, {
		"name": "lgi",
		"buildsystem": "simple",
		"build-commands": [
			"make -C lgi LUA_VERSION=5.4",
			"make -C lgi install LUA_VERSION=5.4 PREFIX=/app"
		],
		"sources": [{
			"type": "archive",
			"url": "https://github.com/lgi-devs/lgi/archive/e06ad94c8a1c84e3cdb80cee293450a280dfcbc7.zip",
			"sha256": "003984a7a33236cc2368bf8c87905b6867d1292f844df236051a7b45f134d272"
		}]
	}, {
		"name": "example",
		"buildsystem": "simple",
		"build-commands": [
			"make",
			"make install"
		],
		"sources": [{
			"type": "dir",
			"path": "."
		}]
	}]
}
	

To compile the app, run flatpak-builder in the current directory and pass a few flags.

		
$ flatpak-builder .build com.example.LuaGnome --user --install --force-clean
	

If everything goes smoothly, the last lines output by flatpak-builder should ressemble something along the lines of,

		
...
Installing app/com.example.LuaGnome/x86_64/master
Pruning cache
	

If all goes well, running your program is as simple as,

		
$ flatpak run com.example.LuaGnome
	

The end result should be a window containing an icon, a title, and some descriptive text.

7. Polishing the App

You now have enough to be able to take this barebones skeleton of an app and extend it however you need. There are a few things missing from our app that are very much recommended for any GNOME app to have, and I’ll walk you through how to add them.

One particularly important omission is a development version of the app. GNOME apps usually have two versions. The first is the “regular” version for most users, which we’ve already made. The second is the “development” version. Updates to dev versions of GNOME apps are meant to be pushed to their users as soon as they’re written so that the changes may be tested, and these versions of the app usually have special styling to indicate that the user is on the development version.

Start by copying com.example.LuaGnome.json, naming the copy as com.example.LuaGnome.Devel.json and opening it in your text editor.

com.example.LuaGnome.Devel.json
		
{
	"app-id": "com.example.LuaGnome.Devel",
	"runtime": "org.gnome.Platform",
	"runtime-version": "46",
	...
	

We start by changing the application ID to match the second manifest.

com.example.LuaGnome.Devel.json
		
	...
	{
		"name": "example",
		"buildsystem": "simple",
		"build-commands": [
			"make DEVEL=true",
			"make install DEVEL=true"
		],
		"sources": [{
			"type": "dir",
			"path": "."
		}]
	}]
}
	

Next we need to change the build instructions for our app. We pass a variable into make to note that it should be building the development version. Of course, to actually use this variable in make requires changes to the Makefile.

Makefile
		
...
CFLAGS = -L $(PREFIX)/lib -Wl,-E $(LIBS)

ifdef DEVEL
CFLAGS += -DDEVEL
endif

$(BIN): $(CSRCS) $(OBJS)
...
	

If the DEVEL variable is passed to Make, then a new flag is added to the compilation process. This flag will be available to C during compile time.

Still, this begs the question. The app logic itself is programmed in Lua. So far, we’ve only used C as a glorified means of launching an embedded Lua app without needing to load any external scripts. Thankfully, Lua is built to extend C programs and so it provides us with a way of making C functions available from the Lua side. Let’s write some very basic Lua functions in C to make use of DEVEL.

example.c
		
...
#include <lualib.h>

static int
get_is_devel_lua(lua_State *L)
{
#ifdef DEVEL
	lua_pushboolean(L, 1);
#else
	lua_pushboolean(L, 0);
#endif
	return 1;
}

extern char _binary_example_bytecode_start[];
...
	

Like all Lua functions, our new function takes a lua_State as its argument. Depending on whether the DEVEL macro is passed in at compilation (through the -DDEVEL flag we added in the Makefile), the function will either push a 1 boolean (true) or a 0 boolean (false) onto the Lua argument passing stack, and then return 1. For custom Lua functions written in C, the return value indicates the number of values that are returned by the function. Thus, this function always returns a single boolean value which tells our Lua application whether it’s been compiled as a development version or not.

One of the first lines written on the Lua side of the app is an application_id property that is set when initializing an Adw.Application. Now that the app’s ID will need to change depending on how it’s compiled, we’ll need a way to determine which one our app should use for itself. While it’s completely possible to use our new get_is_devel_lua function to determine this, I think it’s in better taste if the application ID itself is determined at compile time, so the Lua side of our application itself doesn’t carry more development code than necessary.

example.c
		
...
static int
get_is_devel_lua(lua_State *L)
{
#ifdef DEVEL
	lua_pushboolean(L, 1);
#else
	lua_pushboolean(L, 0);
#endif
	return 1;
}

static int
get_application_id_lua(lua_State *L)
{
#ifdef DEVEL
	lua_pushstring(L, "com.example.LuaGnome.Devel");
#else
	lua_pushstring(L, "com.example.LuaGnome");
#endif
	return 1;
}

extern char _binary_example_bytecode_start[];
...
	

This second function is a straightforward evolution from the first one, returning a string to Lua instead of a boolean.

To actually make this code available to Lua, we need to define a library. Here’s how ours will be defined.

example.c
		
...
static int
get_application_id_lua(lua_State *L)
{
#ifdef DEVEL
	lua_pushstring(L, "com.example.LuaGnome.Devel");
#else
	lua_pushstring(L, "com.example.LuaGnome");
#endif
	return 1;
}

static const luaL_Reg examplelib[] = {
	{ "get_is_devel", get_is_devel_lua },
	{ "get_application_id", get_application_id_lua },
	{ NULL, NULL },
};

extern char _binary_example_bytecode_start[];
...
	

This defines a list of Lua registrations. Each registration has a string and a function. The string determines what the function is named in Lua, and the function is the name of the custom C/Lua function that should be called. So, this library exports two functions, one named get_is_devel and another named get_application_id.

There are a handful of ways to export a custom library to Lua. My preferred way is to set it on Lua’s package.loaded table. This table contains every library loaded by Lua thus far, and when calling Lua’s require function, this table is checked before any loading is done. If the table has an entry matching the argument passed to require, then that entry is returned without any loading.

Like this, we can import our custom C functions by calling require “examplelib”. Besides making the code available like any other library, this method also avoids creating any global variables in our Lua environment, which prevents name clashes that might break dependencies.

example.c
		
	...
	L = luaL_newstate();
	luaL_openlibs(L);

	lua_getglobal(L, "package");
	lua_getfield(L, -1, "loaded");
	lua_remove(L, -2);
	lua_pushstring(L, "examplelib");
	luaL_newlib(L, examplelib);
	lua_settable(L, -3);
	lua_remove(L, -1);

	example_bytecode_len =
		...
	

These 7 lines package our new library into Lua and inserts it into the loaded package cache, as though it was loaded by calling require “examplelib”. Because of how Lua’s package cache works, actually calling require “examplelib” will just return our new library.

Let’s put that into action.

example.lua
		
package.cpath = "/app/lib/lua/5.4/?.so;" .. package.cpath
package.path = "/app/share/lua/5.4/?.lua;" .. package.path

local lib = require "examplelib"

local is_devel = lib.get_is_devel()
local application_id = lib.get_application_id()

local lgi = require "lgi"
local Adw = lgi.require "Adw"
local Gtk = lgi.require "Gtk"
...
	

We start by storing our library in a local variable and calling our new functions, storing their results in is_devel and application_id. Next we need to change our app initialization to use this new ID.

example.lua
		
...
local lgi = require "lgi"
local Adw = lgi.require "Adw"
local Gtk = lgi.require "Gtk"

local app = Adw.Application {
	application_id = application_id,
}
...
	

This change should replace a hard coded string “com.example.LuaGnome”.

Finally, we can use the variable is_devel to determine whether the application should show development styling. Look for the new_application_window function and make this change right near the end.

example.lua
		
	...
	local window = Adw.ApplicationWindow {
		application = app,
		content = tbview,
	}
	if is_devel then
		window:add_css_class "devel"
	end
	return window
end
...
	

Your new development app should now be ready to go. The commands to build and run are the same as before, except you need to specify the new application ID.

		
$ flatpak-builder .build com.example.LuaGnome.Devel --user --install --force-clean
$ flatpak run com.example.LuaGnome.Devel
	

This second app should look like an under-construction version of the first app.

8. Next Steps

At this point, you should be fully equipped to develop your GNOME apps in Lua, and to distribute them using Flatpak once they’re ready. Not only that, but you’ve even started to expand your Lua capabilities with custom native system code using C, enabling many new possibilities.

From here, I recommend consulting the API documentation for Adwaita and Gtk to learn how their widgets work. If a widget exists in both libraries, always prefer the widget from Adwaita as it’ll very likely have many usability and style improvements over the Gtk one. Remember that Adwaita and Gtk are fully object-oriented libraries, so their classes are subject to inheritance — any widget object from either library will inherit all fields, methods, and signals from the parent, so it’s important to consult ancestor documentation as well.

The code for this tutorial is available from my account on GNOME’s GitLab instance, and includes comments to explain each line.

If you have comments, questions, or if any part of this tutorial is misleading, confusing, or just plain wrong, please send me an email or drop a mention on my Mastodon. I’m only one person, not yet an expert in this specific domain, who simply seeks to empower those who may be interested in taking the path I’ve taken.

An aspect of the GNOME project that I greatly admire is the goal of creating an environment that can be used by anyone regardless of computer experience or ability. I hope this guide provides a suitable starting point for those who struggle to develop apps in the ways suggested by GNOME’s own tutorials on the subject. After all, what good is it to create a computing environment that can be used by anyone it not everyone with the skills has the ability to productively contribute?

Victoria