One of my long-term goals for the Ladybird web browser is to make it feel native on every platform it runs on. We’re far from there today, and it’s going to take considerable effort to get there.
However, this week I’ve made one huge step forward in this area. Let me tell you about it!
Born in the fires of SerenityOS
SerenityOS, the birthplace of Ladybird, is a mostly-closed environment. We control all the APIs from bottom to top, and this has given us a lot of flexibility in building a cohesive system.
Everything event-driven in the system (GUI applications, system services, the CLI shell, …) builds on top of Core::EventLoop, Core::Timer and Core::Notifier.
Let’s look at a simple example of Core::EventLoop so we know what we’re talking about:
{
Core::EventLoop event_loop;
auto timer = Core::Timer::create_single_shot(3000, [&] {
event_loop.quit();
});
timer->start();
event_loop.exec();
}
In the example above, we create an event loop and start a single-shot timer that will fire 3 seconds from now and quit the loop. Then we enter the loop by calling exec(). After 3 seconds, exec() will return. Basic stuff that any event loop supports.
The trouble with event loops is that every major GUI toolkit has its own event loop, and their UI widgets typically only work when combined with their own event loop. Qt has QEventLoop, Gtk+ has the GMainLoop, macOS has the CFRunLoop, etc…
In other words, to look native on other platforms, we have to run their event loops.
Attempt 1: abstraction at the wrong layer
Ladybird started life as a Qt wrapper around LibWeb, and as such, the path of least resistance was using a QGuiApplication in its main function.
I added an interface to LibWeb called Web::Platform::EventLoopPlugin which allowed you to override the various operations that LibWeb would normally do on the Core::EventLoop.
The interface looked something like this:
class EventLoopPlugin {
public:
virtual void spin_until(JS::SafeFunction<bool()> condition) = 0;
virtual void deferred_invoke(JS::SafeFunction<void()>) = 0;
virtual NonnullRefPtr<Timer> create_timer() = 0;
virtual void quit() = 0;
};
And it worked out pretty well! LibWeb code was updated to stop calling Core::EventLoop and instead calling the installed EventLoopPlugin. Ladybird got its own plugin that proxied to Qt APIs and all was well…
… Of course, now we also had to patch LibIPC, which LibWeb uses under the hood to communicate between processes. IPC::Connection uses Core::EventLoop::deferred_invoke() to invoke callbacks some time in the future. These needed to be rerouted to the Qt event loop in Ladybird, so I added a new IPC::DeferredInvoker interface:
class DeferredInvoker {
public:
virtual void schedule(Function<void()>) = 0;
};
Ladybird installs its own DeferredInvokerQt on its IPC::Connection objects, which bundles the callback in a QTimer::singleShot() and we’re back on track…
… Well, we’re also not waking up when sockets become readable. LibWeb and our other libraries often use Core::Notifier which invokes a callback once a specific file descriptor becomes readable/writable. Since we’re in the Qt event loop, there’s no Core::EventLoop to notify our notifiers, and nothing happens.
No problem, we’ll just set up a helper QSocketNotifier for each Core::Notifier in Ladybird. We ended up with this thing to equip each IPC client with the necessary assistance:
template<typename ClientType>
void proxy_socket_through_notifier(ClientType& client, QSocketNotifier& notifier)
{
notifier.setSocket(client.socket().fd().value());
notifier.setEnabled(true);
QObject::connect(¬ifier, &QSocketNotifier::activated, [&client]() mutable {
client.socket().notifier()->on_activation();
});
client.set_deferred_invoker(make<DeferredInvokerQt>());
}
Ladybird had to do this manually for anything at any level in the stack that wanted to use a Core::Notifier, but at least now things were working.
Attempt 2: abstraction at the right layer
Over time, as work continued on the browser, new things would sneak in that used LibCore without consideration for Ladybird and its Qt event loop. After playing whack-a-mole with these issues for months, I finally decided to do something about this.
The solution: Instead of patching everyone’s calls to go somewhere other than LibCore, let’s make LibCore pluggable and allow clients to install a different backend for the event loop.
I added a new interface called Core::EventLoopImplementation and made Core::EventLoop a simple wrapper around calls to an underlying implementation object.
class EventLoopImplementation {
public:
enum class PumpMode {
WaitForEvents,
DontWaitForEvents,
};
virtual size_t pump(PumpMode) = 0;
virtual int exec() = 0;
virtual void quit(int) = 0;
virtual void wake() = 0;
virtual void deferred_invoke(Function<void()>) = 0;
virtual int register_timer(Object&, int milliseconds, bool should_reload, TimerShouldFireWhenNotVisible) = 0;
virtual bool unregister_timer(int timer_id) = 0;
virtual void register_notifier(Notifier&) = 0;
virtual void unregister_notifier(Notifier&) = 0;
};
(The API is actually a little more complex than that, due to esoteric features only used in SerenityOS, but the above gives you an idea of the interface.)
Our old event loop has moved into a new EventLoopImplementationUnix class which is what we use on SerenityOS by default. This is also portable to other Unix-like systems and runs fine on Linux, macOS, etc.
Ladybird gets its own EventLoopImplementationQt class, which translates the various LibCore concepts to Qt equivalents. Core::Timer gets a QTimer, Core::Notifier gets a QSocketNotifier, etc.
Libraries no longer need to think about what event loop they might be running on. As far as they care, it’s Core::EventLoop.
What’s so great about this?
On the surface, this is a pretty pedestrian refactoring. However, what we’ve achieved is actually very important in the long term:
There’s now a blueprint for integrating Ladybird with other event loops. Wanna run on top of Gtk+? Implement EventLoopImplementationGlib! macOS? You’ll need an EventLoopImplementationCF.
Once you get those pieces into place, the rest of our browser stack should (in theory!) hum along nicely on top of it. In fact, one might even consider this a first step towards bringing other SerenityOS applications to other platforms as well. :^)