Embedding Servo in Avalonia

Last week, I started working on a binding between Servo and Avalonia, as an experiment to see how far I could get with it and what Servo could offer as an option for the Avalonia WebView. I’ve managed to get a working WebView implementation running on all of the platforms they (and Avalonia) support: Windows, Mac, Linux, and Android, running through Avalonia’s renderer.

I came away very impressed; it’s rough around the edges (both it and my POC code), but I think it has a ton of potential as an embedded browser.

Now, why start this? Well, it seemed like a fun challenge for starters. It has been done before but I had not seen anyone try it in .NET yet. And I was hoping this could solve a problem I was having with the Avalonia WebView. It all starts with airspace...

The Avalonia WebView interoperates with various native webviews to embed them in the app. How it's done depends on the platform; On Windows, we render Webview2 to an offscreen adapter that can be rendered to a texture, but most use the NativeControlHost, where we “embed” the native control within the drawn surface. Depending on your use case, this can be a fine way to add native functions to your Avalonia app, provided you understand the limitations.

For Linux/X11, we use GtkWebkit. As a dialog and web authentication tool, this worked fine, but embedding it as a native control is quite the challenge. The big problem is airspace. If you want to handle native controls within a drawn interface, you have to think about how they’ll interact with the canvas. Users want to be able to layer their drawn elements all around their native controls; to them, they’re not thinking of them as different concepts anyway, it’s just “I have controls, and I want them shown here.” What often happens is that the native control is either always on top or always on bottom, never in between.

Trying to maintain both layered-drawn controls and native ones is tough, and some platforms make it harder than others. With our X11 implementation, even more so. It ends up less as an “embedded control” and more as a “general placement of a control to a bound area.” Depending on your distribution, window manager, and which control you want bound, it is not ideal.

So for our webview, that was a dealbreaker. While we had experimented with offscreen rendering techniques to embed GtkWebkit as a texture instead, we opted to keep it as a dialog-only element (Ex. a popup that would show the WebView in a window) and leave the embedded option as experimental. That would at least allow cases like authentication to be handled, but there would be a subset of application types we couldn’t cover out of the box.

And, since we were now working on .NET MAUI Handlers for Avalonia, we would need something there to support it. We would get roasted for not supporting WebViews everywhere.

So, we decided to get creative. Since handling X11 controls was sketchy at best, were there other browser implementations that let us render to a texture? At the time, I was thinking about Servo, but after some research, I found that WebKit Embedded (WPE) would be a better fit for what we need right now. They support creating custom backends out of the box; it’s more mature and already maps to the features we need for our webview. Internally, it uses a Wayland compositor that we can scrape textures from and run through our renderers, which solves the airspace problem. Funneling input through was also straightforward.

It’s not without its quirks, however. I am an Arch Linux user. Arch maintains active builds of WPE. So that was great when I was working on it; I could install and go. Checking the other major distributions showed they are outliers. Most either don’t build it, or, if they do, it’s by individuals in custom repos, or it's for older, out-of-support versions. It’s not impossible to build WPE, but it’s not trivial either, and most customers would probably prefer something “in-the-box” than deal with it themselves.

After releasing this as part of our open sourcing of the Webview, I started thinking about what else we could bind to. Preferably, one that didn’t have the platform limitations of WPE, could support all the platforms Avalonia targets, and allowed us to render the view as textures we could manipulate. That led me back to Servo. While it’s not as mature as other browser stacks, as an embedded browser, it has a ton of compelling ideas, and it would fit a lot of our needs if it does work.

So I figured, what the hell, let’s try it and see.

Servo is built in Rust. With just the basic build, it couldn’t be simpler or faster for me to get something running. Maybe I’ve been jaded by other projects, but just being able to run cargo and get a working build the first try was remarkable. .NET can interoperate with it via an FFI (Foreign Function Interface). As of this writing, they have not implemented one. It’s not too hard to write a wrapper library that does, but maintaining ABI compatibility is a pain. It’s hard enough tapping into a library, let alone two through indirection. Still, I could hack that out as a one-off.

The FFI mostly wraps the underlying Servo methods, but there is some special code intended for the platforms. Servo supports passing through pointers to native views, but we need to generate our own rendering canvas and pull that in instead. So, I created a context using Surfman and ripped out the frames through it. I'm not 100% sure if it's necessary, but hey, it worked.

Next, we need to bind that FFI to C#. Through csbindgen, that’s trivial to automate. From there, it’s trying to map how Servo functions to that C# API. I mostly looked at Servoshell, their browser frontend, for how to implement this. We have a ServoEngine, which holds the native instance. This controls the event loop and engine-level delegates. A ServoWebView holds the individual tabs and manages their instances, like navigation, loading, input, and the like. For the consumer, this would mean creating the ServoEngine on startup, then handling ServoWebViews for your UI.

View post on Bluesky

From there, we can wrap that code and handle it through Avalonia. For blitting the pixels, I took the simple route and used a WriteableBitmap, which swaps buffers. There are other ways to do this through direct GL interop, but for a first run, I think the performance is pretty remarkable.

View post on Bluesky

It also demonstrates why I was interested in trying this: The embedder can handle views such as select element dropdowns, pickers, and overlays, so we provide custom Avalonia versions, and users can override these defaults with their own implementations that make sense for their app.

While it’s more work to maintain, it’s much more flexible. Servo gives the coordinates, we render the rest. And since it’s a texture, we don’t have airspace issues here as well.

There are some subtle workarounds, though; on my Mac, I would see white blank frames whenever I resized the window, which I could also replicate in Servoshell. I ended up adding a check for it that would use the last frame until a new, fresh one was added. That adds a little visual jank, but it’s better than flashing. Servo itself currently doesn’t render a scrollbar or provide a way to generate one on the host side. While it’s not strictly necessary, I wanted an easy way to navigate the UI without using a scroll wheel or keyboard, so I jerry-rigged some JavaScript and a ScrollViewer and got it working.

The code is very much a “proof-of-concept.” I’ve been learning Rust off and on over the years, and I assume what I have in the servo-ffi is wrong and bad. Likewise, the Avalonia code could be significantly cleaned up. I would also wait until Servo has a proper FFI before trying to bind to it. But my intention wasn’t to build a NuGet package; it was to validate the approach and see if Servo could fill the role. To that end, I think it succeeded.

For Servo, I can totally see its potential. I think there’s just enough churn within it that it’s not quite ready for a proper binding and library, but with a stable ABI, it could be a great alternative for an embedded browser implementation. It doesn’t have to include every possible feature for that use case, as long as it fits the needs of a user in their environment (e.g., embedded Linux) and the functionality of their app or site. That, to me, feels like a goal that can be achieved, and seeing the growth towards that over the past year makes me bullish on it.

I wouldn’t say it’s quite ready for the Avalonia case yet, at least until they provide an FFI and a stable ABI. But in the future, I can see it becoming the embedded browser of choice.