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

vero

package module
v0.9.2 Latest Latest
Warning

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

Go to latest
Published: Sep 20, 2026 License: MIT Imports: 19 Imported by: 0

README

vero

Go backend with a native cross-platform GUI frontend -> macOS, Windows, and Linux

Run a Go binary embedded in a native macOS SwiftUI app. The Go binary and the SwiftUI app communicate over a pipe. The macOS SwiftUI frontend app sends requests, and the Go worker pushes its state back the instant it changes.

Then use the same Go code backend with Windows and Linux frontends.

Go reference


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

macOS example · Windows and Linux examples

If you already have Xcode and Go installed, the example below builds a running app in about 5 minutes.

Run the macOS example

The example is a menu bar app driving the Go worker (main.go below):

git clone https://github.com/calmdocs/vero && cd vero
cd example/menubar-app && ./build.sh
./.build/debug/MenuBarExample

Look for the icon in the menu bar. The source is available at example/menubar-app.

Create a vero macOS app on your Mac

1. Build the go worker

Get vero:

mkdir -p ~/vero-example/macos-app && cd ~/vero-example/macos-app
go mod init macos-app
go get github.com/calmdocs/vero

Create main.go (file also available at example/worker/main.go):

// Command worker is the logic half of the example: the part you would write.
//
// The flags come from vero.WorkerOptions: -json puts the event stream on
// stdout, -quiet silences the log, and -version is what the frontend asks
// before replacing the copy of this it has on disk.
package main

import (
	"flag"
	"fmt"
	"math/rand"
	"slices"
	"time"

	"github.com/calmdocs/vero"
)

// version has to increase on every release, so the frontend can tell which
// of two copies is newer.
var version = "0.9.2"

type Job struct {
	ID       int    `json:"id"`
	Name     string `json:"name"`
	Phase    string `json:"phase"`
	Progress int    `json:"progress"`
}

// Key is how an id in a request finds one job.
func (j Job) Key() int { return j.ID }

func (j *Job) Restart() { j.Phase, j.Progress = "waiting", 0 }

type Status struct {
	Jobs    []Job  `json:"jobs"`
	Working bool   `json:"working"`
	Since   string `json:"since"`
}

func working(s *Status) {
	s.Working = slices.ContainsFunc(s.Jobs, func(j Job) bool {
		return j.Phase != "waiting" && j.Phase != "done"
	})
}

func main() {
	var opts vero.WorkerOptions
	opts.Version = version
	opts.RegisterFlags(flag.CommandLine)
	flag.Parse()
	opts.PrintVersionAndExit()

	w := vero.NewWorker(opts)

	// The state.  vero watches it and sends it to the frontend whenever its
	// JSON changes, which is how the frontend tracks status without asking.
	// Each handler below replies with it as well.
	state := vero.NewState(w, Status{
		Jobs: []Job{
			{ID: 1, Name: "Photos", Phase: "waiting"},
			{ID: 2, Name: "Documents", Phase: "waiting"},
			{ID: 3, Name: "Team share", Phase: "waiting"},
		},
		Since: time.Now().Format("15:04:05"),
	})

	go work(w, state)

	// Update handles a request with no accompanying data.  "status" returns
	// the state.
	vero.Update(state, "status", func(*Status) error { return nil })

	// UpdateWith handles a request with accompanying data.  "restartJob"
	// takes the id of the job to restart.
	vero.UpdateWith(state, "restartJob", func(s *Status, req vero.ID[int]) error {
		if err := vero.Edit(s.Jobs, req.ID, (*Job).Restart); err != nil {
			return fmt.Errorf("no job with id %d", req.ID)
		}
		working(s)
		return nil
	})

	if err := w.Serve(); err != nil {
		w.Log("stopped: %v", err)
	}
}

// work is the business logic - we have added random state changes to
// demonstrate how this works.
func work(w *vero.Worker, state *vero.State[Status]) {
	phases := []string{"looking for changes", "scanning", "uploading", "done"}
	for {
		time.Sleep(time.Duration(200+rand.Intn(400)) * time.Millisecond)

		var name, phase, before string
		state.Do(func(s *Status) {
			j := &s.Jobs[rand.Intn(len(s.Jobs))]
			before = j.Phase
			switch i := slices.Index(phases, j.Phase); {
			case j.Phase == "waiting" || j.Phase == "done":
				j.Phase, j.Progress = phases[0], 0
			case j.Progress < 100:
				j.Progress = min(j.Progress+20+rand.Intn(30), 100)
			case i+1 < len(phases):
				j.Phase, j.Progress = phases[i+1], 0
			}
			name, phase = j.Name, j.Phase
			working(s)
		})

		if phase != before {
			w.Log("%s: %s", name, phase)
		}
	}
}

Build it for both architectures and join them into one binary, so the app runs on either. lipo comes with Xcode:

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
2. Create a new macOS SwiftUI Xcode project

In Xcode, File -> New -> Project -> macOS -> App, with Interface set to SwiftUI. Then:

  • File -> Add Package Dependencies... -> https://github.com/calmdocs/vero
  • drag ~/vero-example/macos-app/worker into the project, ticking your app under Add to targets
3. Add the app's files to the project

The app is Model.swift and MenuBarExampleApp.swift. Download both:

cd ~/vero-example/macos-app
base=https://raw.githubusercontent.com/calmdocs/vero/main/example/menubar-app/Sources/MenuBarExample
curl -O $base/Model.swift
curl -O $base/MenuBarExampleApp.swift

Drag them into the Xcode project, and delete the ContentView.swift and <YourApp>App.swift that Xcode generated: MenuBarExampleApp.swift is the @main entry point.

4. Run it

Press Cmd-R in Xcode. The icon appears in the menu bar, and spins while the worker has jobs in flight. Click the menu bar icon to see progress changes. Click the button on a row to restart that job.

Build the same worker for Windows and Linux

Both can be built on your Mac:

example/wpf-app Windows, WPF
example/gtk-app Linux, GTK4

To see all three at once instead, ./scripts/setup.sh installs the toolchains for every platform and builds everything, and ./scripts/run.sh then opens the three examples together on your Mac.

Licence

MIT

Documentation

Overview

Package vero is a Go backend with native macOS, Windows and Linux frontends, all built on macOS. See the README for how to build one.

Index

Constants

View Source
const DefaultStateInterval = 100 * time.Millisecond

DefaultStateInterval is how often WorkerOptions.State is sampled for a change when StateInterval is not set. Fast enough that a progress bar moves smoothly, slow enough that nothing is sent while nothing is happening.

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 a frontend: 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 frontend has gone before exiting regardless.

Functions

func Edit added in v0.5.0

func Edit[T Keyed[K], K comparable](items []T, key K, change func(*T)) error

Edit finds the item the frontend means and changes it, which is most of what a handler does.

func (s *Status) Pause(req vero.ID[int]) error {
    return vero.Edit(s.Jobs, req.ID, func(j *Job) { j.Paused = !j.Paused })
}

A request naming something that is gone is refused, and the worker carries on.

func Handle

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

Handle answers 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.

func Update added in v0.3.0

func Update[T any](s *State[T], name string, fn func(*T) error)

Update answers a request that carries nothing - a button with no payload - changes the state, and replies with it.

func (s *Status) Add() error { … }

vero.Update(jobs, "addJob", (*Status).Add)

UpdateWith is the same for a request that carries something, and UpdateItem for one that names an item.

func UpdateItem added in v0.8.0

func UpdateItem[T any, J Keyed[K], K comparable](s *State[T], name string, change func(*J) error)

UpdateItem answers a request that names one item and changes it, which is what a button on a row sends.

func (j *Job) Pause() error { j.Paused = !j.Paused; return nil }

vero.UpdateItem(jobs, "pauseJob", (*Job).Pause)

The request is an ID, the item is found by Key, change runs under the lock with a pointer into the state, and the reply is the new state. A request naming something that is gone is refused and the worker carries on.

Which collection is worked out from the item type: the state's one []J field, or the state itself when it is a []J. A state with two fields of the same item type cannot say which, and says so at registration rather than guessing.

func UpdateWith added in v0.8.0

func UpdateWith[T any, Req any](s *State[T], name string, fn func(state *T, request Req) error)

Update answers a request by changing the state, and replies with it.

jobs.Update("restartJob", func(s *Status, req RestartJob) error {
    s.Jobs[req.ID].Progress = 0
    return nil
})

fn runs under the lock, with the state and the decoded request, and the reply is the state as it leaves. So a frontend cannot draw the state from before its own change, and there is nothing to copy or forget to return.

Returning an error sends that to the frontend and changes nothing else.

The lock is held for the duration, so this is for changing state, not for work: anything slow belongs in a goroutine the handler starts, writing back through Do. A handler that replies with something other than the state is Worker.Handle.

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 registered with Handle or Update.
	// 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 frontend 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 frontend 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 ID added in v0.5.0

type ID[T any] struct {
	ID T `json:"id"`
}

ID is a request that carries nothing but an identifier, which is most of them: restart this, cancel that, open the other. T is whatever your ids are.

jobs.Update("restartJob", func(s *Status, req vero.ID[int]) error {
    …
})

type Keyed added in v0.5.0

type Keyed[K comparable] interface {
	Key() K
}

Keyed is anything the frontend can name: a job with an id, a row with a primary key. Key is what the ID in a request matches.

func (j Job) Key() int { return j.ID }

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

Error is the worker's own message, not vero's.

type RunState added in v0.5.0

type RunState int

RunState is what the supervisor last saw the worker doing. It is the worker's lifecycle, not the state it publishes: that is Worker.NewState on the other side of the pipe.

const (
	// Starting means a worker is being launched, including a restart.
	Starting RunState = 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 (RunState) String added in v0.5.0

func (s RunState) String() string

String is what the bindings report: "starting", "running", "restarting" or "stopped".

type State

type State[T any] struct {
	// contains filtered or unexported fields
}

State is everything the frontend draws, and the lock that guards it.

Create one with Worker.NewState. The worker emits it whenever it changes and a handler registered with Worker.Update replies with it, so a frontend never polls and never draws the state from before its own change.

The worker reads it as JSON, under the same lock that Do takes, so there is no copy to get wrong: a snapshot cannot be encoded halfway through a change, and nothing can hand out a value that keeps changing underneath the comparison that decides whether to emit.

func NewState added in v0.8.0

func NewState[T any](w *Worker, initial T) *State[T]

NewState gives this worker its state, and returns it for the application to change with Do.

jobs := vero.NewState(w, Status{Jobs: []Job{{ID: 1, Name: "Photos"}}})
jobs.Do(func(s *Status) { s.Jobs[0].Progress++ })

One worker has one state: calling this twice replaces the first, because the event channel carries no name and a frontend would have no way to tell two kinds of event apart.

func (*State[T]) Do added in v0.5.0

func (s *State[T]) Do(f func(*T))

Do runs f with the state, under the lock. Every change goes through it.

jobs.Do(func(s *Status) { s.Jobs = append(s.Jobs, Job{}) })

Do not keep the pointer, start a goroutine with it, or block in f: the worker cannot encode the state while f holds the lock.

func (*State[T]) Every added in v0.5.0

func (s *State[T]) Every(interval time.Duration, fn func(*T))

Every runs fn with the state, under the lock, every interval, until the frontend goes away. It returns straight away: the loop is a goroutine of its own.

jobs.Every(200*time.Millisecond, func(s *Status) {
    for i := range s.Jobs { s.Jobs[i].Progress++ }
})

This is where a worker's own work goes when it is periodic. Writing the loop by hand costs a goroutine, a ticker that time.Tick never reclaims, and a nested Do.

func (*State[T]) JSON added in v0.5.0

func (s *State[T]) JSON() (json.RawMessage, error)

JSON is the state, encoded under the lock.

This is the only way the state leaves: a Go value handed out would share the slices and maps inside it, and whatever received it would be reading them while Do writes. Use it to reply with the state by hand - Update does it for you - and pass the result straight back as the reply.

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 a frontend 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 registered by name.

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.

A frontend 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() RunState

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(RunState)

	// 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 frontend's main thread. Whatever you do in them, hop to the main thread before touching the frontend.

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) 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() })

Most workers do not call this: set WorkerOptions.State instead and Serve runs it for you, on a context that ends when the frontend does. This is for the ones whose events are not one state snapshot.

Emitting on a timer instead is the obvious thing to write and it defeats the point: a frontend 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) Fallback added in v0.4.0

func (w *Worker) Fallback(h Handler)

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

func (*Worker) FallbackCalls added in v0.4.0

func (w *Worker) FallbackCalls() uint64

FallbackCalls counts the requests that have reached the fallback, so an application can tell when the old shape has stopped being used.

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) Serve

func (w *Worker) Serve() error

Serve answers the requests registered with Handle and Update.

Otherwise identical to Run: it blocks, it returns when the frontend 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.  A frontend 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

	// StateInterval is how often the state is sampled for a change, for a
	// worker that has one.  Zero means DefaultStateInterval.
	StateInterval time.Duration

	// 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: a frontend 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 frontend 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 frontend 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