Skip to content

Desktop apps

The live protocol does not change. Go still renders elements, patches props over SSE, and receives component events over POST. app is a host for that model: the same http.Handler listens on loopback, and the operating system’s webview (WKWebView, WebView2, WebKitGTK) is the client.

There is no JavaScript binding layer and no generated TypeScript. Native capabilities are Go function calls, usually from a live.On handler or a menu callback.

app.Run(app.Options{
Title: "Board",
Width: 1100,
Height: 800,
Handler: mux, // the mux a browser build would ListenAndServe
Menu: app.DefaultMenu(),
Identifier: "com.example.board",
})

The example board is that handler with a flag:

Terminal window
go run -tags desktop ./examples/todo -desktop

Without -tags desktop, Run returns an error that names the tag. Listen does not need it: it is ordinary HTTP, and that is what the tests exercise.

import "github.com/bmartel/alacris-go/app"

It has its own go.mod. The root module still depends on templ and nothing else; CGO and the webview library live only here. A project that never opens a window never sees them.

Releases tag app/vX.Y.Z at the same commit as vX.Y.Z. go get that version, not v0.0.0. A replace in this repository’s go.mod is ignored when the module is a dependency.

go get github.com/bmartel/alacris-go
go get github.com/bmartel/alacris-go/app

The host is still plain HTTP: EventSource only speaks HTTP family URLs, so a custom alacris:// scheme would break live.js. Set CookieSecure: live.SecureNever on the live server you pass to that handler. SecureAuto omits Secure on non-TLS already; naming it keeps a later TLS experiment from dropping the cookie.

The live cookie still authorises the page. Do not drop it because this is a desktop build.

Open / Run bind 127.0.0.1 (never 0.0.0.0) and reject a Host header that is not that address. They also issue a random host token: the webview navigates to a one-shot query parameter, the gate swaps it for an HttpOnly cookie and redirects, and every later request (including EventSource) must present that cookie. Another process on the machine that can open a TCP connection to the port cannot create a live session.

Listen does not install the gate. It is the testable half of the host.

live.On(srv, "export", func(c *live.Ctx, d struct{}) error {
path, err := app.SaveFile(c.Context(), app.FileFilter{Name: "JSON", Ext: ".json"})
if err != nil {
return err
}
return os.WriteFile(path, payload, 0o644)
})
SurfaceAPI
WindowRun, Options, NewWindow. SetTitle, SetSize, SetPosition, Center, Minimize, Maximize, Fullscreen, Show, Hide, Focus, SetAlwaysOnTop, SetDecorations, SetBadge, Close.
MenusMenu, MenuItem, DefaultMenu, EditMenu. Do is a Go callback. Roles (RoleQuit, RoleCopy, …) map to the first responder. Installed on macOS, Windows, and Linux.
TrayOptions.Tray with a Menu.
DialogsOpenFile, SaveFile, SaveAs, Message, Confirm. ErrCanceled means the user dismissed the dialog.
FilesThe Go standard library. There is no JS fs plugin. DataDir / CacheDir are identifier-scoped.
ClipboardWriteClipboard, ReadClipboard.
NotificationsNotify.
Open / revealOpenURL, RevealInFileManager.
ShortcutsWindow.RegisterShortcut (global; macOS and Windows).
Single instanceOptions.SingleInstance plus Identifier. Second launch returns ErrAlreadyRunning and forwards args.
Deep linksOptions.DeepLinkScheme and OnDeepLink. The bundler writes CFBundleURLTypes / .desktop MimeType.
UpdatesUpdater.Check / Apply / ApplyAndRelaunch. ed25519 over the artifact bytes. Relaunch execs the new file; on Windows a locked exe is parked as .new and swapped then.

Do not add window.__TAURI__ or a Wails-style invoke. A component that needs the filesystem emits an event; Go does the rest.

alacris.app.json is bundle metadata (name, identifier, icon, optional deepLinkScheme). Window size and live wiring stay in Go.

Terminal window
alacris-go app init -name Board -id com.example.board
go get github.com/bmartel/alacris-go@latest github.com/bmartel/alacris-go/app@latest
go generate ./...
alacris-go app dev
alacris-go app build -o dist
alacris-go app info

init writes a desktop-first project: app.Run, a templ page, web/components.js, and the JSON file. It is not a Vite app.

dev is go run -tags desktop, and it rebuilds when .go, .templ, or .js change. Pass -watch=false for a single run.

build compiles with that tag, wraps the binary (.app / .desktop+nfpm.yaml / .exe+winres.json), writes icons from a PNG, and runs codesign, hdiutil, nfpm, or go-winres when those tools are on PATH. Set bundle.macOS.signingIdentity to codesign. Notarize with ALACRIS_NOTARY_PROFILE pointing at a notarytool keychain profile. Cross-compiling CGO (Linux → macOS) is not supported; build on the target OS or a CI matrix.

The endpoint serves a JSON manifest. The signature is ed25519 over the artifact bytes, base64-encoded, same idea as Tauri’s updater so a GitHub Release can host one file:

{
"version": "0.2.0",
"notes": "",
"platforms": {
"darwin-arm64": {
"url": "https://github.com/org/app/releases/download/v0.2.0/Board_darwin-arm64.tar.gz",
"signature": "<base64>"
}
}
}

The map key is GOOS-GOARCH (darwin-arm64, windows-amd64, linux-amd64). Embed the public key in the app; keep the private key off the machine that builds nothing.

upd, err := app.Updater{
Endpoint: "https://example/latest.json",
PublicKey: pub,
Current: version,
}.Check(ctx)
if upd != nil {
_ = updater.ApplyAndRelaunch(ctx, upd)
}

Apply replaces the executable (on Windows, writes app.exe.new when the running file is locked). ApplyAndRelaunch then execs the new file. Run calls FinishPendingUpdate so a parked .new is applied if a previous relaunch did not finish.

Default go test ./...Does not enter app/. Root stays free of webkit.
cd app && go test ./...Host, dialogs, updater, host token. No display.
-tags desktopLinks the OS webview. Needs CGO, Xcode CLT / WebView2 / libwebkit2gtk.