Desktop Apps

Updated

July 30, 2026

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 build produces 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 package for .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.

Shared Backend Pattern

Regardless of UI kit, keep domain logic UI-free:

package greeter

type Service struct {
    // deps: db, config, http client
}

func (s *Service) Greet(name string) (string, error) {
    if name == "" {
        return "", fmt.Errorf("name required")
    }
    return "Hello " + name, nil
}
// Wails adapter
func (a *App) Greet(name string) (string, error) {
    return a.svc.Greet(name)
}
// Fyne adapter inside button callback
text, err := svc.Greet(entry.Text)

Unit-test greeter.Service without starting a window.

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/amd64

Embed 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.

  1. Validate all UI input in Go, not only in JS.
  2. Disable dangerous WebView features you do not need (arbitrary file URLs, remote debugging in prod).
  3. Path traversal — if the UI asks to “open file”, resolve and confine to a sandbox directory.
  4. Auto-update — verify signatures (Cosign, Sparcle-like flows, or platform stores).
  5. 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

  1. Business logic in React only — duplicates rules; desktop and future CLI drift.
  2. Blocking the UI goroutine — multi-second HTTP inside a button callback freezes Fyne/Wails.
  3. Assuming Electron mental model — Wails does not bundle Chromium; Linux WebKitGTK missing breaks installs.
  4. CGO surprises — Fyne/Gio may pull CGO; cross-compile carefully.
  5. No code signing — macOS quarantines unsigned apps; users see scary dialogs.
  6. Shipping debug tooling — open DevTools in production builds.

Exercises

  1. Wails hello — Scaffold a Wails app; bind a Go Add(a, b int) int and call it from the frontend.
  2. Fyne counter — Build the counter window; add a Reset button and a keyboard shortcut.
  3. Extract service — Move greet logic into an internal package; unit-test it without UI.
  4. Safe paths — Implement safeOpen with table-driven tests for ../ escapes.
  5. Release metadata — Inject version via -ldflags and show it in a Fyne label or Wails about dialog.
  6. 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-core

Save 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-safe

Save 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-core

Save 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 version via ldflags the same way as CLI/server releases.

Try next

  • Move App to internal/app and add table tests for Greet and safeOpen.
  • Scaffold a Wails or Fyne shell that calls the same Greet/Add methods.