Glaze is a desktop WebView binding for Go. It is a pure-Go port of webview/webview built on purego, keeping CGo out of the picture. Each backend talks to the WebView framework the OS already ships -- WKWebView on macOS, WebKitGTK on Linux, WebView2 on Windows -- so nothing native is bundled.
It started as a fork of go-webview but has diverged enough to live as a separate codebase with its own goals and API.
| Desktop | Game of Life | Starfield |
|---|---|---|
![]() |
![]() |
![]() |
| Doom Fire | Mandelbrot | Falling Sand |
|---|---|---|
![]() |
![]() |
![]() |
| Raycasting | Filo REPL | |
|---|---|---|
![]() |
![]() |
This is the whole point of the project, and it's the part that's easy to miss. Most native-WebView bindings reach for CGo, which quietly takes back the things that make Go pleasant to ship: cross-compiling suddenly needs a matching C cross-compiler for every target (mingw for Windows, a sysroot for Linux), builds stop being reproducible, and go install only works for people who already have that C toolchain set up.
glaze keeps CGo out entirely - with purego it dlopen/LoadLibrarys the WebView the OS already ships, so there is no C compiler in the loop. What that buys you:
-
Cross-compile to every desktop from one machine -- no C cross-toolchain, just
GOOS/GOARCH:GOOS=windows GOARCH=amd64 go build # from a Mac, from Linux, from anywhere GOOS=linux GOARCH=arm64 go build GOOS=darwin GOARCH=arm64 go build -
CGO_ENABLED=0builds -- reproducible output, and ago get/go installthat just works for whoever clones the repo, with no compiler to install first.
One caveat, "self-contained" isn't misread: glaze does not bundle a browser engine - it is not Electron. The binary ships no native library and stays small, but it uses the system WebView at runtime, so the target machine needs that present: WebView2 on Windows (preinstalled on current Windows 10/11), WebKitGTK on Linux (a package), WKWebView on macOS (built in).
- No CGo
- Windows, macOS, and Linux
- Zero bundled native libraries -- binds the OS WebView directly (WKWebView / WebKitGTK / WebView2)
- JavaScript to Go binding
- Helpers for common desktop patterns:
BindMethods,RenderHTML,AppWindow, a Go↔JSEventsbridge - A dependency-free code editor component (
glaze/editor): line numbers, syntax highlighting, autocompletion, error marks — Filo and SQL definitions included, languages pluggable - Native file dialogs (
OpenFile/OpenFiles/SaveFile/OpenDirectory) and a reusable native menu bar (glaze/menu) - Custom URL schemes: serve embedded assets from a portless, secure-context
app://origin (NewWithOptions) - Window control from Go:
SetTitle,SetSize,Focus(keyboard focus into the web content) andRaise(front the window and activate the app) NewWindow(debug, window)embeds the WebView into an existing native window (Newis the create-a-window shortcut)- Plays nicely with
go.workmulti-module setups
Glaze stays focused on the window and the WebView. OS features that aren't window-bound -- and especially the more platform-specific or less standardized ones, like desktop notifications and the system tray -- live in native, a sibling collection of small, cgo-free packages on the same purego foundation: clipboard, single-instance locks, opening a URL or revealing a file, memory-mapped files, keeping the machine awake, and more. The two don't depend on each other -- an application imports each directly for what it needs; where a platform can't support something cleanly, the native package returns a clear ErrUnsupported instead of shipping something flaky.
go get github.com/crgimenes/glaze@latestGlaze binds the WebView the operating system already provides; there is nothing to bundle, but that runtime must be present:
- macOS -- nothing extra. The Cocoa/WebKit frameworks ship with the OS.
- Linux -- a system WebKitGTK, GTK4 or GTK3; glaze detects which at runtime. The exact libraries and how to install or debug them are in Linux shared libraries below.
- Windows -- the Microsoft Edge WebView2 Runtime (preinstalled on current Windows 10/11; otherwise install the Evergreen Runtime). It is located via the registry, and
Newreturns an error if it is missing. To bundle zero native DLLs, glaze calls the runtime's internal environment-creation export directly instead of shippingWebView2Loader.dll; that export is undocumented and could change in a future Edge runtime (in which caseNewreturns a clear error). See the note oncreateEnvironmentin webview2_windows.go.
Linux is the hard case. Every distro packages WebKitGTK a little differently and glaze can't paper over all of it -- but what it needs is concrete. These are the exact sonames it tries to dlopen at startup. They have to be loadable by the dynamic linker (on the default search path or in the ldconfig cache, or in LD_LIBRARY_PATH) and the same architecture as your binary -- a 64-bit Go build needs 64-bit libraries.
Always loaded:
libglib-2.0.so.0libgobject-2.0.so.0
libwebkitgtk-6.0.so.4 decides the stack: if it loads, glaze uses GTK4; otherwise GTK3. It never loads both -- most desktops have GTK3 and GTK4 installed side by side, and pulling both into one process corrupts GTK's type system and crashes gtk_init.
- GTK4:
libgtk-4.so.1,libwebkitgtk-6.0.so.4,libjavascriptcoregtk-6.0.so.1 - GTK3:
libgtk-3.so.0,libwebkit2gtk-4.1.so.0(orlibwebkit2gtk-4.0.so.37),libjavascriptcoregtk-4.1.so.0(orlibjavascriptcoregtk-4.0.so.18)
On the GTK4 stack, the file dialogs additionally load libgio-2.0.so.0 the
first time a dialog opens (it ships with GLib, so it is present wherever the
libraries above are).
Installing the WebKitGTK package pulls GTK and GLib in as dependencies:
- Debian / Ubuntu:
apt install libwebkit2gtk-4.1-0(GTK3) orlibwebkitgtk-6.0-4(GTK4) - Fedora:
dnf install webkit2gtk4.1orwebkitgtk6.0 - Arch:
pacman -S webkit2gtk-4.1orwebkitgtk-6.0 - Nix / NixOS: these libraries are not on the default loader path, so a bare
go runoutside a shell that provides them fails to load. Addwebkitgtk_4_1(orwebkitgtk_6_0) to yourbuildInputs/ dev shell, or expose them throughLD_LIBRARY_PATHornix-ld.
If New returns webview: none of [...] could be loaded, the linker can't find that soname. See what's actually visible to it:
ldconfig -p | grep -E 'libwebkit(2)?gtk|libjavascriptcoregtk|libgtk-[34]'wrong ELF class: ELFCLASS32 means the library was found but in the wrong architecture -- a 64-bit binary was pointed at 32-bit libraries (check your LD_LIBRARY_PATH).
The test suite reflects this: the GUI tests skip themselves when none of these libraries can load, so go test ./... stays green on a box without WebKitGTK instead of failing.
package main
import (
"log"
"github.com/crgimenes/glaze"
)
func main() {
w, err := glaze.New(true)
if err != nil {
log.Fatal(err)
}
defer w.Destroy()
w.SetTitle("Glaze")
w.SetSize(800, 600, glaze.HintNone)
w.SetHtml("<h1>Hello from Glaze</h1>")
w.Run()
}Glaze pins the goroutine that creates the first window to its current OS thread. Keep direct window calls on that goroutine, and use Dispatch to re-enter the UI thread from background work.
A convenience layer over Bind that exposes every exported method of a Go value as a JavaScript-callable function.
What it does:
- Reflects over the exported methods of a struct or pointer receiver.
- Builds JavaScript names with a prefix and snake_case conversion.
- Example:
GetUserByIDwith prefixapibecomesapi_get_user_by_id.
- Example:
- Applies the same signature rules as
Bind: no return, value, error, value and error. - Returns the list of registered names so you can log or verify them.
Useful when you have a service object and want to expose a consistent JavaScript API without writing one Bind call per method.
type Store struct{}
func (s *Store) GetItems() []string { return []string{"a", "b"} }
bound, err := glaze.BindMethods(w, "store", &Store{})Renders a named Go html/template to a string you can pass to SetHtml.
What it does:
- Runs a specific template (nested calls included).
- Returns the final HTML string.
- Wraps execution errors with template context.
Useful when you want server-style template rendering in a local desktop app without running an HTTP server for that page.
html, err := glaze.RenderHTML(tpl, "page", data)
if err != nil {
return err
}
w.SetHtml(html)Wraps an http.Handler inside a native desktop window backed by a local loopback HTTP server.
What it does:
- Selectable transport with platform-aware default:
auto(default):unixon macOS/Linux,tcpon Windowstcp: direct loopback HTTP (127.0.0.1)unix: handler served on a Unix socket with a lightweight loopback HTTP gateway for browser navigation
- Starts listeners on random free ports/paths by default (or a custom
Addr/UnixSocketPath). - Creates a native window and navigates it to that local URL.
- Runs the UI loop and shuts down the HTTP server when the window exits.
- Supports window sizing, title, debug mode, and an optional readiness callback.
OnReadyreceives the browser URL (loopback;http://127.0.0.1:..., orhttp://[::1]:...if you pass an IPv6Addr).OnReadyInforeceives the resolved backend details (Transport,Backend,Gateway) so you can verify unix vs tcp from logs.
The shortest path from an existing net/http app to a desktop app, with minimal changes to routing, templates, and assets.
err := glaze.AppWindow(glaze.AppOptions{
Title: "My App",
Width: 1280,
Height: 800,
Transport: glaze.AppTransportAuto,
Handler: mux,
OnReadyInfo: func(info glaze.AppReadyInfo) {
log.Printf("transport=%s backend=%s gateway=%s", info.Transport, info.Backend, info.Gateway)
},
})Serve a window's assets from your own app://-style origin -- one that WebKit
and WebView2 treat as a secure context -- without opening a TCP port. Hand
NewWithOptions a map of scheme name to handler; the handler turns a request
into bytes:
//go:embed ui
var uiFS embed.FS
w, err := glaze.NewWithOptions(glaze.Options{
Debug: true,
SchemeHandlers: map[string]glaze.SchemeHandler{
"app": func(req *glaze.SchemeRequest) *glaze.SchemeResponse {
data, ctype := serve(req.URL) // from your embedded FS, however you like
if data == nil {
return nil // a nil response is a 404
}
return &glaze.SchemeResponse{Body: data, MIMEType: ctype}
},
},
})
w.Navigate("app://home/index.html") // secure origin, no portWhy not just file:// or SetHtml? Because neither is a secure context,
and a large part of the web platform is gated behind one:
| Approach | Port? | Secure context? |
|---|---|---|
Loopback http://127.0.0.1:<port> server |
opens a port | yes |
file:// / SetHtml |
no port | no -- crypto.subtle is undefined, getUserMedia/geolocation are blocked, localStorage is unreliable, routing is hash-only |
| Custom scheme (this) | no port | yes -- localStorage, crypto.subtle, getUserMedia, and path routing all work |
Handlers are supplied at construction (not added later) because macOS bakes the
scheme handlers into the WKWebViewConfiguration before the WKWebView exists.
New/NewWindow delegate to NewWithOptions, so existing code is unaffected.
Each backend uses its own native mechanism: macOS a WKURLSchemeHandler; Linux
webkit_web_context_register_uri_scheme marked secure. Windows has no
per-scheme secure flag, so the scheme is served over a per-scheme
https://<scheme>.localhost virtual host (an https origin is a secure context)
and Navigate rewrites <scheme>://… to it -- so your handler and your
Navigate URLs use the one scheme:// form on every platform. See
examples/scheme.
On macOS a click on a window that does not have focus is spent activating the window: it never reaches the page. For a control panel, a dashboard or a player -- anything the user clicks in passing -- that reads as a broken button, and the user ends up clicking twice.
w, err := glaze.NewWithOptions(glaze.Options{AcceptsFirstMouse: true})It is opt-in, and deliberately so: the AppKit default is what protects destructive interfaces. In a drawing tool, an editor, or any window with a delete button, a click that merely raises the window must not also press whatever happens to sit under the cursor. Leave it off when a stray first click could destroy something.
macOS only; ignored on Linux and Windows, where a click on an inactive window
already reaches the content. The mechanism is a WKWebView subclass answering
YES to acceptsFirstMouse: -- AppKit asks the view under the cursor, so
there is no window-level or runtime switch for it.
It is not always enough. AppKit will deliver the click to the view, but WebKit hosts the page in another process and does not always forward that first click to the DOM while the window is not key -- measured, not assumed. When a program knows it took its own focus away (it launched a window that activates, say), the reliable answer is to take the focus back:
w.Raise() // front the window and activate the app; the next click just worksRaise is the blunt instrument and should be used sparingly -- stealing focus
from someone typing in another application is worse than the second click it
saves. Focus is the other half: it moves the caret inside the page.
A lightweight publish/subscribe bridge between Go and JavaScript, layered on
Bind/Init/Eval with no extra native code. Create one per window, then emit
and subscribe on either side; an event reaches every listener on both sides
exactly once.
ev, err := glaze.NewEvents(w)
if err != nil {
log.Fatal(err)
}
// Go subscribes; each argument arrives as raw JSON to decode as you like.
ev.On("ui:save", func(args ...json.RawMessage) {
var name string
_ = json.Unmarshal(args[0], &name)
log.Println("save requested for", name)
})
// Go emits to JS — safe to call from any goroutine.
_ = ev.Emit("app:ready", map[string]any{"version": 3})// JS subscribes to Go events and emits its own.
glaze.events.on("app:ready", (info) => console.log("ready", info.version));
glaze.events.emit("ui:save", "untitled.txt");On returns a function that cancels that one subscription; Off(name) drops all
of them. Go handlers run on the goroutine that emitted (or the binding goroutine
for events coming from JS), so re-enter the UI thread with Dispatch if a handler
touches the window. See examples/events.
Native open/save/directory dialogs, exposed on the WebView interface (a glaze
extension; upstream webview has none):
path, _ := w.OpenFile(glaze.FileDialogOptions{
Title: "Open an image",
Filters: []glaze.FileFilter{{Name: "Images", Extensions: []string{"png", "jpg"}}},
})
paths, _ := w.OpenFiles(glaze.FileDialogOptions{}) // multi-select
saveTo, _ := w.SaveFile(glaze.FileDialogOptions{Filename: "untitled.txt"})
dir, _ := w.OpenDirectory(glaze.FileDialogOptions{})Backends: NSOpenPanel/NSSavePanel (macOS), IFileOpenDialog/IFileSaveDialog
(Windows), GtkFileChooserNative (Linux). Each shows the modal dialog, blocks the
calling goroutine, and returns the chosen path(s) or "" on cancel. Call them
from Bind callbacks (a background goroutine), never from the UI thread. See
examples/filedialog.
github.com/crgimenes/glaze/menu installs a native menu bar. It depends
only on purego, not on the WebView, so a game or any other window-owning app can
use it the same way.
menu.Set([]menu.Item{
{Title: "App", Submenu: []menu.Item{
{Title: "About", OnClick: showAbout},
{Separator: true},
{Title: "Quit", Shortcut: "cmd+q", OnClick: w.Terminate},
}},
{Title: "Edit", Submenu: []menu.Item{
{Title: "Copy", Shortcut: "cmd+c", OnClick: doCopy},
}},
}, menu.Options{Window: w.Window()})macOS (NSMenu) and Windows (Win32 menu bar) are implemented; Linux returns
ErrUnsupported. See examples/menu.
examples/ is a separate Go module (it keeps the library's go.mod
purego-only), so run the examples from inside it:
cd examples
go run ./simple
go run ./bind
go run ./zero_tcp
go run ./schemeOr from each example directory:
cd examples/appwindow && go run .
cd examples/desktop && go run .
cd examples/filorepl && go run .examples/zero_tcp shows a local-first UI with no HTTP server and no loopback
TCP gateway: it stages the frontend to disk, navigates to a file:// URL, and
talks to Go through BindMethods alone.
examples/scheme is the secure-context counterpart: it serves an embedded
frontend from a portless app:// origin via a custom scheme handler, so
localStorage, crypto.subtle, and path routing all work (they do not on the
file:// origin above).
go test ./...This runs the pure-logic unit tests (binding marshalling, transport selection) plus the per-platform GUI smoke tests, which drive a real WebView (WKWebView / WebKitGTK / WebView2). Those GUI tests skip themselves when the system WebView can't run here -- no display, or the libraries aren't installed (WebKitGTK on Linux, the Edge WebView2 Runtime on Windows) -- so the command above stays green on a headless or minimal box instead of failing.
For a fast, headless run, -short skips the GUI scenarios on every platform
(each drives a real run loop and can take a few seconds):
go test -short ./...To actually exercise the GUI tests on Linux, install WebKitGTK and run under a virtual display:
xvfb-run -a go test ./...Use windowsgui to hide the console window:
go build -ldflags="-H windowsgui" .webview_common.go-- theWebViewinterface, function-wrapper, and JS marshallingwebview_bridge.go/webview_bridge_webkit.go-- the injected JS bridge (init/bind scripts)webview_darwin.go/webview_linux.go/webview_windows.go(+webview2_windows.go,putbounds_amd64.go,putbounds_arm64.go) -- the per-OS pure-Go backendsscheme.go(+webview2_scheme_windows.go) -- the custom URL-scheme handler API (NewWithOptions/SchemeHandler)appwindow.go-- desktop window + local HTTP server helperdialog.go/dialog_darwin.go/dialog_windows.go/dialog_linux.go-- native file dialogshelpers.go-- utility helpers (BindMethods,RenderHTML)events.go-- the Go↔JS publish/subscribe events bridge (NewEvents)menu/-- the standalone native menu-bar package (github.com/crgimenes/glaze/menu)examples/-- runnable sample applications (their own Go module)
Glaze loads the OS WebView framework directly and bundles or extracts no native library, so there is no extracted file to verify or swap.
- abemedia/go-webview for the original Go binding base
- webview/webview for the original C++ WebView implementation this is ported from
- purego for dynamic linking without CGo
- filo: a small scripting language safe to embed in Go programs.
- keikiban: a PostgreSQL dashboard; database load, top SQL, locks, index health.
- kutta: a 2D wind tunnel; watch air misbehave around an airfoil.
- minigui: a tiny immediate-mode GUI for Ebitengine.
- neko: the classic desktop cat chasing your pointer, in Go.
More at github.com/crgimenes and crg.eti.br.







