avatar

How To Build a Standalone GUI Application for SerenityOS

Published on 2022-05-11.

Building a GUI application as part of SerenityOS is easy, and has been done plenty of times. Just take a look at any of the existing ones, or read the (slightly outdated!) Introduction to SerenityOS GUI programming on Andreas' blog.

But what if you wanted to build one that exists as a standalone project and is not part of the SerenityOS repository, and instead installed as a port, or not open source in the first place? That's also possible, but not entirely straightforward — especially when using GML, Serenity's very own GUI Markup Language.

Creating an Example Application

The file structure for our example application looks like this:

example
├── CMakeLists.txt
└── src
  ├── ExampleWindow.gml
   └── main.cpp

With src/main.cpp being a very simple Serenity C++ program using LibGUI and LibMain:

#include <LibGUI/Application.h>
#include <LibGUI/Window.h>
#include <LibMain/Main.h>
#include <ExampleWindowGML.h>

ErrorOr<int> serenity_main(Main::Arguments arguments)
{
auto app = TRY(GUI::Application::try_create(arguments));
auto window = TRY(GUI::Window::try_create());
auto widget = TRY(window->try_set_main_widget<GUI::Widget>());

widget->load_from_gml(example_window_gml);
window->set_title("Example");
window->resize(200, 100);
window->show();

return app->exec();
}

And the GML in src/ExampleWindow.gml used to define layouts and widgets:

@GUI::Widget {
fill_with_background_color: true
layout: @GUI::VerticalBoxLayout {
margins: [4]
}

@GUI::Button {
text: "Well Hello Friends!"
}
}

The result looks like this:

Now, how do we turn this into an executable compiled for SerenityOS?

CMake Shenanigans

You might have noticed that we include a file in our program which I didn't mention yet, ExampleWindowGML.h, which is where the example_window_gml variable is defined. Generally speaking, the load_from_gml() function just takes a StringView, so we could define our GML source in the C++ file and pass it to that function directly.

That's ugly though and kind of defeats the point of trying to separate the GUI declaration from the program logic. So instead, we let CMake generate a header file from our GML source file!

This is all handled by the SerenityOS build system, which defines the compile_gml() CMake function, which in turn invokes a simple shell script outputting code. The result isn't very exciting, and yet this is a useful and commonly used piece of glue code:

extern const char example_window_gml[];
const char example_window_gml[] = R"~~~(@GUI::Widget {
...
}
)~~~"
;

A regular CMakeLists.txt for a SerenityOS GUI application including this GML-to-C++ transform looks like this:

cmake_minimum_required(VERSION 3.16)

project(Example)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

compile_gml(src/ExampleWindow.gml ExampleWindowGML.h example_window_gml)

set(SOURCES
src/main.cpp
ExampleWindowGML.h
)

add_executable(Example ${SOURCES})
target_link_libraries(Example gui main)

install(TARGETS Example RUNTIME DESTINATION bin)

In an actual application you'll also want to add some compiler flags, such as these in the Lagom CMakeLists.txt.

We set up the project using C++20 as required by the SerenityOS libraries, "compile" the GML into a generated header, specify that in SOURCES along with main.cpp, define an executable built from those files, link it against LibGUI and LibMain, and finally install it.

All of this is run by a handful of standard CMake/Ninja build commands, pointing it at Serenity's generated CMakeToolchain.txt:

mkdir -p Build
cd Build
cmake .. -GNinja -DCMAKE_TOOLCHAIN_FILE="${SERENITY_SOURCE_DIR}/Build/${SERENITY_ARCH}/CMakeToolchain.txt"
ninja
ninja install

So far, so good — right? Unfortunately not:

CMake Error at CMakeLists.txt:9 (compile_gml):
Unknown CMake command "compile_gml".

More CMake Shenanigans

Within SerenityOS everything is set up so that you don't have to deal with where and how to make these extra functions available in any regular CMakeLists.txt, but here we're on our own.

compile_gml() is from Meta/CMake/code_generators.cmake, so let's include() that (using the SERENITY_SOURCE_DIR environment variable found in various other build scripts):

set(SerenityOS_SOURCE_DIR $ENV{SERENITY_SOURCE_DIR})
include(${SerenityOS_SOURCE_DIR}/Meta/CMake/code_generators.cmake)

SerenityOS_SOURCE_DIR is a variable referenced by the function itself and is usually set by CMake based on the project() name.

Trying again:

CMake Error at /path/to/serenity/Meta/CMake/code_generators.cmake:18 (add_dependencies):
Cannot add target-level dependencies to non-existent target
"all_generated".

The add_dependencies works for top-level logical targets created by the
add_executable, add_library, or add_custom_target commands. If you want to
add file-level dependencies see the DEPENDS option of the add_custom_target
and add_custom_command commands.
Call Stack (most recent call first):
CMakeLists.txt:12 (compile_gml)

This is an implementation detail of the build system: all generated files are added to a custom all_generated target. Let's simply define that as well, even though we're not going to use it:

add_custom_target(all_generated)

Now we get to a point where the compiler gets involved, but fails with:

FAILED: CMakeFiles/Example.dir/src/main.cpp.o
/path/to/serenity/Toolchain/Local/x86_64/bin/x86_64-pc-serenity-g++ --sysroot=/path/to/serenity/Build/superbuild-x86_64/../x86_64/Root -std=c++20 -MD -MT CMakeFiles/Example.dir/src/main.cpp.o -MF CMakeFiles/Example.dir/src/main.cpp.o.d -o CMakeFiles/Example.dir/src/main.cpp.o -c /path/to/example/src/main.cpp
/path/to/example/src/main.cpp:4:10: fatal error: ExampleWindowGML.h: No such file or directory
4 | #include <ExampleWindowGML.h>
| ^~~~~~~~~~~~~~~~~~~~
compilation terminated.
ninja: build stopped: subcommand failed.

It's our generated GML header! Let's check if it actually exists:

$ file Build/ExampleWindowGML.h
Build/ExampleWindowGML.h: C source, ASCII text

So we got that working, but the compiler doesn't know where to find it. Since the file simply ends up in the build directory, we can add CMAKE_CURRENT_BINARY_DIR to the include directories (which is also what Serenity's root CMakeLists.txt does):

include_directories(${CMAKE_CURRENT_BINARY_DIR})

And with that it finally builds and installs the executable into Serenity's Root/usr/local/bin directory! Let's confirm:

$ cd "${SERENITY_SOURCE_DIR}/Build/${SERENITY_ARCH}/Root/usr/local/bin"
$ file Example
Example: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /usr/lib/Loader.so, with debug_info, not stripped
$ objdump -p Example | grep NEEDED
NEEDED libgui.so.serenity
NEEDED libm.so
NEEDED libgcc_s.so
NEEDED libc.so

NOTE: LibMain is statically linked.

Once you rebuild the QEMU _disk_image, the externally built example application will be available within the VM.

For completeness, here is the full working CMakeLists.txt:

cmake_minimum_required(VERSION 3.16)

project(Example)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

set(SerenityOS_SOURCE_DIR $ENV{SERENITY_SOURCE_DIR})
include(${SerenityOS_SOURCE_DIR}/Meta/CMake/code_generators.cmake)
add_custom_target(all_generated)
include_directories(${CMAKE_CURRENT_BINARY_DIR})

compile_gml(src/ExampleWindow.gml ExampleWindowGML.h example_window_gml)

set(SOURCES
src/main.cpp
ExampleWindowGML.h
)

add_executable(Example ${SOURCES})
target_link_libraries(Example gui main)

install(TARGETS Example RUNTIME DESTINATION bin)

Conclusion

By replicating a few things from Serenity's build system in our own CMakeLists.txt, we can get a code generator working that was never explicitly intended to be used outside of the project :^)

I haven't tried, but this should also be applicable to other kinds of code generators in case you need that. And, of course, if you saw anything that could be streamlined here, please let me know!