Glaze, a Desktop WebView in Go Without CGO
Opening a window with HTML in Go almost always charges a toll in C.
Either you turn on cgo and drag a C toolchain into the project, or someone embeds a compiled .dll/.so/.dylib in the binary and extracts it to disk on first run. Both work. And both take away exactly what I like about the ecosystem: the go build that cross-compiles to three systems without a second thought, the reproducible build, the go install that works for anyone who cloned the repo. It’s the first thing that goes the moment C enters the picture.
Glaze started out doing the second option. It was a fork of go-webview: it embedded the compiled C++ library from webview/webview, extracted the blob to a temporary directory, and loaded it from there. It even had a BLAKE2b-256 check of the extracted file against the embedded bytes, so no one could swap the lib on disk between extraction and load. It worked. But that’s a lot of ceremony just to open a window: one binary blob per platform, generated in a CI job, extracted at runtime, and a hash to validate a file I had just written myself.
The turning point was to stop loading a lib of my own and start calling the WebView the operating system already has. macOS ships with WKWebView. Windows 10/11 brings the WebView2 runtime. On Linux it’s a system WebKitGTK, which you declare as a dependency instead of packaging. In none of these cases does the native lib need to ride along in the binary. It’s already on the machine.
What was missing was talking to it without cgo. That’s where purego comes in.
purego is FFI without cgo
The idea behind purego is to open a shared library at runtime and call C functions directly, without import "C", without a C compiler in the way. On Linux and macOS it’s dlopen/dlsym under the hood:
lib, _ := purego.Dlopen("libwebkitgtk-6.0.so.4", purego.RTLD_NOW|purego.RTLD_GLOBAL)
var webkitWebViewNew func() uintptr
purego.RegisterLibFunc(&webkitWebViewNew, lib, "webkit_web_view_new")
After that, webkitWebViewNew() is a normal Go function that calls the C symbol. No compilation stage, no headers.
On Windows there’s no Dlopen – you resolve symbols with LoadLibrary and GetProcAddress and register them the same way. For a C callback that calls back into Go there’s NewCallback, which on Windows delegates to the stdlib’s syscall.NewCallback and gets the stdcall convention right. And for macOS there’s the objc package, which talks to the Objective-C runtime: registering a class, creating a Go method that AppKit calls back, blocks, structs passed by value. You can build an entire NSWindow with a WKWebView inside it entirely in Go.
Every system has its own curse
It’s not free. You trade a binary blob for reimplementing the backend on each system, and each one charges its own price.
macOS was the cleanest. Cocoa via objc, delegates registered as Go classes, the autorelease pool by hand. Laborious, but straightforward.
Linux is pure C ABI, the terrain where purego shines. The annoying detail is versioning. The original webview picks GTK3 or GTK4 at compile time; at runtime you don’t get that luxury. So Glaze detects which stack is installed – libwebkitgtk-6.0 (GTK4) or libwebkit2gtk-4.1/4.0 (GTK3) – and switches the calls, because the arity of some functions changed between the two versions.
Windows was hell. WebView2 isn’t a dumb C API, it’s COM. You call a method by index into a vtable of pointers, implement the callback interfaces on this side (a vtable built in Go with QueryInterface/AddRef/Release/Invoke), and on top of that manage the ref-count of an object the garbage collector knows nothing about. And to keep “zero DLL” without embedding WebView2Loader.dll, Glaze reimplements the loader’s discovery: it finds the runtime DLL through the Windows registry and calls an internal Edge export to create the environment. That’s what the official loader does under the hood. The price is that this export is internal and undocumented, and Microsoft may rename or remove it. If it disappears, you get a clear error instead of an ugly crash.
One rule runs across all three backends: no Go pointer crosses into C. The engine is identified by an integer id kept in a map on the Go side, and what goes to C is only the integer. A Go pointer in C’s hands is an invitation for the GC to move the memory out from under it.
The test bench is CI
There’s a decidedly unglamorous detail: I develop on macOS. Linux and Windows I had no way to run by hand. And ABI bugs don’t show up in cross-compilation – it compiles beautifully and breaks at runtime.
So the test bench became containers and CI: GitHub Actions running all three systems (macOS, Windows, Linux on GTK3 and GTK4, amd64 and arm64), plus adversarial review of the COM code before spending a CI cycle on Windows. That’s how it surfaced, for example, that g_signal_handlers_disconnect_by_data isn’t an exported symbol, it’s a GObject macro – an undefined symbol that only blows up at runtime, inside a container. Or that passing a RECT by value to WebView2 works on amd64 and breaks on arm64, because the 16-byte struct goes by reference on one and packed into two registers on the other.
What’s left
In the end, what remains is what I wanted from the start:
CGO_ENABLED=0everywhere;- cross-compile to all three systems straight from
go build, no C toolchain; - a binary that carries no native lib along with it;
go install github.com/crgimenes/glazeworking for anyone who cloned it, no ritual.
What you come to depend on is the runtime being present: nothing extra on macOS, a WebKitGTK on Linux, the Edge WebView2 Runtime on Windows (which already ships with 10/11). It’s an honest trade: a declared system dependency in place of an embedded, disguised one.
Glaze no longer “uses” webview/webview at runtime. It became a port: the same idea, rewritten in pure Go on top of purego, without a single byte of C++ in the binary. Trading a compiled blob for three hand-written backends is more work, and it was. But what comes out of go build now speaks COM on Windows, objc on macOS, and GTK on Linux without an import "C" anywhere.