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) |
✅ (WebKitGTK inspector) |
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) |
raco pkg install --auto glazeInstalling from the Racket package catalog pulls in the library, the raco glaze CLI, and the documentation (browse it later with raco docs).
raco glaze init myapp
cd myappracket main.rktA native window opens showing your app served from a local HTTP server; without a WebView backend it falls back to the system browser at http://127.0.0.1:<port>.
Prefer installing straight from a GitHub checkout instead of the catalog?
git clone https://github.com/turinglambdaai/glaze.git cd glaze raco pkg install --auto --link ./glaze-lib ./glaze-cli ./glaze-docTo work on Glaze itself, see CONTRIBUTING.md.
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/ # Umbrella package (install `glaze` to get everything)
├── glaze-lib/ # Core library (server, API, browser launcher, assets)
├── glaze-cli/ # CLI tool (raco glaze init / dev / build)
├── glaze-doc/ # Documentation (Scribble)
└── glaze-test/ # Tests
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 app window opens a one-time?glaze-token=bootstrap URL that exchanges the token for anHttpOnlycookie (api.js deliberately hands out nothing); programmatic clients sendX-Glaze-Token. Honest scope: defense-in-depth against casual local callers — a process of the same user can still read the token from process memory. - 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))Desktop notifications work on all three platforms (osascript / notify-send / WinRT toast via PowerShell).
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?and resize-follow on all three platforms. Pure Racket FFI throughout, no compiler. Remaining polish (not a blocker): multi-window ergonomics.
Licensed under the MIT License.
