Desktop Apps
Desktop Apps: Wails, Fyne, and Gio
Overview
Go is a strong choice for cross-platform desktop tools: one language for business logic, solid concurrency, and easy shipping of a single binary (or a small bundle). The three main UI paths in 2026 are:
| Stack | UI model | Look | Binary size (order of mag.) | Best for |
|---|---|---|---|---|
| Wails | Native WebView + HTML/JS | OS-native web engine | ~10–30 MB | Dashboards, internal tools, SaaS companions |
| Fyne | Retained custom widgets (GL) | Consistent Material-ish | larger than pure CLI | Cross-platform + mobile, offline tools |
| Gio | Immediate-mode | Fully custom | small core | High-control UIs, specialized editors |
This chapter compares the stacks, shows idiomatic wiring, and covers packaging and pitfalls.
Choosing a Stack
Reuse web frontend (React/Svelte/Vue)? --> Wails
Want one UI on desktop + mobile? --> Fyne
Need game-like / fully custom drawing? --> Gio
Just a tray icon + menu? --> systray / small Fyne or Wails
Rule of thumb: If the product is a website in a window, Wails. If the product is a native-feeling tool without a web team, Fyne.
Wails — Electron Alternative Without Chromium
Wails uses the system WebView (WebKit on macOS, WebView2 on Windows, WebKitGTK on Linux). Backend is Go; frontend is any SPA stack. No bundled Chrome → much smaller than Electron.
Project shape
myapp/
main.go # Wails entry, app struct
app.go # Methods bound to JS
frontend/ # Vite + React/Svelte/Vue
wails.json
go.mod
Binding Go to JavaScript
package main
import (
"context"
"fmt"
)
// App holds application state for the UI bridge.
type App struct {
ctx context.Context
}
func NewApp() *App { return &App{} }
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
// Greet is callable from the frontend after Wails generates bindings.
func (a *App) Greet(name string) string {
if name == "" {
name = "world"
}
return fmt.Sprintf("Hello %s from Go", name)
}
// SavePrefs demonstrates returning errors across the bridge.
func (a *App) SavePrefs(theme string) error {
if theme != "light" && theme != "dark" {
return fmt.Errorf("invalid theme %q", theme)
}
// persist...
return nil
}Frontend (generated bindings):
import { Greet, SavePrefs } from "../wailsjs/go/main/App";
const msg = await Greet("Ada");
await SavePrefs("dark");Events both ways
Go can emit events the UI listens for (background jobs, tray actions):
import "github.com/wailsapp/wails/v2/pkg/runtime"
func (a *App) startWatcher() {
go func() {
// ...
runtime.EventsEmit(a.ctx, "job:done", map[string]any{
"id": "42",
"status": "ok",
})
}()
}import { EventsOn } from "../wailsjs/runtime/runtime";
EventsOn("job:done", (payload) => console.log(payload));Production notes for Wails
- Keep heavy work off the UI bind path; return quickly or stream progress via events.
- Never put secrets in the frontend bundle; call Go methods that read OS keychain/env.
- Test pure Go packages with ordinary
go test; treat UI as an integration layer. - Build:
wails buildproduces platform-native binaries; sign/notarize on macOS for Gatekeeper.
Fyne — Custom Widget Toolkit
Fyne draws its own controls via OpenGL (and related backends). Same UI code can target desktop and mobile.
package main
import (
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func main() {
a := app.New()
w := a.NewWindow("Counter")
n := 0
label := widget.NewLabel("Count: 0")
btn := widget.NewButton("Increment", func() {
n++
label.SetText("Count: " + itoa(n))
})
w.SetContent(container.NewVBox(label, btn))
w.Resize(fyne.NewSize(320, 160))
w.ShowAndRun()
}
func itoa(n int) string {
return fmt.Sprintf("%d", n)
}Form and validation sketch
entry := widget.NewEntry()
entry.SetPlaceHolder("email@example.com")
form := &widget.Form{
Items: []*widget.FormItem{
{Text: "Email", Widget: entry},
},
OnSubmit: func() {
if !strings.Contains(entry.Text, "@") {
// show dialog / error label
return
}
// save...
},
}Fyne production notes
- Asset bundling: use Fyne’s resource bundling for icons/fonts.
- Long tasks: run in goroutines; update widgets with
fyne.Do/ thread-safe APIs so you don’t touch UI from random goroutines incorrectly. - Packaging:
fyne packagefor.app/.exe/ mobile artifacts. - Look is consistent, not always native—set expectations with designers.
Gio — Immediate Mode
Gio rebuilds the UI every frame from state (game-engine style). Maximum control, more code.
Conceptual loop:
for each frame:
process input events
layout widgets from current state
draw
Use Gio when you are building something like a timeline editor, custom charting, or a specialized instrument UI—not for a settings form CRUD app.
Packaging and Distribution
| Platform | Notes |
|---|---|
| macOS | Codesign + notarize; hardened runtime; entitlements for network/files |
| Windows | Authenticode sign; WebView2 ever-green runtime for Wails |
| Linux | .tar.gz, .AppImage, or distro packages; WebKitGTK deps for Wails |
CI sketch:
# build desktop artifacts on tags (matrix os)
- run: wails build -platform darwin/arm64
- run: wails build -platform windows/amd64Embed version the same way as CLI apps:
go build -ldflags="-X main.version=${VERSION}"Security Considerations
Desktop apps inherit a large attack surface: local files, IPC, and web content.
- Validate all UI input in Go, not only in JS.
- Disable dangerous WebView features you do not need (arbitrary file URLs, remote debugging in prod).
- Path traversal — if the UI asks to “open file”, resolve and confine to a sandbox directory.
- Auto-update — verify signatures (Cosign, Sparcle-like flows, or platform stores).
- Least privilege — macOS entitlements and Windows manifests should not request admin by default.
func safeOpen(root, userPath string) (*os.File, error) {
clean := filepath.Clean(userPath)
full := filepath.Join(root, clean)
if !strings.HasPrefix(full, filepath.Clean(root)+string(os.PathSeparator)) {
return nil, fmt.Errorf("path escapes root")
}
return os.Open(full)
}Production Checklist
- Domain logic in plain Go packages with tests
- UI layer is a thin adapter (Wails methods / Fyne callbacks)
- Long work off the UI thread; progress via events or bindings
- Secrets never shipped in frontend assets
- Signed builds on macOS/Windows for distribution outside stores
- Version embedded; about dialog shows it
- Crash logging to a user-visible file or OS log
- Document runtime deps (WebView2, graphics drivers)
Common Pitfalls
- Business logic in React only — duplicates rules; desktop and future CLI drift.
- Blocking the UI goroutine — multi-second HTTP inside a button callback freezes Fyne/Wails.
- Assuming Electron mental model — Wails does not bundle Chromium; Linux WebKitGTK missing breaks installs.
- CGO surprises — Fyne/Gio may pull CGO; cross-compile carefully.
- No code signing — macOS quarantines unsigned apps; users see scary dialogs.
- Shipping debug tooling — open DevTools in production builds.
Exercises
- Wails hello — Scaffold a Wails app; bind a Go
Add(a, b int) intand call it from the frontend. - Fyne counter — Build the counter window; add a Reset button and a keyboard shortcut.
- Extract service — Move greet logic into an internal package; unit-test it without UI.
- Safe paths — Implement
safeOpenwith table-driven tests for../escapes. - Release metadata — Inject
versionvia-ldflagsand show it in a Fyne label or Wails about dialog. - Compare sizes — Build a minimal Wails and minimal Fyne app; record binary/bundle sizes on your OS.
More examples
UI-free domain service (testable core)
Desktop shells should call pure logic. This is the Go core you unit-test without Fyne/Wails.
mkdir -p /tmp/go-desk-core && cd /tmp/go-desk-core
go mod init example.com/desk-coreSave as main.go:
package main
import (
"fmt"
"strings"
)
type Greeter struct {
prefix string
}
func (g Greeter) Greet(name string) string {
name = strings.TrimSpace(name)
if name == "" {
name = "world"
}
return g.prefix + name
}
func main() {
g := Greeter{prefix: "Hello, "}
fmt.Println(g.Greet("Ada"))
fmt.Println(g.Greet(" "))
}go run .Expected output:
Hello, Ada
Hello, world
Safe open under app data root
mkdir -p /tmp/go-desk-safe && cd /tmp/go-desk-safe
go mod init example.com/desk-safeSave as main.go:
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func safeOpen(root, rel string) ([]byte, error) {
full := filepath.Join(root, filepath.Clean("/"+rel))
relOut, err := filepath.Rel(root, full)
if err != nil || strings.HasPrefix(relOut, "..") {
return nil, fmt.Errorf("denied: %s", rel)
}
return os.ReadFile(full)
}
func main() {
root, _ := os.MkdirTemp("", "deskdata")
defer os.RemoveAll(root)
_ = os.WriteFile(filepath.Join(root, "note.txt"), []byte("secret-note"), 0o600)
b, err := safeOpen(root, "note.txt")
fmt.Println("ok:", string(b), err)
_, err = safeOpen(root, "../note.txt")
fmt.Println("escape err:", err != nil)
}go run .Expected output:
ok: secret-note <nil>
escape err: true
Runnable example
Desktop frameworks (Wails/Fyne/Gio) are not stdlib; the durable part is domain logic outside the UI. This CLI-shaped program is the service layer you unit-test without a window toolkit—bind the same functions from Wails or call them from Fyne callbacks.
mkdir -p /tmp/go-desktop-core && cd /tmp/go-desktop-core
go mod init example.com/desktop-coreSave as main.go:
package main
import (
"fmt"
"path/filepath"
"strings"
)
// App is what a Wails struct or Fyne model should call—no UI imports.
type App struct {
version string
count int
}
func NewApp(version string) *App { return &App{version: version} }
func (a *App) Greet(name string) string {
name = strings.TrimSpace(name)
if name == "" {
name = "world"
}
return fmt.Sprintf("Hello, %s! (v%s)", name, a.version)
}
func (a *App) Add(delta int) int {
a.count += delta
return a.count
}
func (a *App) Reset() { a.count = 0 }
// safeOpen jails user-selected relative paths under root (file dialogs).
func safeOpen(root, userPath string) (string, error) {
cleanRoot, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return "", err
}
joined := filepath.Join(cleanRoot, userPath)
abs, err := filepath.Abs(joined)
if err != nil {
return "", err
}
rel, err := filepath.Rel(cleanRoot, abs)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("path escapes root: %s", userPath)
}
return abs, nil
}
func main() {
app := NewApp("1.0.0")
fmt.Println(app.Greet("Ada"))
fmt.Println("count:", app.Add(1))
fmt.Println("count:", app.Add(2))
app.Reset()
fmt.Println("after reset:", app.Add(0))
root := "/tmp/desktop-docs"
ok, err := safeOpen(root, "notes/todo.txt")
fmt.Println("safe path:", ok, err)
_, err = safeOpen(root, "../../etc/passwd")
fmt.Println("escape blocked:", err != nil)
}go run .Expected output (paths host-dependent):
Hello, Ada! (v1.0.0)
count: 1
count: 3
after reset: 0
safe path: /tmp/desktop-docs/notes/todo.txt <nil>
escape blocked: true
What to notice
- UI toolkits bind methods; keep business rules in plain Go packages so tests do not need a display server.
- Path validation belongs in Go, not only in the frontend file picker.
- Inject
versionvia ldflags the same way as CLI/server releases.
Try next
- Move
Apptointernal/appand add table tests forGreetandsafeOpen. - Scaffold a Wails or Fyne shell that calls the same
Greet/Addmethods.