projects / gantry
Gantry
A Go framework for native desktop and Android apps with React frontends - frameless windows, tray icons, live reload, and a single .exe or APK out the other end.
'=:
O0Xx=
;KW0W@K+
oW@@@MMMMMb
.~xM@@@@MM0WW0Md
'=+;`:*xK@@@@MWKKKXO0WW0@i
-+io!+*lKMWWWWWM@0OdbOK0MM0Mb
=~;, .`;-=ld0WMMMMMWWK'
:OWW@MMM0X.
~K0M@MMWk
i00WMWWo
.KW0WWW!
bMWMWM=
dMWWWW,
0W0WM0
:@MMMM0
+@MMMMW
*@MMMMW.
l@M@@@@,
l@@@@@@=
l@M@@@@k
*MM@@@@M.
=@M@@@@@=
:@M@@@@@O
`W@@@@@@@;
K@@@@@@@X
k@@@@@@@@*
i@@@@@@@@M'
;W@@@@@@@@K
.0@@@@@@@@@0.
X@@@@@@@@@@X
d@M@@@@@@@@@d
c@W@@@@@@@@@@O
l@MM@@@@@@@@@@K`
k@@@@@@@@@@@@@@@*
dW0M@@@@@@@@@@@M@d.
!0W@M@@@@@@@@@MMM@@i
~0XM@@@@@@@@WM@@WM@@Mo`
-OWM@@@@@@@MM@@@@MMM@@@k:
-x0M@@@@@@@MWMMMM@MWWWWM@b+
x0M@@@WWM@M@MM@MMW00KO0KWMX!'
*X@@@@@@WM@@MW0W0WKOOXXXObKKXo~.
'OWM@@@WM@@MMW0WW0KKXXOObbbOXXXXd='
~M@@MM@@W@@MM@MWWW0KKKXOXOOOOOOXbXd*'
0@M@@@W@@@@@@MW0K00KXX0XXXbddXOObb00c;
00M@@@@@WW@@@M000XOXKOdObdddkdOKKKOOOOd=
cW@KM@@@WM@@@MWW0XKX0XddXbxkoxbkkKKbbbbXO!`
;0WW@@MM@@@MMMWMW0000KKXXkkOkobbkdXXObOOKXd!.
'OWW@@@@@@@@@@MWWWK0KKX0KOOXXxkOXObKKXXXXXKKb*
~XW@MM@M@@M@@WW@WWW0WXWXOK00XkbObdOKKX0XbOK00O-
!0WMWMMM@WW@@@@WWWM@MWXXKK0WOXKXbdOKXO0XbOKbbKl
kWMWMMMKW0M@@@@@MMMMM0000WWOXW0KObKKXXW0KKOdkbb'
*KMM@M@MW00W@@@@@@M@000K0WWWXWWKKddK0OK0W0XXbdb0-
`cWM@WM@@0WKWM@W0@@@WWW0WWWMKWM00XxbXXXXX000KbOKK!:
!X0WM@MWKMM0M0W@@@@@MMM0W@00MWKWbbdxbOOOKK0WObkxd
-00XWMWK@WK00MM@@MM@@@W0MW0MMWMKOOxdOdkXblxXXdkdi
+ObOK0WMWKWXKM@@WX@@@MWMMWWMMM000bdOdckbdl~cXXXO*
;lcxkOMMKWXdK@@WOMW@MMMMWK0@@W0WKbObcodXOO=oObOXi
,==+cOW00MXXbWWXW@@MWWWKWXM@MWM0bdKdxooOXk-OWOdXl.
;=~+ikOMM@0kkd0WMK00WW0XK0W0KbXOWOXkxkxdx-OMdkXx,
;;-~~+lOXMWbld0MWOWMWWKKbXObbWW0KkokkOOXx-b@doOK;
`-~-=+obbbckM0dKWWWXKOKOkKM0WXdObdOXXKb~X@blK!
.~-~*=-*ooib0lKMKWOKOd0kMMK0KKXddXKKK0c-b@Xido
:;~!*+~+ooid*oK00KbKbWKW0bKWWKdXXK0KObx~k@KidX`
-=cKM0Oxlc!=+!ldbOK0WWW0XX0MKKKOOXXdbdbio0M*kM!
oKldXOoXMo+l+=!*!kXK0M0KbKKXXObO0XbOkdKkk0W*Xd=
0WdbXXxOxioi-=~-=lod00OOkdO0bb0M0KOddOXXb0dcWb
-WMW0@OWMoXXc~...`:~xKbbkW@XbXKMKdO00OKXdO0cXk
`bMKM0M@KbKb, 'icckXKbXdbxkOWWW00KOOK0bO
.oX0WOM0b@K' '~*ddbdxxdM0XW0WW0Ob0MOK=
'=!bKdWO@@i lxdoddbWMO00WWKOOKMKb-
-bdbO@@W, :OkdxokbK0X0W00Kb0W@k
:--+ckO@@MW' ;ii*xxclWbXK00XbWW@O
,~::,.,K@K@O =l+,iKOXK0OOW0@i
;, 'lX0M= *kXOXdKWM@-
`;- 'iXk kKXkMO@b:
,;- +Ox. .:oOdkWi`
:-= =Ok- !KOkO!
;;~ -dk' ;Obxd=
.-' ;oo, '.
`;; ;c!,
;:` :i!'
i= *ii-
*ic' ~!~=*
-*==. ~+~*:
++*~+- ~*=+
.~~+~ -+l,
.++=- .';~+c=`
,+=-~ :il*!-io~
++=' ,o*i!*-o+
=*` +ocl*l+~l
*! . `oi=*+;
'+**i. '+o=cd,
!!=o* *~=k!
:!+x! cd, ,`!i
++~;l` :di dd!+
;*+ ~ =ib: xl`
i*= lO*x~ ic~`,`
-!*: ' :l !kk;~*
=; -i!lll '
`*; *! .c'
'+!; -!*o*oO~
'=+~ ;+`;';i,
.++
.`
Overview
Gantry is a framework for building native desktop apps in Go with React frontends. The app logic lives in Go, the UI is React and TypeScript, and gantry build turns the lot into one binary per platform: a frameless WebView2 window on Windows, a WebKitGTK window on Linux, and the same app as an Android APK.
I extracted it from the app shell I had written for Timekeep my custom scheduler for my tasks and events, because I wanted those frameless windows, tray icon and popups in the next app without copying files across. Gantry's optional Go-driven UI layer borrows the Bubble Tea model that Archon's terminal client uses, which allows those who are not highly preficiant in react or those who just want something simple to quickly build out an application.
At v0.5.0 it is about 21,000 lines of Go, a 3,000-line npm package (gantry-web), a CLI and 57 pages of docs.

The problem
Go is good at the parts of a desktop app nobody sees: file watching, timers, network calls, storage. It has no good answer for the part people do see. React does, but shipping React on the desktop usually means Electron and a whole bundled browser.
Electron isn't the only option. Wails and Tauri both put a web frontend in the system's own webview and are actively developed, and Wails even uses Go. I built my own anyway because I wanted to learn how desktop windowing and the lower-level OS APIs actually work, and Timekeep gave me a real problem to learn on instead of one invented for the exercise.
What I wanted from a shell
- It feels native. No OS title bar, but dragging, resizing, snapping and closing to the tray behave like a real window.
- One file out. A single exe with the frontend embedded and nothing extra to install on Windows.
- A fast loop. Saving a
.tsxfile hot reloads; saving a.gofile rebuilds the backend and lands me on the same page. - Go stays in charge. Logic lives in Go, React is the view, and a whole page can be written in Go.
How it works
Every Gantry app has the same shape: one Go binary that serves the built React app on 127.0.0.1:<port> and opens a native webview pointed at it. Serving over loopback HTTP sounds roundabout, but it buys three things at once. The native window, --browser mode and dev mode are one code path where only the URL changes. The UI gets a websocket for server push. And binding a fixed port doubles as the single-instance lock: if net.Listen fails, another copy is already running.
One binary, every role
Desktop widgets and notification popups are separate windows, and each one runs as a separate process: the same exe started again with a --shellrole flag. gantry.Run checks for it before doing anything else.
// Helper window roles run as child processes (crash isolation): // the exe re-invokes itself with --shellrole and renders exactly // one window. if *shellRole != "" { defer appshell.RoleLog(cfg.Name, *shellRole)() args := RoleArgs{Port: *port, URL: *roleURL, Monitor: *monitor, Position: *position} var err error switch { case *shellRole == "popup": err = appshell.RunPopup(appshell.PopupOptions{ AppName: cfg.Name, URL: args.URL, Width: 460, Height: 140, Monitor: args.Monitor, Position: args.Position, }) case cfg.Roles[*shellRole] != nil: err = cfg.Roles[*shellRole](args) default: log.Fatalf("unknown --shellrole %q", *shellRole) } if err != nil { log.Fatalf("%s: %v", *shellRole, err) } return }
If the flag is setL4 the process draws exactly one window and returns without ever starting the serverL26. The child loads its page over HTTP from the main process, so the app logic stays in one place. RoleLogL5 sends the child's log to a file, because a -H windowsgui build has no console.
There are two reasons for a process per window. The first is crash isolation: a renderer crash takes out one widget, and closing a widget is just Process.Kill(). The second is a hard WebView2 rule. Two processes can't share a browser data folder, and when they try, the second one never starts. Each role gets its own folder, at the cost of a full webview process per widget.
Frameless windows, and why everything is posted
Removing the frame on Windows is one line: return 0 from WM_NCCALCSIZE and the client area becomes the whole window. Everything the frame did then has to be rebuilt in one WndProc subclass: edge and caption hit-testing, clamping maximise to the work area so it doesn't cover the taskbar, and saving geometry when a drag ends.
The catch is that the WebView2 child covers the whole client area and swallows the mouse, so the native hit-test never sees it. Instead, the React TitleBar calls a bound Go function on mousedown:
// closeWindow asks a window to close the same way the native close// button does - asynchronously via WM_CLOSE. Calling webview Terminate()// from inside a JS binding crashes the app (teardown mid-dispatch), so// every close in this package goes through here.func closeWindow(hwnd uintptr) { procPostMessageW.Call(hwnd, wmClose, 0, 0)}// dragWindow hands the window to the native move loop. The WebView2// child window covers the whole client area, so the parent's// WM_NCHITTEST caption band never sees the mouse - the web top bar calls// this on mousedown instead: releasing Chromium's capture and posting a// caption-drag starts the native move. PostMessage, not SendMessage -// entering the modal move loop from inside a JS-dispatch stack is the// Terminate()-style crash again.func dragWindow(hwnd uintptr) { procReleaseCapture.Call() procPostMessageW.Call(hwnd, wmNCLButtonDown, htCaption, 0)}
The important part is PostMessageL18 instead of SendMessage. A bound function runs in the middle of a JavaScript dispatch on the window's message loop. Entering the modal move loop from there hangs the app, and tearing the webview down from there crashes it, which is why close is posted tooL5-7. The rule in the codebase is that bindings only ever post: close is an async WM_CLOSE, drag and edge resize are posted WM_NCLBUTTONDOWN messages with the right hit-test code, and minimise and maximise use ShowWindowAsync. Each of those rules was a real bug first.
On Linux the same jobs go through GTK's own move and resize drags. I vendored a fork of webview_go there, because upstream hardcodes the webkit2gtk-4.0 package name and Fedora 40+ and Ubuntu 24.04+ only ship 4.1.
The bridge: bindings for the window, a websocket for the app
React talks to Go over two channels. Window verbs like close, drag and minimise go through functions the webview binds onto window.gantry*. They are one-way and per window, and in a plain browser tab they are safe no-ops, which is what lets --browser mode work. Everything else goes over one JSON websocket at /gantry/ws.
Every page and component is a folder with a .go half, a .tsx half and an optional .css file, keyed by the folder path. In React, usePaired() returns send, call (a promise), on (pushes from Go) and state, and the Vite plugin injects the key so only Go writes it down.
Calls run on their own goroutine. A returned error rejects the promise with a code the frontend can switch on, while a panic is recovered and reported with a trail of recent navigations, events and calls, so the window shows an error screen instead of going white. Crash handling is on by default.
A new connection always replaces the old one as "the client". That one rule makes webview reloads, React StrictMode double mounts and dev restarts just work. The client reconnects with backoff and re-sends its ready frame each time, so the server re-renders whatever page it was on.
Pages written in Go
The optional Tea layer moves a page's state into Go: a Model with Init, Update and View, where View() returns a tree of nodes that the runtime renders as real React components. Each page model gets one goroutine, and that goroutine is the only thing that touches the model, so models need no locks.
func (p *program) loop() { p.runCmd(p.model.Init()) // No unconditional first render: delivery starts when the page // activates (setDeliver sends a rerenderMsg), and rendering here // too would race it - the client would see two initial trees. for { select { case <-p.stop: return case msg := <-p.msgs: changed := p.apply(msg) // Coalesce: drain every already-queued message before // rendering, so a burst of rapid events produces ONE // render instead of hammering the frontend with a full // tree per click. drain: for { select { case more := <-p.msgs: if p.apply(more) { changed = true } default: break drain } } if changed { p.render() } } }}
After applying one messageL11 the loop drains everything already queuedL16-26 before rendering onceL27-29, so a burst of clicks becomes one frame. After the first full tree, Go diffs each new tree against the last one and sends a patch. Handler ids are built from a node's position plus the event name, so a node that didn't change keeps its id and doesn't need resending.
The repo's TestMeasureScaling changes one row and a counter in a list of n rows. The patch stays at 167 bytes from 10 rows to 32,000, while the full tree grows from 2.2 KB to 6.8 MB. Go spends about as long on a patch as on a full render, because diffing costs about as much as marshalling; the win is on the wire and in React, which only re-renders patched paths.
Live reload
gantry dev runs Vite and the Go app as two separate process groups, so Vite's hot reload keeps handling .tsx and .css while Go restarts. A polling watcher hashes the path, modification time and size of every .go file every 300 ms. On a change it kills the whole Go process tree, waits for it to exit (which frees the port, and with it the single-instance lock), regenerates the registry and starts it again. The websocket reconnects, re-announces its page, and the window comes back where it was.
The watcher must ignore the files it generates: when dynamic routes added a generated folder in v0.4.0, it saw its own output and rebuilt forever until v0.4.1 taught it to skip that folder. A failed build doesn't end gantry dev; it waits for the next save.
Building: one exe, or an APK without gomobile
gantry build runs vite build, embeds the output with //go:embed, and runs go build once per target. Windows builds cross-compile from Linux because the WebView2 binding is pure Go. A fresh scaffolded app built that way is one 8.5 MB exe, 256 KB of it frontend. Linux builds need cgo for GTK, so they happen on Linux.
Android is the part I am happiest with. There is no gomobile and no Java in the app. The Go server is cross-compiled as an ordinary executable but named like a shared library:
// Cross-compile the Go server (frontend embedded via webdist) into // jniLibs, one shared-library-named executable per ABI. for _, arch := range arches { abi, ok := androidABI[arch] if !ok { return "", false, fmt.Errorf("unknown android arch %q", arch) } out := filepath.Join(synthDir, "app", "src", "main", "jniLibs", abi, "libgantryapp.so") step("building android/%s (go)", arch) // 16 KB page-size devices (Android 15+ hardware) reject ELFs // whose LOAD segments are 4 KB-aligned; the NDK's clang only // aligns to 16 KB by default from r28, so pass it explicitly. cmd := exec.Command("go", "build", "-trimpath", "-ldflags", "-s -w "+versionLdflag(cfg)+" -extldflags=-Wl,-z,max-page-size=16384", "-o", out, ".") cmd.Dir = appDir cmd.Env = append(os.Environ(), "GOOS=android", "GOARCH="+arch, "CGO_ENABLED=1", "CC="+tools.clang(arch, cfg.Mobile.Android.MinSdk), ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return "", false, fmt.Errorf("go build android/%s failed: %w", arch, err) } }
Android unpacks anything under jniLibs/<abi>/L8 as an executable native library, so the generated Kotlin shell can start it as a child process. The max-page-size=16384 flagL10-14 is there because Android 15 devices with 16 KB pages refuse ELF files aligned to 4 KB. At runtime the shell starts the binary with --port 0 --announce-ready --token <random>, reads GANTRY_READY port=N from its stdout and points a full-screen WebView at that port. Notifications use the same pipe: Go prints a GANTRY_NOTIFY {...} line and the shell posts a real Android notification. The random token stops other apps on the phone from using the loopback server. The desktop server code runs unchanged; the cost is a child process to supervise, which the shell restarts with backoff.
History
Gantry came together in two bursts. v0.1.0 was tagged on 16 July 2026, the day I pulled it out of Timekeep, and v0.2.0 and v0.3.0 followed the same day: Linux parity, the one-call gantry.Run boot, the build system, and then Android (Gradle project generation, home-screen widgets, notifications) and the iOS scaffold. The next four days added the error UI, gantrytest (an end-to-end driver that runs the real app and attaches to its webview), Go live reload, dynamic routes, and an offline docs browser with an MCP server for coding agents. That was v0.4.1 on 20 July: 60 commits in five days.
The September burst was about running on more machines and running faster. v0.4.2 (2 September) added the Linux webview fork after the install failed on Fedora, and a capture-compatibility mode after an issue showed OBS recording the window as black, because WebView2 output is GPU-composited. The repo also moved from the B-Commissions organisation to my own account. v0.5.0 (5 September) added installable modules with JSON schemas, pprof, proper cache headers, lazy-loaded pages and the patch diffing above.
Outcome

Gantry does what I built it for. A new app is a short main.go, a folder of paired files, and one command to get an exe or an APK. The weather example is a complete app on it: a Go weather service fetches the forecast from Open-Meteo and saves the settings, and React draws it.
The honest gaps
- The desktop websocket isn't authenticated. It accepts any origin, and the token check only runs on Android. While writing this up I connected to a running app from a client claiming to be
https://evil.exampleand it answered a service call. A web page in the user's browser could probably do the same, and since the newest connection wins, it would also knock the app's own window off. The fix is to checkOriginagainst the app's own origin and use the token on desktop too. - No macOS. Mac builds fall back to the default browser, and iOS is a generated Xcode project that can't link the Go code yet.
- Single instance only refuses. A second launch exits instead of focusing the first window.
- The Tea diff is positional. Adding one item to a list replaces the whole list subtree, and a page's model keeps running after you leave the page.
- No signing or store packaging. Windows and Mac builds aren't code-signed, and Android builds produce an APK, not a Play Store bundle.
What's next is Apple support, which is the open issue now, alongside the websocket fix. I'm also moving Timekeep across to Gantry, because a real app will find problems that writing demo apps can't.