Help us shape the future of Go! Take the 2026 Go Developer Survey

vero

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: MIT Imports: 18 Imported by: 0

README

vero

Desktop applications whose logic is written once, in Go, and whose interface is the platform's own toolkit.

Every other way of building a desktop app in Go asks you to give something up. Fyne and Gio draw their own widgets, so the result looks identical everywhere and native nowhere. Wails and Lorca wrap a webview, which is Electron's tradeoff in a smaller binary. Walk is Windows only.

vero takes the fourth path. The interface is genuinely SwiftUI, or WPF, or GTK — real controls, real menus, real notifications — and Go supplies everything behind it.


macOS — SwiftUI, in the menu bar
Windows — WPF
Linux — GTK4

All three are the same Go worker. Only the drawing differs — and all three are recordings of the examples in this repository actually running: one on a Mac, one on Windows 11 in a VM, and one under Xvfb in a container.

Everything you can see is a real control drawn by the platform. The icon on the left of each row is a button, not a picture of one: it hovers, takes focus and answers the keyboard the way a button on that platform does, because it is one. Nothing is polling either — the progress moving is the worker pushing an event each time its state changes.

   SwiftUI           WPF             GTK
  ┌─────────┐    ┌─────────┐    ┌─────────┐
  │  macOS  │    │ Windows │    │  Linux  │   the platform's own controls
  └────┬────┘    └────┬────┘    └────┬────┘
       └──────────────┼──────────────┘
                 ┌────┴────┐
                 │ your Go │                 one implementation
                 └─────────┘

If you already have Xcode and Go installed, the example below takes about two minutes and gives you a running app.

Example

Setup

Create a new macOS SwiftUI Xcode project, then:

  • File -> Add Package Dependencies... -> https://github.com/calmdocs/vero
Build the worker and the archive
git clone https://github.com/calmdocs/vero
cd vero

# the worker: a plain go binary, universal
cd example/worker
GOOS=darwin GOARCH=amd64 go build -o worker-amd64 && \
GOOS=darwin GOARCH=arm64 go build -o worker-arm64 && \
lipo -create worker-amd64 worker-arm64 -output worker

# the archive your app links: the same go code, built as a C archive
cd ../..
CGO_ENABLED=1 GOARCH=amd64 CC="clang -arch x86_64" \
    go build -buildmode=c-archive -o libvero-amd64.a ./cshim && \
CGO_ENABLED=1 GOARCH=arm64 \
    go build -buildmode=c-archive -o libvero-arm64.a ./cshim && \
lipo -create libvero-amd64.a libvero-arm64.a -output libvero.a

Drag example/worker/worker and libvero.a into your Xcode project. Dragging the archive in links it; the worker is a resource, and vero copies it out and runs it for you.

In the new Xcode project, replace ContentView.swift with the following code:
import SwiftUI
import Vero

// What the worker sends us, and what we send back. The name on RestartJob is
// the handler it is routed to - it matches vero.Handle in the go worker.
struct Job: Decodable, Identifiable {
    let id: Int
    let name: String
    let phase: String
    let progress: Int
}

struct Status: Decodable {
    let jobs: [Job]
}

struct RestartJob: NamedRequest {
    static let name = "restartJob"
    typealias Reply = Status
    let id: Int
}

struct ContentView: View {
    @StateObject private var model = Model()

    var body: some View {
        List(model.jobs) { job in
            HStack {
                Text(job.name)
                Text(job.phase).foregroundStyle(.secondary)
                ProgressView(value: Double(job.progress) / 100)
                Button {
                    model.restart(job)
                } label: {
                    Image(systemName: "arrow.clockwise")
                }
                .disabled(model.worker?.isBusy ?? true)
            }
        }
        .onAppear { model.start() }
    }
}

@MainActor
final class Model: ObservableObject {
    @Published var jobs: [Job] = []
    @Published private(set) var worker: VeroClient?

    func start() {
        guard worker == nil else { return }
        do {
            // Copies the worker out of the app bundle, launches it, restarts
            // it if it dies, and stops it when this app exits.
            let worker = try VeroClient(
                bundledWorker: "worker", directoryName: "Example/bin")

            // Pushed the instant the go side changes, already on the main
            // actor, so it can go straight into published state.
            worker.onEvent(Status.self) { [weak self] status in
                self?.jobs = status.jobs
            }
            if let status = worker.latest(Status.self) { jobs = status.jobs }

            self.worker = worker
        } catch {
            print("could not start the worker:", error.localizedDescription)
        }
    }

    func restart(_ job: Job) {
        worker?.call(RestartJob(id: job.id))
    }
}

Run it. Three jobs appear and their progress moves, pushed from Go as it changes - nothing polls. The button sends a request back.

The go side

The worker is example/worker/main.go, and this is all of the interface to Swift:

r := vero.NewRouter()

vero.Handle(r, "restartJob", func(_ context.Context, req RestartJob) (Status, error) {
    return restart(req.ID)
})

// Push an event when the state changes, not on a timer.
go w.EmitOnChange(ctx, 100*time.Millisecond, func() any { return snapshot() })

w.Serve(r)

Returning an error from a handler is not a crash: it arrives in Swift as VeroError.refused, with the worker still running.

Windows and Linux

The same worker, behind WPF and GTK4. Each example is small and has a README.

You can build and run all three from the Mac:

./scripts/setup.sh                                   # toolchains, then build
./scripts/run.sh --iso ~/Downloads/win11.iso         # opens all three
script
scripts/setup.sh installs whatever toolchain is missing, then builds everything
scripts/run.sh opens the example on all three at once
scripts/build-all.sh every artefact, for all three, into dist/
scripts/release.sh one archive per platform, plus checksums
scripts/run-linux.sh the GTK example in a window on your Mac
scripts/run-windows.sh a Windows VM with your build on a disc

More

Tests

go test -race ./...
cd bindings/python && python3 -m unittest
swift build

The Go tests run the test binary as their own worker, so the worker really is a separate process: crashes, restarts, concurrent requests and the lifecycle are all exercised against a real one. The Python tests build the shared library and the example worker and drive the whole stack through ctypes.

Licence

MIT

Documentation

Overview

Package vero builds desktop applications whose logic is written once, in Go, and whose interface is the platform's own toolkit - SwiftUI on macOS, WinUI on Windows, GTK on Linux.

The Go GUI field splits three ways: Fyne and Gio draw their own widgets, so the result looks the same everywhere and native nowhere; Wails and Lorca wrap a webview, which is Electron's tradeoff with a smaller binary; Walk is Windows only. vero takes the fourth path. The interface is genuinely the system's, and Go supplies everything behind it.

The shape of a vero application

Your logic lives in a worker: an ordinary Go program that either runs on its own, as a daemon on a headless server, or is launched by a GUI. The same binary does both, and it does not know which it is doing beyond one flag.

worker  ->  your logic; runs headless, or behind an interface
        ->  a Supervisor launches it, restarts it, and talks to it
        ->  a native interface drives the Supervisor through five C functions

The channel between the interface and the worker is the pipes the operating system already gave you when the supervisor launched it. That is not a smaller version of a socket, it is a different guarantee: there is no filesystem object, so nothing else on the machine can connect even in principle, and the channel is destroyed with the process. It also means a worker cannot outlive the interface that started it - when the application quits, or crashes, or is force quit, the worker's standard input closes and it exits, with no PID file and no heartbeat to get wrong.

Crashes

The worker is a separate process, so a panic in it - or one of Go's unrecoverable runtime failures, like a concurrent map write, which recover cannot catch - takes down the worker and not your interface. The supervisor notices, reports it, and starts a new one.

The wire format

Newline delimited JSON, in both directions. Your own message travels in "p", untouched:

to the worker     {"id":7,"p":<your request>}
from the worker   {"t":"reply","id":7,"p":<your reply>}
                  {"t":"reply","id":7,"e":"what went wrong"}
                  {"t":"event","p":<your event>}

Events are unsolicited: the worker sends one whenever its state moves, so an interface never polls and never waits on a timer. Replies are matched to requests by id, so a slow request cannot hold up the events behind it.

Standard output carries this stream and nothing else. Logs go to standard error, always. A worker that no one is watching writes neither.

Index

Constants

View Source
const MaxLineSize = 16 << 20 // 16 MiB

MaxLineSize caps one protocol line, so a corrupt or hostile stream cannot ask for an unbounded allocation. Payloads larger than this belong in a file with the path sent through the pipe.

Variables

View Source
var ErrAlreadyRunning = errors.New("a worker is already running for this application")

ErrAlreadyRunning is returned when another process is already supervising this worker.

Two copies of an application - the one in /Applications and the one still in ~/Downloads after an update - are two processes, and without this they would each start a worker and both would work on the same state. One window too many is a nuisance; two workers is corruption.

View Source
var ErrWorkerNotRunning = errors.New("vero: the worker is not running")

ErrWorkerNotRunning is returned by Request when the worker has died and has not been restarted yet. It is worth distinguishing in an interface: it means wait, not that anything you did was wrong.

View Source
var ShutdownGrace = 5 * time.Second

ShutdownGrace is how long Run waits for handlers to notice that the interface has gone before exiting regardless.

Functions

func Handle

func Handle[Req any, Rep any](r *Router, name string, fn func(ctx context.Context, request Req) (Rep, error))

Handle registers a handler for one request name.

Req is what the request decodes into and Rep is what the reply is encoded from, so neither the handler nor the caller has to guess. Use struct{} for a request that carries nothing.

It is a function rather than a method because Go does not allow methods to introduce type parameters.

Types

type Envelope

type Envelope struct {
	// Kind is "event" or "reply" on the way out of a worker, and empty on the
	// way in, where every line is a request.
	Kind string `json:"t,omitempty"`

	// ID matches a reply to the request that caused it.  Events have none.
	ID uint64 `json:"id,omitempty"`

	// Name routes a request to a handler, when a Router is used.  Empty when
	// the worker has a single handler that dispatches for itself.
	//
	// It lives here rather than inside the payload so that vero can route
	// without knowing anything about the message, and so an application is
	// not obliged to carry a type field in every request it defines.
	Name string `json:"n,omitempty"`

	// Payload is your own message, carried through untouched.
	Payload json.RawMessage `json:"p,omitempty"`

	// Error is set instead of Payload when a handler returned an error.
	Error string `json:"e,omitempty"`
}

Envelope is one line of the protocol. Everything is optional except what the message kind requires, so the same struct decodes in both directions.

type Handler

type Handler func(ctx context.Context, request json.RawMessage) (reply any, err error)

Handler answers one request.

ctx is cancelled when the interface goes away, so a handler that waits - a long poll, most obviously - must select on ctx.Done() as well. Without that the worker cannot exit when its parent does: it has stopped reading requests, but it is still waiting for the answer to one nobody will read. Returning an error sends the message to the caller rather than closing anything: the interface can then tell "the worker refused this" from "the worker is not there", which want opposite responses.

Returning a nil reply and a nil error answers nothing at all, which suits a request that only causes an action.

type RemoteError

type RemoteError struct{ Message string }

RemoteError is what a Handler's error becomes on the other side of the pipe.

func (*RemoteError) Error

func (e *RemoteError) Error() string

type Router

type Router struct {
	// contains filtered or unexported fields
}

Router sends each request to a handler chosen by name, with the request and the reply as the types that handler actually wants.

Without it a worker takes one Handler and dispatches for itself, which means a switch on a type field, a json.Unmarshal per branch, and a caller that has to work out what came back. That last part is where it goes wrong: an application ends up trying to decode a reply as one type, then another, until one succeeds - and an empty JSON array decodes as any of them, so a list that happens to be empty is read as whatever was tried first.

r := vero.NewRouter()
vero.Handle(r, "getGroups", func(ctx context.Context, _ struct{}) ([]Group, error) {
    return groups(), nil
})
vero.Handle(r, "addGroup", func(ctx context.Context, req AddGroup) (Group, error) {
    return add(req.Name)
})
w.Serve(r)

The name travels in the envelope, so an application's own messages stay exactly as it defined them.

func NewRouter

func NewRouter() *Router

NewRouter returns an empty Router.

func (*Router) Fallback

func (r *Router) Fallback(h Handler)

Fallback answers requests whose name matches nothing registered, including requests that carry no name at all.

Without one an unknown name is refused, which is the default and usually right: it is what makes named routing the only way in. Set one only while something older is still expected to connect, and watch FallbackCalls to know when that has stopped being true. It is worth setting when an interface can be newer than the worker it is driving: it then asks for things this build has never heard of, and refusing is a failure the person did not cause and cannot act on.

func (*Router) FallbackCalls

func (r *Router) FallbackCalls() uint64

FallbackCalls counts the requests that have reached the fallback.

It answers the question a fallback creates: when can it go? A fallback is there for interfaces that predate named routing, and while any of them are still running this climbs. When it stays at zero across a release, nothing is using the untyped path and both it and the fallback can be deleted - which is what makes typed routing the only way in.

func (*Router) Names

func (r *Router) Names() []string

Names lists what has been registered, in no particular order.

type State

type State int

State is what the supervisor last saw the worker doing.

const (
	// Starting means a worker is being launched, including a restart.
	Starting State = iota
	// Running means a worker is up and answering.
	Running
	// Restarting means one died and another is on the way.
	Restarting
	// Stopped means Stop was called; nothing further will be launched.
	Stopped
)

func (State) String

func (s State) String() string

type Supervisor

type Supervisor struct {
	// contains filtered or unexported fields
}

Supervisor runs a worker, keeps it running, and talks to it.

func Supervise

func Supervise(opts SupervisorOptions) *Supervisor

Supervise launches the worker and keeps it alive until Stop.

It returns immediately; the worker comes up on its own goroutine. Requests made before it is ready return ErrWorkerNotRunning rather than blocking, so an interface can draw itself at once and show that it is waiting.

func (*Supervisor) Call

func (s *Supervisor) Call(ctx context.Context, name string, request any) (json.RawMessage, error)

Call sends a request to the handler registered under name, for a worker using a Router.

The name travels beside the payload rather than inside it, so an application's own message types stay exactly as it defined them.

func (*Supervisor) Err

func (s *Supervisor) Err() error

Err reports why the worker never started, or nil.

The one that matters is ErrAlreadyRunning: another process holds the lock, so this Supervisor will not run a worker at all.

func (*Supervisor) Latest

func (s *Supervisor) Latest() json.RawMessage

Latest returns the most recent event, or nil before the first one.

An interface that has just drawn a window needs the current state without waiting for the next change, and this is that. Events keep it up to date afterwards.

func (*Supervisor) Request

func (s *Supervisor) Request(ctx context.Context, request any) (json.RawMessage, error)

Request sends a request and waits for its reply.

A handler's error comes back as *RemoteError, which is worth telling apart from ErrWorkerNotRunning: one means the worker refused what you asked, the other that it is not there to ask.

func (*Supervisor) Restarts

func (s *Supervisor) Restarts() int

Restarts counts how many times the worker has been relaunched after dying.

func (*Supervisor) State

func (s *Supervisor) State() State

State reports what the worker is doing now.

func (*Supervisor) Stop

func (s *Supervisor) Stop() error

Stop shuts the worker down and waits for it to go.

Closing its standard input is the whole shutdown protocol: Run returns, the process exits, and anything it was holding is released the way it would be on any other exit. A worker that ignores that is killed after a grace period.

type SupervisorOptions

type SupervisorOptions struct {
	// Path is the worker binary.  Args are passed to it verbatim, so the
	// application keeps its own command line.
	Path string
	Args []string

	// Dir and Env, if set, are the worker's working directory and
	// environment.  VERO_SERVE is added to Env either way.
	Dir string
	Env []string

	// OnEvent receives every event the worker emits, in order.
	OnEvent func(event json.RawMessage)

	// OnLog receives the worker's standard error, a line at a time.  Workers
	// log there precisely so this does not have to guess at framing.
	OnLog func(line string)

	// OnStateChange is told when the worker starts, dies, or is given up on.
	OnStateChange func(State)

	// Backoff is the wait before the first restart, doubling up to
	// MaxBackoff.  A worker that crashes on startup would otherwise be
	// relaunched as fast as the machine can fork.  Zero means 200ms and 30s.
	Backoff    time.Duration
	MaxBackoff time.Duration

	// Lock keys the single-worker lock: one process at a time supervises a
	// worker under a given key.  Empty means the worker's own path, which is
	// what you want unless two applications share a binary and should still
	// be allowed to run at once - then give them a key each.
	Lock string

	// NoLock turns the lock off, for a program that genuinely wants several
	// workers at once.
	NoLock bool
}

SupervisorOptions describes the worker to run and who to tell about it.

The callbacks are called from the supervisor's own goroutines, never on the interface's main thread. Whatever you do in them, hop to the main thread before touching the interface.

type Worker

type Worker struct {
	// contains filtered or unexported fields
}

Worker is the logic half of a vero application.

func NewWorker

func NewWorker(opts WorkerOptions) *Worker

NewWorker prepares a worker. Whether a supervisor launched it is read from the environment, so the same binary and the same code run both ways.

Call this before starting any goroutine

In serve mode standard output belongs to the protocol, so a stray Println anywhere in the program would corrupt a frame. Rather than ask every author of every line in the program to remember that, this takes the real standard output for itself and points the package-level os.Stdout at standard error.

fmt.Println and friends read os.Stdout at the moment they are called, so every existing print in the program - and every one anybody adds later, in any package, including ones you depend on and cannot edit - lands harmlessly on stderr from here on. There is nothing to audit and nothing to keep auditing.

It does mean reassigning a package-level variable, so call NewWorker before anything else could be printing: another goroutine writing while this reassigns is a data race, and one the race detector will find.

func (*Worker) Emit

func (w *Worker) Emit(event any)

Emit sends an event to whoever is listening. When nothing is - a daemon running with no interface and no -json - it costs a comparison and returns.

Call it whenever state moves. That is what lets an interface show progress without polling: there is no timer to tune and nothing is sent while the worker is quiet.

func (*Worker) EmitOnChange

func (w *Worker) EmitOnChange(ctx context.Context, interval time.Duration, snapshot func() any)

EmitOnChange emits what snapshot returns, whenever it differs from what was emitted last. It runs until ctx is done, so start it with go.

go w.EmitOnChange(ctx, 250*time.Millisecond, func() any { return state() })

Emitting on a timer instead is the obvious thing to write and it defeats the point: an interface is sent the same thing several times a second, and the quiet-when-nothing-happens property - the reason events are pushed rather than polled - is gone.

Comparison is by reflect.DeepEqual, so snapshot should return a value rather than something holding a pointer to state that keeps changing underneath it.

func (*Worker) Log

func (w *Worker) Log(format string, a ...any)

Log writes a line to standard error, where logs belong. Standard output is reserved for the protocol, and a stray write there corrupts the stream.

func (*Worker) Run

func (w *Worker) Run(h Handler) error

Run answers requests until the other end goes away, then returns nil.

When no supervisor launched this worker there is nothing to answer, so Run blocks forever and the worker just works. When one did, standard input closing means the interface has quit - or crashed, or been force quit - and Run returns so the process can exit with it. That is the whole of the lifecycle: no PID file, no heartbeat, no orphan.

func (*Worker) Serve

func (w *Worker) Serve(r *Router) error

Serve answers requests by name, using a Router.

Otherwise identical to Run: it blocks, it returns when the interface goes away, and on its own it never returns.

func (*Worker) Supervised

func (w *Worker) Supervised() bool

Supervised reports whether a Supervisor launched this worker. Useful when the standalone form wants to behave differently - printing a summary and exiting, say, rather than running forever.

type WorkerOptions

type WorkerOptions struct {
	// JSON emits the event stream on standard output.  A Supervisor turns
	// this on for you; set it yourself when a script is watching.
	JSON bool

	// Quiet suppresses the human readable log on standard error.
	Quiet bool

	// Version is what -version reports.  An interface that ships this worker
	// in its bundle asks for it to decide whether the copy it has on disk is
	// older than the one it was built with, so the two halves have to agree
	// on the flag: vero registers it here so they cannot drift.
	Version string

	// ShowVersion is set by RegisterFlags when -version was passed. Answer it
	// and exit before anything else starts - it runs as its own short-lived
	// process, several times per launch, and must not open a port, touch a
	// configuration directory, or print anything else.
	ShowVersion bool
}

WorkerOptions controls what a worker writes, and where.

The two settings are deliberately separate. Format and volume are different questions: an interface wants every state change as JSON, and a person reading a terminal or a journal wants a handful of lines saying what actually happened. Emitting the first to satisfy the second is how logs become unreadable.

func (*WorkerOptions) PrintVersionAndExit

func (o *WorkerOptions) PrintVersionAndExit()

PrintVersionAndExit answers -version if it was asked for, and does not return in that case.

Call it immediately after flag.Parse, before anything else starts.

func (*WorkerOptions) RegisterFlags

func (o *WorkerOptions) RegisterFlags(fs *flag.FlagSet)

RegisterFlags adds -json and -quiet to fs. Call it before flag.Parse.

A worker does not need a flag for serve mode: a Supervisor sets it through the environment, so nothing on the command line has to describe an arrangement the user did not ask for.

Directories

Path Synopsis
Command cshim builds vero as a C archive, so a native interface can embed it and drive a worker without knowing anything about pipes, JSON framing or process supervision.
Command cshim builds vero as a C archive, so a native interface can embed it and drive a worker without knowing anything about pipes, JSON framing or process supervision.
example
worker command
Command worker is the logic half of the example: the part you would write.
Command worker is the logic half of the example: the part you would write.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL