Build desktop apps with a Racket backend and a web frontend. A Tauri-like framework for Racket — write your app logic in Racket, build your UI with HTML/CSS/JS, and ship a desktop application.
English · 中文
Racket's racket/gui works but is hard to style into a modern product-grade UI. Glaze takes a different approach: serve a local web app from Racket and display it in the system browser (Phase 1) or an embedded WebView (Phase 3).
You get:
- Racket for logic — the full power of Racket's macro system, contracts, pattern matching
- Web for UI — Tailwind, Svelte, React, or any web framework
- JSON API bridge — the page calls Racket with plain
fetch("/api/...")
| Glaze | Tauri | Electron | wails | |
|---|---|---|---|---|
| Backend language | Racket | Rust | JS/Node | Go |
| Native toolchain needed | none (pure FFI) | Rust + cargo | none | Go + WebView2 deps |
| Binary size | tiny (Racket exe + assets) | small | 100 MB+ | small |
| Frontend→backend | HTTP JSON routes (fetch) |
invoke() IPC |
Node APIs | bindings |
| Works without webview (browser fallback) | yes | no | no | no |
Agent-friendly UI verification (title/url/screenshot) |
built-in | via WebDriver | via CDP | limited |
| WebView backends | WebView2 / WKWebView / WebKitGTK | same | bundled Chromium | WebView2/WKWebView |
All three webview backends pass the real-window CI e2e (open, load, capture, navigate, close, on-close). Remaining honest gaps: no typed IPC layer (plain JSON), Linux needs a desktop session or Xvfb.
| Capability | macOS | Windows | Linux |
|---|---|---|---|
| HTTP server + browser | ✅ | ✅ | ✅ |
| System tray | ✅ | ✅ | ✅ (CI-verified) |
| JSON API bridge | ✅ | ✅ | ✅ |
| Native webview window | ✅ verified end-to-end | ✅ CI e2e (WebView2) | ✅ CI e2e (Xvfb + WebKitGTK) |
webview-title / webview-url |
✅ | ✅ | ✅ |
webview-capture! (screenshot) |
✅ | ✅ (PrintWindow + PowerShell PNG) | ✅ (gdk_pixbuf) |
#:devtools? |
✅ (inspectable, macOS 13+) | ✅ (OpenDevToolsWindow) |
🔲 |
Without a native backend, run-app / open-window automatically fall back to the system browser — the app still works everywhere.
| Dependency | Purpose |
|---|---|
| Racket | 7.0 or later (includes raco) |
git clone https://github.com/turinglambdaai/glaze.git
cd glazeraco pkg install glazeraco glaze init myapp
cd myappracket main.rktYour browser opens to http://127.0.0.1:8080 with a working page.
raco glaze init <name> # Create a new Glaze project
raco glaze dev # Start dev server with auto-open browser
raco glaze build # Build a distributable (exe + bundled assets)
raco glaze help # Show helpPackage a Glaze project into a platform distribution (raco exe + raco distribute) with the frontend assets bundled alongside the executable.
raco glaze build --name myapp # produces dist/myapp(.exe) + dist/lib + dist/public
raco glaze build --name myapp --installer # also produce msi / dmg / AppImage (or zip/tar.gz fallback)Options: --name, --icon <.ico/.icns>, --entry <path> (default main.rkt), --out <dir> (default dist), --embed-dlls (Windows: single-file exe), --installer.
The installer step probes for the native toolchain (WiX / NSIS on Windows,
create-dmg/hdiutilon macOS,appimagetool/linuxdeployon Linux) and degrades gracefully to a.zip/.tar.gzwhen it's absent, printing a warning naming what to install.
A new Glaze project looks like this:
myapp/
├── main.rkt # Racket entry point
└── public/
└── index.html # Frontend
main.rkt starts a local HTTP server serving files from public/ and opens the browser:
#lang racket/base
(require glaze)
(define-values (port server)
(start-dev-server #:public-dir "public"))
(printf "Glaze app running at http://127.0.0.1:~a\n" port)
(open-browser (format "http://127.0.0.1:~a" port))
(with-handlers ([exn:break?
(lambda (e)
(stop-server server)
(printf "Server stopped.\n"))])
(sync never-evt))glaze/
├── glaze-lib/ # Core library (server, API, browser launcher, assets)
├── glaze-cli/ # CLI tool (raco glaze init / dev)
├── glaze-doc/ # Documentation (Scribble)
├── glaze-test/ # Tests
└── info.rkt # Multi-package root
The one-call entry: picks a free port, starts the server (static + JSON API), opens the native webview window, and blocks until the window closes.
(run-app #:public-dir "public"
#:api (list (GET "api/ping" ...)))
;; webview path: window closed -> server stopped -> (values 'webview shutdown)
;; browser fallback (no native backend): opens browser -> (values 'browser shutdown)Starts a local HTTP server serving static files with SPA fallback, plus optional JSON API routes. start-dev-server is a backward-compatible alias.
(start-server #:port 8080
#:public-dir "public"
#:api (list (GET "api/ping" (lambda (req) (hasheq 'pong #t)))))
;; Returns (values port shutdown-proc); verifies the listener is accepting
;; before returning.Stops the server.
(stop-server shutdown-proc)Opens a URL in the system default browser (cross-platform: Windows, macOS, Linux).
(open-browser "http://127.0.0.1:8080")The frontend calls Racket with plain fetch("/api/...") — Glaze's answer to Tauri's invoke(). One code path works in the embedded WebView, in the system-browser fallback, and in dev (curl-able). Routes are ordinary values:
(require glaze)
(GET "api/ping" (lambda (req) (hasheq 'pong #t)))
(POST "api/items/:id/bump" (lambda (req id) (hasheq 'id id 'bumped #t)))
(POST "api/echo" (lambda (req)
(define body (request-json-body req))
(hasheq 'echo body)))- Handlers take the request plus captured
:params; return a jsexpr (auto-wrapped as JSON 200) or a full response. request-json-bodyparses the JSON body — note Racket jsexpr parses JSON object keys as symbols ((hash-ref body 'delta)).- A handler that raises becomes a 500 JSON error, never a broken connection.
- Unmatched requests fall through to static files (SPA
index.htmlfallback).
In the page:
const s = await fetch('/api/counter/bump',
{method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({delta: 5})}).then(r => r.json());(define-api-routes api
[(POST "api/counter/bump")
(bump [delta exact-nonnegative-integer? 1]) ; required, checked, or default
(hasheq 'count (add1 delta))])One clause defines a Racket procedure (bump), a route (bad input → a 400
naming the parameter; handler errors → 500), and a JS client entry — the
served /glaze/api.js exposes glaze.api.counterBump({delta: 5}), plus
glaze.call(method, path, body) and glaze.on(name, fn).
(define bus (make-event-bus))
(start-server ... #:events bus)
(bus-broadcast! bus 'count-changed (hasheq 'count 42)) ; from any threadglaze.on('count-changed', s => render(s.count));The page can also use new EventSource('/glaze/events') directly. Works in
the browser fallback too — same origin, no extra port.
- Requests are only served for Host headers
127.0.0.1/localhost/[::1](DNS-rebinding guard; hostile origins get 403). - API handlers never crash the connection — parameter problems are 400
JSON, handler exceptions are 500 JSON (and reach
run-app's#:on-errorfor crash reporting hooks). - Optional API token (
#:api-token): guards API routes and the SSE stream (401 otherwise); the generated api.js bootstraps aglaze_tokencookie so pages work unmodified, programmatic clients sendX-Glaze-Token. Honest scope: defense-in-depth against casual local callers — full local-process isolation is not achievable over plain HTTP. - Update checks:
run-app #:check-update <manifest-url> #:current-version "1.0.0"fetches{"version","url","notes"}, reports to stderr and broadcastsupdate-available. Self-replacement stays the app's decision.
See examples/counter/ for the complete working app.
(require glaze/sys)
(clipboard-set! "hello") ; (clipboard-get)
(notify! "Download finished" "report.pdf is ready")
(open-path "/Users/me/report.pdf") ; default handler
(reveal-path "/Users/me/report.pdf"); Finder/Explorer, selected
(unless (single-instance? "com.me.app") (exit 0))Window controls (from glaze/webview): webview-set-title!,
webview-set-size!, webview-set-fullscreen!.
Glaze provides a cross-platform system tray so your app can live in the notification area / menu bar with a right-click (or left-click on macOS) menu. The backend is chosen by platform — pure Racket FFI, no native compilation required:
- Windows —
Shell_NotifyIconWviaffi/unsafe - macOS —
NSStatusItem/NSMenuviaffi/unsafe/objc - Linux —
libayatana-appindicator+libgtk-3viaffi/unsafe
If a platform's native libraries aren't available at runtime, the tray silently degrades to a no-op so the rest of the app keeps working.
(require glaze)
(define t
(make-tray #:icon #f
#:tooltip "My Glaze App"
#:menu (list (make-menu-item "Quit"
#:action (lambda () (exit 0))))))
(tray-set-tooltip! t "running")
;; ...later
(tray-close t)macOS note: a pure menu-bar app (no Dock icon) requires building as an
.appbundle withLSUIElementset —raco glaze buildconfigures this for you.
| Example | What it shows |
|---|---|
examples/showcase/ |
Kitchen sink (start here) — every capability in one window |
examples/hello/ |
Minimal app — run-app in 8 lines |
examples/counter/ |
JS↔Racket bridge — fetch calls Racket state |
examples/webview-demo.rkt |
Webview lifecycle: load, navigate, close, verification APIs |
examples/agent-verify.rkt |
Agent workflow: assert page state + screenshot with no human |
examples/tray-demo.rkt |
Cross-platform system tray with a working menu |
- Phase 1 — Local HTTP server + system browser
- Phase 2 — Frontend asset bundling, system tray, app packaging
- Phase 3 — Native WebView embedding (WebView2 / WKWebView / WebKitGTK) — done, verified by the 3-OS CI e2e
Phase 3 done: all three backends (macOS WKWebView, Windows WebView2, Linux WebKitGTK) pass the real-window CI e2e — open, page load,
webview-title/urlverification,webview-capture!screenshots,webview-navigate, close (programmatic and OS chrome), and#:on-closecallbacks;#:devtools?on macOS/Windows. Pure Racket FFI throughout, no compiler. Remaining polish (not blockers): Linux#:devtools?, Windows resize-follow (put_Boundsis set once, not on WM_SIZE), multi-window ergonomics.
Licensed under the MIT License.