If your product is a web UI, the usual desktop story is Electron. You ship
a browser, double the memory footprint, and keep a Node toolchain around.
That model is microslop by construction: a thin app-shaped crust over a
rented runtime, spending your users' RAM to pad someone else's platform
metrics. Linux offers a leaner route. Keep the web UI, wrap it in the
system's WebKitGTK through a webview library, add a tray icon, and package
the result with the same PKGBUILD machinery Arch uses for everything else.
The outcome is one Go binary at ~15 MB, with no bundled browser and no CGO.

This is how it's built, taken from a real app: a Go single-binary inventory
tool with a Svelte UI. Everything below compiles and runs. The snippets are
the parts that matter.

Talk is cheap, show me the code: everything below, stripped to a runnable ~270-line skeleton, lives in go-webview-hello.

The shape of the thing

Three components in one process:

  1. an embedded SPA (//go:embed of the built frontend),
  2. an http.Server bound to loopback serving that SPA plus a JSON API,
  3. a webview window pointing at http://127.0.0.1:<port>.

The webview is the WebKitGTK your distro already ships, loaded through
purego (dlopen-based FFI, so no CGO and cross-compilation stays simple).
The server and the window are decoupled. The same binary builds as a
headless CLI (go build) and as a desktop app (go build -tags desktop).

//go:build desktop package main import ( "github.com/crgimenes/glaze" // webview over WebKitGTK "fyne.io/systray" ) func runUI(d uiDeps) { w, err := glaze.New(false) if err != nil { fatal(fmt.Errorf("webview: %w", err)) } w.SetTitle("stow") w.SetSize(1280, 860, glaze.HintNone) w.Navigate("http://" + d.url) // loopback server with the embedded SPA // ... } 

uiDeps carries the running server and a release func(). Nothing global
crosses the boundary between the server layer and the UI layer, which pays
off when the CLI face needs the same code.

The close button is a trap

The obvious way to handle the window's X button is to hook the destroy
signal. It is wrong, and the failure mode is confusing: by the time
destroy fires, GTK has already decided to tear the window down. What you
want is delete-event, which fires first and lets you veto the deletion by
returning TRUE.

I need the veto because "close" can't mean "quit" when the app lives in the
tray. Closing the window should hide it while the in-process server keeps
running, so the decision has to be intercepted. The webview library binds
destroy only, so the app dlopens GTK itself through purego and connects
the handler directly:

sigConnect func(instance uintptr, detailedSignal string, handler, data, destroy, flags uintptr) uintptr func ensureGTK() error { gtkOnce.Do(func() { gtk, err := purego.Dlopen("libgtk-3.so.0", purego.RTLD_NOW|purego.RTLD_GLOBAL) if err != nil { gtkInitErr = fmt.Errorf("libgtk-3.so.0: %w", err) return } purego.RegisterLibFunc(&sigConnect, gtk, "g_signal_connect_data") purego.RegisterLibFunc(&widgetHide, gtk, "gtk_widget_hide") purego.RegisterLibFunc(&widgetShow, gtk, "gtk_widget_show") }) return gtkInitErr } 

The delete-event handler runs on the UI thread, reads a small
close-mode struct (hide-to-tray vs quit), and either hides the window or
lets the event through. The snippet also needs a guard. Probe for
libwebkitgtk-6.0.so first and fail with an error if it is present, because
loading GTK3 and GTK4 into one process corrupts the GObject type system.
A process can hold only one GTK major version.

The tray, and the threading rule that saves you an afternoon

fyne.io/systray works over DBus, so it has no GTK dependency of its own:

//go:embed packaging/icons/stow-32.png var trayPNG []byte func trayReady(w glaze.WebView, live *closeMode, quit func(string)) { systray.SetIcon(trayPNG) mShow := systray.AddMenuItem("Show window", "Bring the window back") mQuit := systray.AddMenuItem("Quit", "Stop everything and exit") go func() { for { select { case <-mShow.ClickedCh: w.Dispatch(gtkShowWindow) // tray callbacks are NOT the UI thread case <-mQuit.ClickedCh: quit("tray quit") return } } }() } 

Keep this rule in mind: tray menu callbacks arrive on background
goroutines, and every webview/GTK method must run on the UI thread. So every
GTK-side effect goes through w.Dispatch(...). Skip this and you get
intermittent segfaults that depend on click timing, which are hard to debug.

One shutdown funnel

Three different exit paths (window close in quit mode, tray Quit,
SIGINT/SIGTERM) must produce the same teardown: stop the sidecar, drain the
HTTP server with a timeout, close the store, break the GTK loop. A sync.Once
shared by all three does the job:

var quitOnce sync.Once quit := func(reason string) { quitOnce.Do(func() { go func() { sidecar.stop() shCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = d.httpSrv.Shutdown(shCtx) w.Terminate() // documented as background-safe }() }) } 

Terminate is the one webview method that is safe to call from a
non-UI goroutine, which is why the whole funnel runs in that goroutine
instead of fighting the thread rule from the previous section.

Packaging: a PKGBUILD against your own checkout

The desktop entry, the icons, and the binary install through the standard
pacman machinery. The app appears in the launcher and follows the theme, and
it uninstalls cleanly. The interesting part of the PKGBUILD is the version
rule: the version must move with the sources, or pacman -U will happily
"upgrade" you to the same version with stale binaries. Derive it from the
checkout itself:

_pkgrev="$(git -C "$startdir/.." rev-list --count HEAD 2>/dev/null || echo 0)" _pkgsha="$(git -C "$startdir/.." rev-parse --short HEAD 2>/dev/null || echo unknown)" pkgname=myapp pkgver="0.1.0.r${_pkgrev}.g${_pkgsha}" # 0.1.0.r24.gdda9a32 > 0.1.0.r23 > 0.1.0 under vercmp — every commit is a # strictly newer package 

Build order matters. The Go binary embeds the SPA at compile time, so the
frontend has to build first:

build() { cd "$startdir/.." && cd web bun install --frozen-lockfile && bun run build cd .. go build -trimpath -o build/myapp . go build -trimpath -tags desktop -o build/myapp-desktop . } 

Dependencies are the system web stack, not a bundled browser:

depends=(webkit2gtk-4.1 gtk3 glib2 dbus) optdepends=('bubblewrap: sandbox the embedded web content') makedepends=('go' 'bun') 

Then come the usual desktop-entry and icon installs. The build and install
is cd packaging && makepkg -f && sudo pacman -U myapp-*.pkg.tar.zst. Launch
it, and the app shows up in your launcher with its own icon. pacman -R
removes every file it ever laid down.

What you give up, and why it is fine

There is no OS-level autoupdate (the package manager is the updater —
rebuild and pacman -U), no code signing, and the webview is WebKitGTK
rather than Chromium, so test your CSS against it (Svelte apps in practice
need nothing special). In exchange you get one binary, no Node runtime at
application time, and a memory footprint your users won't complain about.
Desktop integration also comes from the distro rather than from JavaScript.
For a tool-shaped app on Linux, I'd take that trade every time.

Glossary

  • Electron: framework that bundles Chromium and Node.js to run desktop apps written in JavaScript.
  • Node / Node.js: JavaScript runtime that executes outside the browser.
  • RAM: the computer's working memory.
  • Webview: a web-display component embedded inside a native application.
  • WebKitGTK: WebKit-based rendering engine for GTK applications, shipped by your distribution.
  • GTK: the graphical toolkit behind GNOME and many Linux applications.
  • GObject: the type and object system GTK is built on.
  • Signal (GTK): a notification emitted by a GTK object, such as destroy or delete-event.
  • CGO: Go's mechanism for calling C code; it complicates cross-compilation.
  • FFI (foreign function interface): calling a native library from another language.
  • dlopen: system call that loads a shared library at runtime.
  • purego: Go library that calls C libraries through dlopen, with no CGO.
  • SPA (single-page application): a UI loaded once, then driven by JavaScript.
  • Svelte: JavaScript framework for building user interfaces.
  • CSS: the web's page-styling language.
  • HTTP: the client-server communication protocol of the web.
  • JSON API: an HTTP interface that exchanges data as JSON.
  • JSON: plain-text format for exchanging structured data.
  • Loopback: the 127.0.0.1 network interface, reachable only from the local machine.
  • CLI: command-line interface.
  • Systray (system tray): the desktop area showing background-app icons.
  • DBus: Linux inter-process message bus.
  • Sidecar: auxiliary process launched by the app.
  • Store: the app's persistence layer.
  • UI thread: the thread owning the graphical interface — the only one allowed to call GTK.
  • Goroutine: lightweight task scheduled by the Go runtime.
  • sync.Once: Go primitive guaranteeing a function runs exactly once.
  • Segfault: memory error that crashes a program.
  • SIGINT / SIGTERM: Unix interruption (Ctrl+C) and termination signals.
  • PKGBUILD: package build recipe for Arch Linux.
  • makepkg: tool that runs a PKGBUILD and produces a package.
  • pacman: Arch Linux package manager.