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

tuohi

package module
v0.3.1 Latest Latest
Warning

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

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 38 Imported by: 0

README

tuohi

tuohi puts a Go program's web interface in a native desktop window, using the web engine the operating system already has: WebKitGTK on Linux, WKWebView on macOS, and WebView2 on Windows. It needs no cgo, so a program that embeds it still cross-compiles to every desktop from one machine with CGO_ENABLED=0, and it ships no browser engine of its own.

Tuohi is Finnish for birch bark: the thin, light outer layer of the tree, and the traditional material for light containers. terva and other terva-sh tools embed tuohi to offer a desktop window over the web interface they already serve, instead of an Electron package.

go get github.com/terva-sh/tuohi

tuohi is a fork of github.com/malivvan/appkit v0.1.0, whose repository no longer exists. docs/provenance.md records where the source came from and what the review of it found, and NOTICE credits the projects it draws on. The library is under active development; its v0 API can change. See the v0.3.1 release notes for the Windows background fix, the v0.3.0 release notes for clipboard images, and the v0.2.0 release notes for the desktop APIs and their platform limits. docs/architecture.md records the shape tuohi is moving to, and why.


tuohi is a pure-Go foundation for building web-based desktop applications. It drives the web engine each operating system already ships - WKWebView on macOS, WebKitGTK on Linux, WebView2 on Windows - behind a single Go API, and adds what a window needs around it: windows and app windows, drag regions, custom URL schemes, URL/file opening and native file dialogs. Notifications, the clipboard, single instance, autostart and the tray live in subpackages beside it (see Desktop services). Everything is cgo-free.

Why no cgo

Most native-view bindings reach for cgo, which quietly takes back the things that make Go pleasant to ship: cross-compiling needs a matching C cross-compiler for every target (MinGW for Windows, a sysroot for Linux), builds stop being reproducible, and go get/go install only works for people who already have that toolchain set up.

tuohi keeps cgo out entirely. Through purego it loads the OS view at runtime (dlopen / LoadLibrary), so no C compiler is in the loop:

  • Cross-compile to every desktop from one machine - no C cross-toolchain, just GOOS/GOARCH:

    GOOS=windows GOARCH=amd64 go build   # from a Mac, from Linux, from anywhere (see "xdemo" Makefile target)
    GOOS=linux   GOARCH=arm64 go build
    GOOS=darwin  GOARCH=arm64 go build
    
  • CGO_ENABLED=0 builds - reproducible output, and a go get / go install that just works with no compiler to install first.

One caveat, so "self-contained" is not misread: tuohi does not bundle a browser engine - it is not Electron. The binary ships no native library and stays small, but it uses the system view at runtime, so the target machine needs that present: WebView2 on Windows (preinstalled on current Windows 10/11), WebKitGTK on Linux (a package), WKWebView on macOS (built in).

What's in the box

  • No cgo
  • Windows, macOS and Linux
  • Zero bundled native libraries - binds the OS view directly (WKWebView / WebKitGTK / WebView2)
  • JavaScript ↔ Go binding
  • A single *App scope for the app: App is both the configuration (like an http.Server) and the runtime context; the app-scoped operations are methods on it (Show, Wait, Quit, Open, Reveal, Backend) and its settings are committed the first time any method is called
  • Native file dialogs - a single View.Dialog(opts dialog.Options) method and the standalone dialog.Open, with the panel kind chosen through dialog.Options.Type - plus opening URLs / revealing files (App.Open / App.Reveal)
  • A declarative system tray with a menu - PNG icons (light/dark/macOS-template variants), checkboxes, submenus, separators (the tray/ package, set up from App.Start), desktop notifications (the notify/ package), and a best-effort runtime application icon (App.Icon; a no-op on Windows, which reads the icon from the executable's own resources, and shown by a GTK3 window on Wayland only when App.DesktopEntry lets tuohi install a desktop entry for it)
  • The system clipboard (the clipboard/ package) and single instance (the instance/ package: one process per application, and a later launch hands its arguments to the running process)
  • A Go↔JS Events bridge built into every view: w.On / w.Off / w.Emit (Go side) with window.events on the page (the events global's name is configurable through App.Events)
  • App-scoped UI serving: set App.FS once (an io/fs.FS) and every view serves your UI from it at the uniform app:// origin. Serving is scheme-first on Windows and Linux (WebView2's https vhost carries the isolation headers; Linux's registered custom app:// scheme cannot - WebKitGTK can't attach the headers to scheme responses - but the JSC option still enables SharedArrayBuffer). macOS always serves over a per-view loopback http://localhost server (WKWebView cannot make a custom scheme a secure context and a long-standing WebKit bug keeps SharedArrayBuffer off plain WKWebView pages), and App.HTTP opts Linux and Windows into that same loopback origin - the loopback and vhost responses carry the cross-origin-isolation headers (COOP/COEP + CORP), so SharedArrayBuffer is available on every platform. Nothing is ever exposed beyond the loopback interface. The URL an app uses never changes per platform.
  • Launch at login from Go: autostart.New(id) returns an *Autostart - Enable(args...) registers the running executable to start with the user session under id, Enabled/Path/ Backend report the current registration and Disable removes it. The backend is per platform: XDG autostart .desktop file, HKCU\…\Run registry value, macOS LaunchAgent plist (or SMAppService for bundled macOS 13+ apps)
  • Window state from Go, per View: Show, Hide (remove the window from the screen AND the taskbar/window list - the hide-to-tray pair), Maximize/Minimize and their inverses Unmaximize/Unminimize all work at runtime and are safe to call from any goroutine. Focus (keyboard focus into the web content) and Raise (front the window and activate the app) round out the runtime controls. Geometry, however, does not change at runtime - sizing and moving a window after creation is a defined non-feature of tuohi (the platforms cannot agree on it: Wayland compositors do not let a client move or resize its own toplevel, and GTK4 has no move API at all), so a unified runtime setter would silently fail on part of the target matrix. Set the initial geometry on the View itself - Left/Top/Width/Height plus the resize State - and if you really need to move or resize the window afterwards, take its native handle from View.Window (filled by App.Show) and use that platform's own API
  • Frameless windows (the default - View.Frame is false) are fully transparent: no OS decoration of any kind and the desktop shows through everywhere the page does not paint. Give the page's html/body an explicit background when one is wanted; View.Frame true gives the ordinary OS-framed, opaque window
  • Windows are declarative: define a View (geometry, options, bindings and the first-page URL), then App.Show(&view) creates it and navigates to URL. view.Debug enables the inspector, and view.FirstMouse opts macOS into first-click passthrough. For script that must run when the page comes up, call view.Eval from the view.Ready callback: Ready fires when the first page finished loading, so the DOM and the page's own scripts are already in place and one Eval reaches them reliably across all three engines. view.window (unexported) lets in-package embedders supply a host native window before Show.
  • Plays nicely with go.work multi-module setups

Desktop services

The root package github.com/terva-sh/tuohi is the window: App, View, bindings, events, serving (App.FS, App.HTTP), the runtime icon (App.Icon), and opening URLs or revealing files (App.Open / App.Reveal). App.Backend() reports which web engine the app runs on - webkitgtk-6.0/webkit2gtk-4.1 on Linux, WKWebView on macOS, WebView2 on Windows.

The desktop services live in subpackages on the same cgo-free foundation. The optional updater package verifies signed release feeds, stages bounded downloads through the consumer's transport, and supports explicit portable-executable or whole-macOS-bundle installation after graceful shutdown. Consumers retain update consent, channel, UI and package-manager policy. The selfupdate example shows the desktop lifecycle; updatefeed signs metadata offline without publishing it. None of them imports the root, and the root imports none of them except dialog, so a program that only opens a window links none of their dependencies: neither godbus (the Linux tray and notifications) nor the clipboard's code. Where a platform cannot support something cleanly, the API returns a clear ErrUnsupported instead of shipping something flaky.

Each service that used to hang off App has moved:

Was Is now
App.ID and App.Exec (Single Instance Mode) instance/: instance.Acquire(id, onMessage), Lock.Release, instance.Send(id, args)
App.Autostart() autostart/: autostart.New(id), with the same Enable/Disable/Enabled/Path/Backend
App.Copy / App.Paste clipboard/: clipboard.Copy(text) / clipboard.Paste(), on strings rather than bytes
App.Notify notify/: notify.Show(app.Name, title, message)
App.Tray tray/: tray.Set(cfg) from App.Start, tray.Remove() after Wait

View.Dialog stays on the view, because a dialog needs its parent window; the panels themselves are the dialog/ package.

Single instance. The first process to Acquire an id holds the lock; a later one gets instance.ErrAlreadyRunning, hands its arguments over and exits. The package does not exit for you, and the old --new-instance override went with App.Exec: check your own arguments before Acquire if you want one. On Unix the lock and socket live in a directory only the user can use, never in /tmp, and on Windows the pipe is open to the user alone. Either way the running instance takes messages only from the same user; the package documentation says what that does and does not guarantee.

const id = "com.example.app"
lock, err := instance.Acquire(id, func(m instance.Message) {
	// Runs on its own goroutine. m.Args and m.Dir, the later launch's
	// working directory, are untrusted input.
})
if errors.Is(err, instance.ErrAlreadyRunning) {
	if err := instance.Send(id, os.Args[1:]); err != nil {
		log.Fatal(err)
	}
	return
}
if err != nil {
	log.Fatal(err)
}
defer lock.Release()

Autostart. autostart.New(id) stores the registration under exactly id, which must be 1-200 characters from A-Za-z0-9._-. It no longer falls back to a name derived from App.Name or the executable. Enable replaces an entry an older build registered under another name, because it removes any entry that points at the same executable. The exception is a bundled .app on macOS 13 and later: it registers itself through SMAppService as its own login item, named by its bundle identifier, so id and the arguments to Enable are not used there.

Clipboard. clipboard.Copy and clipboard.Paste run no external program. On Linux, FreeBSD and NetBSD they call the GTK tuohi's window loaded, so no xclip, xsel or wl-copy is needed, and on macOS NSPasteboard. There they work once a tuohi App has shown its first window, and until then return clipboard.ErrNoApp. On Wayland, and on X11 without a clipboard manager, copied text lasts only as long as the process. On Windows they use the Win32 clipboard, which needs no tuohi window. clipboard.PasteImage(limits) returns the clipboard's image as PNG, reading PNG, BMP, JPEG or GIF offers within a byte and a pixel limit, and clipboard.ErrNoImage when there is none. It is for an image the page's paste event does not carry: WebKitGTK hands a page no BMP, which is how WSLg offers an image copied on Windows. It reads on GTK only for now, and returns clipboard.ErrUnsupported on macOS and Windows.

Tray. App.Start runs on the UI thread when Wait starts, before its loop dispatches any event, which is where tray.Set must be called. An error from it ends Wait. The tray icon is no longer derived from App.Icon: pass your own tray.Config.Icon.

cfg := tray.Config{
	Icon:    trayPNG,
	Tooltip: "my app",
	Items: []tray.Item{
		{Label: "Quit", OnClick: app.Quit},
	},
}
app.Start = func() error { return tray.Set(cfg) }
err := app.Wait()
tray.Remove()

Install

go get github.com/terva-sh/tuohi@latest

Requirements

tuohi binds the view the operating system already provides; there is nothing to bundle, but that runtime must be present:

  • Linux, FreeBSD and NetBSD - a system WebKitGTK with GTK4 or GTK3; tuohi detects which at runtime, or you can pin one with the TUOHI_BACKEND environment variable (Choosing a stack below). The exact libraries and how to install or debug them are in Linux shared libraries below.
  • Windows - the Microsoft Edge WebView2 Runtime (preinstalled on current Windows 10/11; otherwise install the Evergreen Runtime). It is located via the registry, and App.Show returns an error if it is missing. To bundle zero native DLLs, tuohi calls the runtime's internal environment-creation export directly instead of shipping WebView2Loader.dll; that export is undocumented and could change in a future Edge runtime (in which case App.Show returns a clear error). See the note on createEnvironment in lib_windows.go.
  • macOS - nothing extra. The Cocoa/WebKit frameworks ship with the OS.
Support tiers
  • Tier 1: Linux, macOS and Windows. Every change to main is built and tested on the real engine: WebKitGTK 6.0 and 4.1 on amd64 and arm64 Linux, WKWebView on macOS, and WebView2 on Windows. The Linux GUI scenarios also gate every internal pull request before it merges; macOS and Windows are tested after the merge.
  • Tier 2: FreeBSD and NetBSD. They must cross-build on every change, and nothing runs them.
  • Go 1.26 or newer, except linux/s390x, which needs Go 1.27: purego reaches it without cgo only from 1.27 on.
  • glibc on Linux. Even with CGO_ENABLED=0, a binary that reaches purego asks for glibc's loader, /lib64/ld-linux-x86-64.so.2 on amd64, so it runs on glibc desktops. On musl, such as Alpine, it needs gcompat.

The other architectures in the table below compile, and nothing tests them.

Supported platforms (build targets)

tuohi binds the OS web engine through purego (runtime dlopen, no cgo), so compilation tracks purego's supported platforms:

GOOS GOARCH
Linux amd64, arm64*, 386, armv7*, armv6*, armv5*, loong64*, ppc64le*, riscv64*, s390x*
FreeBSD amd64, arm64*
NetBSD amd64, arm64*
Windows amd64, arm64*, 386
Darwin amd64, arm64*

linux/s390x needs Go 1.27 or newer; every other target builds with Go 1.26.

Architectures marked with a * have only been tested to compile, not to run. If somebody has a machine of that architecture and can verify the runtime, please open an issue.

  • FreeBSD needs one build flag. With CGO_ENABLED=0, purego's internal/fakecgo exports environ and __progname with a directive the compiler accepts only in cgo-generated code, so a FreeBSD build without cgo must compile that package with -std:

    CGO_ENABLED=0 GOOS=freebsd go build -gcflags=github.com/ebitengine/purego/internal/fakecgo=-std ./...
    

    NetBSD needs no flag.

  • Runtime on the BSDs is not verified and depends on what the port provides: a desktop GTK/WebKitGTK with the sonames tuohi probes (shared libraries - a BSD port may name them differently), a session D-Bus for the tray/notify/dialog backends, and working flock/Unix sockets for the instance package. Some helpers are Linux-specific at runtime (e.g. the xdg-open opener and the console-bell fallback in notify) and degrade or report unsupported elsewhere. Other GOOSes (OpenBSD, DragonFly, Solaris, AIX, Plan 9, js) have no lib-family backend and do not compile.

Linux shared libraries

Linux is the hard case: every distro packages WebKitGTK a little differently, but what tuohi needs is concrete. These are the exact sonames it tries to dlopen at startup. They must be loadable by the dynamic linker (on the default search path, in the ldconfig cache, or in LD_LIBRARY_PATH) and match the architecture of your binary - a 64-bit Go build needs 64-bit libraries.

Always loaded:

  • libglib-2.0.so.0
  • libgobject-2.0.so.0

libwebkitgtk-6.0.so.4 decides the stack: if it loads, tuohi uses GTK4; otherwise GTK3. It never loads both - most desktops have GTK3 and GTK4 installed side by side, and pulling both into one process corrupts GTK's type system and crashes gtk_init.

  • GTK4: libgtk-4.so.1, libwebkitgtk-6.0.so.4, libjavascriptcoregtk-6.0.so.1
  • GTK3: libgtk-3.so.0, libwebkit2gtk-4.1.so.0 (or libwebkit2gtk-4.0.so.37), libjavascriptcoregtk-4.1.so.0 (or libjavascriptcoregtk-4.0.so.18)

Either stack needs WebKitGTK 2.40 or newer. The GTK4 stack needs GTK 4.12 or newer, and the GTK3 stack GTK 3.20 or newer. An older library fails App.Show with an error that names every function it lacks.

Choosing a stack (TUOHI_BACKEND)

The TUOHI_BACKEND environment variable pins one of the two stacks before the probe above runs - useful when both are installed and you want to force one, or to reproduce a bug against a specific WebKitGTK:

  • TUOHI_BACKEND=webkitgtk-6.0 - the GTK4 stack
  • TUOHI_BACKEND=webkit2gtk-4.1 - the GTK3 stack (still falls back to the -4.0 sonames inside that stack when -4.1 is absent)

If the pinned backend's libraries cannot be loaded - or the value is anything other than the two above - tuohi prints a warning to stderr and continues with the auto-detected stack that works. macOS and Windows always use their single built-in backend (WKWebView / WebView2) and ignore the variable. App.Backend() reports the stack that was actually loaded (the showcase logs it on every start).

The TUOHI_* environment variables were named APPKIT_* in v0.1.0-alpha.1, and the old names are no longer read.

On the GTK4 stack, the file dialogs additionally load libgio-2.0.so.0 the first time a dialog opens (it ships with GLib, so it is present wherever the libraries above are).

Installing the WebKitGTK package pulls GTK and GLib in as dependencies:

  • Debian / Ubuntu: apt install libwebkit2gtk-4.1-0 (GTK3) or libwebkitgtk-6.0-4 (GTK4)
  • Fedora: dnf install webkit2gtk4.1 or webkitgtk6.0
  • Arch: pacman -S webkit2gtk-4.1 or webkitgtk-6.0
  • Nix / NixOS: these libraries are not on the default loader path, so a bare go run outside a shell that provides them fails to load. Add webkitgtk_4_1 (or webkitgtk_6_0) to your buildInputs / dev shell, or expose them through LD_LIBRARY_PATH or nix-ld.

If App.Show reports that none of the libraries could be loaded, the linker cannot find the soname. See what is actually visible to it:

ldconfig -p | grep -E 'libwebkit(2)?gtk|libjavascriptcoregtk|libgtk-[34]'

wrong ELF class: ELFCLASS32 means the library was found but in the wrong architecture - a 64-bit binary was pointed at 32-bit libraries (check your LD_LIBRARY_PATH).

The test suite reflects this: the GUI tests skip themselves when none of these libraries can load, so go test ./... stays green on a box without WebKitGTK instead of failing.

Hello world

package main

import (
	"log"

	"github.com/terva-sh/tuohi"
)

func main() {
	app := &tuohi.App{}
	view := &tuohi.View{
		Debug:  true, // inspector on (App.Debug turns it on for every view)
		Width:  800,
		Height: 600,
		URL:    "data:text/html,%3Ch1%3EHello%20from%20tuohi%3C%2Fh1%3E",
	}
	view.Ready = func() { /* the first page finished loading */ }
	if err := app.Show(view); err != nil {
		log.Fatal(err)
	}
	defer view.Close()

	if err := app.Wait(); err != nil {
		log.Fatal(err)
	}
}

Call Show and Wait from main and run everything else, such as an HTTP server, in goroutines. macOS requires it: AppKit runs only on the process's main thread, tuohi keeps the main goroutine there, and off it Show and Wait return ErrNotMainThread unless a run loop already runs on the main thread. Linux and the BSDs need it too: WebKitGTK 2.54 aborts the process when it is first used off the main thread, so tuohi keeps the main goroutine there as on macOS, and a first Show elsewhere returns ErrNotMainThread. On Windows the goroutine that creates the first window is pinned to its OS thread and becomes the UI thread. View methods such as Eval, Navigate and Close are safe from any goroutine, and Window(func(unsafe.Pointer)) re-enters the UI thread from background work (it hands you the native window handle).

Desktop helpers

Bind

Bindings expose Go values to the page as window.* JavaScript. They are declarative maps - one on the app, one per view - and are bound automatically when a window spawns, deterministically: the App.Bind entries first, then the View.Bind entries, each map iterated in alphabetical key order, so the outcome never depends on Go's map iteration order.

The entry's key is a dotted path that nests variables on the page: dots separate levels under window, so a value bound at app.someAPI.call lives at window.app.someAPI.call. What the entry's value becomes is decided by its kind alone - one value always binds under exactly one name:

  • A Go function becomes a JS function the page calls: view.Bind["sum"] = fn appears as window.sum(...). The function's arity decides whether it ALSO works as a variable:
    • a zero-argument function is a callable getter: call it (window.now()) or read it as a value (await window.now, which calls the Go function with no arguments and resolves to its result) - view.Bind["api.now"] = func() (time.Time, error) { return time.Now(), nil };
    • a one-argument function is a callable setter: call it (window.log(msg)) or assign to it (window.log = msg, which runs the Go function with the assigned value; the assignment expression yields msg itself - await the call form for the result) - view.Bind["api.log"] = func(s string) error { ... }. Functions with other arities are plain callables.
  • A length-2 array of two functions - a getter and a setter ([2]any{getter, setter}) - becomes a property that is readable AND writable while staying wired to Go: view.Bind["app.size"] = [2]any{getSize, setSize} makes window.app.size a value the page can read (const size = await window.app.size, which runs getSize over the bridge) and write (window.app.size = "9px", which runs setSize with the assigned value). The getter takes no arguments, the setter exactly one. To expose only one side, bind a lone function instead: a zero-argument function is a read-only getter, a one-argument function a write-only setter (see above) - view.Bind["app.readOnly"] = getSize reads only, view.Bind["app.writeOnly"] = setSize writes only.
  • Any other value - a bool, number, string, or any JSON-encodable value such as a struct, map or slice - becomes an immutable JS constant bound wholesale under its name: view.Bind["app.meta"] = Meta{Version: "1.2.0"} appears as window.app.meta with window.app.meta.version === "1.2.0".

Nothing is derived from the Go type: structs and maps are never walked, there is no method expansion and no bind:"…" struct tag. A struct or map you want to expose must spell out the names itself - either bind each function at its own dotted key, or bind the whole value as one constant.

A page's calls are dispatched to Go in the order it makes them, so a read issued after a write observes the write (window.count = 1; await window.count). The calls still run off the UI thread, so a blocking binding delays only that view's later calls.

Once the bindings of a page are installed, every object the binding process created is frozen: the namespace containers, the function wrappers and the constant trees are sealed (Object.freeze), so the page cannot mutate the functions or constants it was given. The page's own window is left alone.

Binding names are checked when the window is created, and a bad one fails App.Show loudly instead of producing odd page objects: a dotted name must have non-empty, whitespace-free segments ("api.call" is fine; "a..b", ".x" and "x.y z" are not); a top-level name must not be one of the common window.* built-ins (close, open, name, fetch, document, …), tuohi's own internals (__webview__, anything starting __tuohi) or the page's events global (window.events by default, whatever App.Events renames it to); and a leaf and its namespace cannot both be bound ("api" together with "api.id" is refused, because one would silently destroy the other). A View.Bind entry under a name App.Bind already uses replaces that binding (the view wins); a nil entry removes it.

Only zero-argument functions are awaitable (await window.now): bound functions of any other arity are plain callables, so an accidental await window.fn can never fire a no-argument Go call the function would reject. Assigning to a setter or writing a variable (window.log = msg, window.app.theme = v) runs the Go side, but an ECMAScript assignment expression yields the ASSIGNED VALUE - the Go result is not observable through the expression - so await the CALL form (window.log(msg)) when the result matters. Errors are still visible: if the Go side fails and nothing awaits the call, the rejection is rethrown to the page's console, so a failure is never invisible.

Every bound function follows the same signature rules: no return value, a value, an error, or value and error (the page gets a Promise either way).

App.Bind covers every window the app spawns; View.Bind is per-window and its names win over the app-wide ones. A nil entry in View.Bind unbinds that name for the view (dropping an app-wide binding the window does not want); a nil entry in App.Bind binds nothing.

app.Bind = map[string]any{
	"app.meta": map[string]any{"version": "1.2.0", "features": []string{"tray", "autostart"}}, // frozen constant
}
view.Bind = map[string]any{
	"sum": func(a, b int) int { return a + b }, // window.sum(...)
	"cfg.theme": "#203040",                     // window.cfg.theme
}

// Accessors: state stays in Go, the page reads and writes through the name.
theme := "ocean"
readTheme := func() (string, error) { return theme, nil }
writeTheme := func(s string) error { theme = s; return nil }
view.Bind["app.theme"] = [2]any{readTheme, writeTheme} // read + write
view.Bind["app.themeRO"] = readTheme                   // zero-arg func: read only
view.Bind["app.themeWO"] = writeTheme                  // one-arg func: write only

// app.Show(view) installs window.app.meta (constant), window.sum,
// window.cfg.theme (constant), the app.theme* accessors and freezes the
// namespace.
Application lifecycle

There is no automatically created app window: the App only provides the lifecycle around the windows you spawn. Windows are declarative - define a View (geometry, settings, bindings all live on the struct), hand it to App.Show, and keep the same pointer as the window's handle afterwards. Then block with App.Wait, which runs the platform UI loop:

  • The first App.Show (or App.Wait) performs the one-time app initialization: platform init and the best-effort runtime icon (App.Icon). Content serving starts per window, later: each view is served from App.FS through the platform's app scheme.
  • App.Start, when set, runs on the UI thread as Wait starts, before the loop dispatches any event. Set up services that need the UI thread there, such as the tray; an error from it ends Wait.
  • Wait returns when App.Quit is called, or when the last window spawned with App.Show closes and App.Exit is true. Exit defaults to false, so an app keeps running after its windows are gone (tray/menu-bar applications, background helpers) until it calls App.Quit.
  • App.Quit ends a running application from any goroutine (Wait then returns). Calling it before Wait is harmless.
  • The App is both the configuration and the scope of the application; its exported settings are committed the first time an App method is called.
app := &tuohi.App{Name: "My App", Exit: true} // end when the window closes
view := &tuohi.View{
	Width: 1280,
	Height: 800,
	URL:   "https://example.com", // the first page; loaded by App.Show
}
if err := app.Show(view); err != nil {
	log.Fatal(err)
}
defer view.Close()

if err := app.Wait(); err != nil { // returns when the window closes or Quit is called
	log.Fatal(err)
}
Window close and native state

View.CloseRequested intercepts the window's user/keyboard close action (GTK delete/close request, macOS Close/⌘W, Windows Close/Alt+F4) and View.RequestClose, for custom chrome. Nil preserves normal closing. The callback runs on the UI thread and must return promptly:

view.CloseRequested = func(request *tuohi.CloseRequest) {
    request.Cancel()
    view.Hide() // same native window and renderer; view.Show restores it
}
view.WindowChanged = func(state tuohi.WindowState) {
    // Native top-level activation, independent of focus inside the web page.
    // state.Visible, state.Active, state.Closed
}

On macOS, hiding a fullscreen window first waits for its fullscreen exit, then hides the same window and renderer. Show cancels a pending hide; App.Quit and Close invalidate it during teardown. Native visibility callbacks during the transition do not represent a new reveal request.

The consumer decides whether a hidden window remains reachable. A tray's registration cannot prove its icon is visible; use the tray availability API and your own background-running policy before hiding the only window.

A callback that returns without a decision allows closing. Cancel keeps the window alive. Defer keeps the request pending after the callback returns; a worker can later Allow or Cancel. The first decision wins and the methods return whether they accepted it. Repeated close actions coalesce while pending. request.Done() closes on decision or invalidation so workers can stop waiting. An unresolved deferred request deliberately keeps that window open. View.Close is unconditional, invalidates outstanding requests immediately and queues native teardown when called off the UI thread. App.Quit invalidates requests and ends Wait without consulting close policy; it retains its existing run-loop behavior. Native application/session termination is not intercepted. Deferred decisions target only their original window lifetime.

WindowChanged receives an initial snapshot after the engine is published, changes with duplicate snapshots suppressed, and one terminal Closed snapshot on native destruction. view.WindowState() safely reads the latest snapshot from any goroutine; an unshown/closed view reports Closed. Callbacks run on the UI thread without internal locks; changing native state inside one can produce a nested callback. Configure callbacks before App.Show.

Visible describes the toolkit's mapped/shown window state (excluding native minimization where exposed); it does not report occlusion, another workspace, or whether the user can see any pixels. Linux uses GTK map/unmap and is-active signals; minimization/workspace reporting depends on the compositor and toolkit. macOS uses key-window, minimization and occlusion-change notifications while sampling isVisible, plus explicit show/hide operations. Windows uses WM_ACTIVATE, WM_SHOWWINDOW and WM_SIZE. Native activation cannot guarantee that the consumer's relevant conversation has focus: combine it with page state. Callbacks describe tuohi-owned windows; internal host embedding retains the host's close and lifecycle policy.

examples/lifecycle demonstrates retained renderer state, close cancellation, deferred allow and unconditional quit through public APIs.

The per-window knobs tuohi reads at window creation live directly on the App and the View - there is no nested settings struct:

  • App.Debug and View.Debug (default false) - the dev-tools / inspector switch. View.Debug opens one window's inspector; App.Debug applies app-wide; the two OR together. The TUOHI_DEBUG=1 environment variable turns the tools on for every view only when the program sets App.AllowEnvDebug, so a release build is not opened to the inspector by whoever launches it. Backend mapping: WebView2 DevTools, WebKitGTK enable-developer-extras, WKPreferences.developerExtrasEnabled.
  • View.FirstMouse (default false) - macOS first-click passthrough (see below).
  • App.Events (default "" → "events") - the page-side JS global of the events bridge, window.events with on/off/emit.
view := &tuohi.View{
	Debug: true, // inspector on for this window only
}

Page JavaScript is always enabled on every backend - there is no disable knob, so the engines' JS switches stay at their on defaults. Each backend keeps a few tuned defaults of its own (Linux media-stream + JS clipboard access, macOS fullscreen, WebView2's hidden status bar), applied inline when the view is created; they are not common knobs because the engines do not agree on them.

Window content is served by your app through ONE app-scoped filesystem (App.FS), and every view loads it from the same uniform app:// origin as a secure, cross-origin-isolated context:

  • Windows serves the filesystem through WebView2's https vhost for the custom app:// scheme; the vhost responses carry the isolation headers.
  • Linux/BSD is scheme-first too: the registered custom app:// scheme serves the filesystem (WebKitGTK cannot attach the isolation headers to scheme responses, so a scheme-served Linux page is not crossOriginIsolated; SharedArrayBuffer still works through the JSC option).
  • macOS always serves over a per-view loopback http://localhost server - WKWebView cannot make a custom scheme a secure context, and a long-standing WebKit bug keeps SharedArrayBuffer off plain WKWebView pages - while the loopback origin is a secure, isolated context by itself. App.HTTP opts Linux and Windows into that same loopback origin too. The server lives as long as its view and listens on 127.0.0.1 only. It answers only requests that name its own origin and carry its per-server token, which the view's first navigation exchanges for a cookie, so neither a web page reaching the port through DNS rebinding nor another user's process can read App.FS through it. A process running as the same user can read App.FS from the binary or from memory anyway, so keep secrets out of it.

Every response - loopback and vhost alike - carries the cross-origin-isolation headers (Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: require-corp, Cross-Origin-Resource-Policy: same-origin), so every app page is cross-origin isolated and SharedArrayBuffer is available on every platform (on Linux the engine additionally enables the JSC useSharedArrayBuffer option, which some WebKitGTK builds gate behind regardless of isolation). Because COEP is require-corp, cross-origin subresources must carry a CORP header; cross-origin fetch follows the remote's CORS headers exactly like any browser page.

Frameless windows and drag regions

View.Frame is false by default, so windows are frameless: no OS frame of any kind (no title bar, no system buttons) and a fully transparent background - the desktop shows through everywhere the page does not paint. Set Frame: true on the View for the ordinary OS-framed, opaque window. On a frameless window your page is the chrome. Mark the movable boxes with the custom CSS attribute -app-region:

<style>
  .titlebar {
    -user-select: none;   /* the drag swallows clicks; no text selection */
    -app-region: drag;
  }
  .titlebar-button {
    -app-region: no-drag;
  }
</style>
<div class="titlebar">
  My App
  <button class="titlebar-button">×</button>   <!-- still clickable -->
</div>
  • -app-region: drag - the box moves the window when dragged; clicks inside it are swallowed (like a real title bar). no-drag always wins, so a button marked no-drag inside a draggable bar stays interactive.
  • Double-clicking a drag box toggles maximize/unmaximize (like a native title bar) on every platform - macOS, Linux (GTK3/GTK4) and Windows.
  • The legacy -webkit-app-region (Electron) and -webview-app-region spellings are accepted as aliases, so existing stylesheets keep working unchanged.
  • The attribute is tracked at runtime: styles, element positions and window size are watched, so regions follow scrolling, resizing and DOM changes.
  • A resizable frameless window keeps native edge/corner resizing, including the correct resize cursor when hovering the edges/corners (State controls resizability as usual; StateFixed turns edge resizing off).

The showcase application runs frameless by default (fully transparent, custom chrome) with a complete runnable title bar (drag anywhere on it, click the dot to close) - the same chrome on every platform.

Serving your UI (App.FS)

Set App.FS before the app scope opens (it is committed once, like every App setting) and tuohi serves your content to every view from the uniform app:// origin:

//go:embed ui
var uiFS embed.FS

app := &tuohi.App{
	Name: "My App",
	FS:   uiFS, // the whole UI: HTML, CSS, JS, assets
}
view := &tuohi.View{Debug: true}
if err := app.Show(view); err != nil {
	log.Fatal(err)
}
view.Navigate("app://index.html") // same uniform URL on every platform

The host after app:// is an arbitrary origin identity (any host works); the path selects the file in the filesystem: navigating to app://index.html serves uiFS's index.html with the right Content-Type, app://styles/app.css serves styles/app.css, and so on. A path that is not in the filesystem is answered as "not found".

Why not just file:// or inline HTML? Because neither is a secure context, and a large part of the web platform is gated behind one:

Approach Port? Secure context?
file:// / inline HTML no port no - crypto.subtle is undefined, getUserMedia/geolocation are blocked, localStorage is unreliable, routing is hash-only
app:// filesystem (this) no external port yes, and cross-origin isolated - localStorage, crypto.subtle, SharedArrayBuffer, getUserMedia, and path routing all work

Each backend uses its own native mechanism. Windows has no per-scheme secure flag, so there the scheme is served over a per-scheme https://<scheme>.localhost virtual host (an https origin is a secure context) and Navigate rewrites app://… to it; the vhost responses carry the isolation headers. Linux serves the same way through its registered custom app:// scheme (WebKitGTK cannot add the isolation headers to scheme responses, so a scheme-served Linux page is not crossOriginIsolated; SharedArrayBuffer still works via the JSC option). macOS always serves over a per-view loopback http://localhost server - WKWebView cannot make a custom scheme a secure, isolated context and a long-standing WebKit bug keeps SharedArrayBuffer off plain pages - and App.HTTP opts Linux and Windows into that same loopback origin, whose responses carry the COOP/COEP/CORP isolation headers. SharedArrayBuffer is available on every platform. The showcase serves its own UI through this one App.FS on every platform.

The main menu (macOS)

On macOS, tuohi installs a standard main menu when it creates the first window, so the usual keys work:

  • the application menu, with About, Hide (⌘H), Hide Others (⌥⌘H), Show All, and Quit (⌘Q), which calls App.Quit;
  • Edit, with Undo (⌘Z), Redo (⇧⌘Z), Cut (⌘X), Copy (⌘C), Paste (⌘V), and Select All (⌘A), which a page's text fields need;
  • Window, with Minimize (⌘M) and Close (⌘W), which also work on frameless windows.

The application menu takes its title from App.Name, or the process name. There is no public menu configuration API yet. If an embedding host or bundled launch already installed a main menu, tuohi adds only missing Edit, Quit and Close commands. Existing menus, action targets and key equivalents are preserved, including host selectors using the standard shortcuts. Added Edit commands use the native responder chain. A host-provided Quit retains its own policy; a supplemented Quit calls App.Quit, so App.Wait returns for the consumer to join its cleanup. Close follows CloseRequested on framed and frameless windows.

First click on an inactive window (macOS)

On macOS a click on a window that does not have focus is spent activating the window: it never reaches the page. For a control panel, a dashboard or a player - anything the user clicks in passing - that reads as a broken button, and the user ends up clicking twice.

app := &tuohi.App{}
view := &tuohi.View{FirstMouse: true}
if err := app.Show(view); err != nil {
	log.Fatal(err)
}

It is opt-in, and deliberately so: the AppKit default is what protects destructive interfaces. In a drawing tool, an editor, or any window with a delete button, a click that merely raises the window must not also press whatever happens to sit under the cursor. Leave it off when a stray first click could destroy something.

macOS only; ignored on Linux and Windows, where a click on an inactive window already reaches the content. The mechanism is a WKWebView subclass answering YES to acceptsFirstMouse: - AppKit asks the view under the cursor, so there is no window-level or runtime switch for it.

It is not always enough. AppKit delivers the click to the view, but WebKit hosts the page in another process and does not always forward that first click to the DOM while the window is not key. When a program knows it took its own focus away (it launched a window that activates, say), the reliable answer is to take the focus back:

w.Focus(true) // front the window and activate the app; the next click just works

Raise is the blunt instrument and should be used sparingly - stealing focus from someone typing in another application is worse than the second click it saves. Focus is the other half: it moves the caret inside the page.

Events

A lightweight publish/subscribe bridge between Go and JavaScript, layered on Bind/Init/Eval with no extra native code. Every spawned View carries its own bridge - App.Show installs it at creation, so w.On/w.Off/ w.Emit always work and there is no separate handle to create. An event reaches every listener on both sides exactly once.

view := &tuohi.View{}
if err := app.Show(view); err != nil {
	log.Fatal(err)
}
w := view

// Go subscribes; each argument arrives as raw JSON to decode as you like.
w.On("ui:save", func(args ...json.RawMessage) {
	var name string
	_ = json.Unmarshal(args[0], &name)
	log.Println("save requested for", name)
})

// Go emits to JS - safe to call from any goroutine.
_ = w.Emit("app:ready", map[string]any{"version": 3})
// JS subscribes to Go events and emits its own.
events.on("app:ready", (info) => console.log("ready", info.version));
events.emit("ui:save", "untitled.txt");

On returns a function that cancels that one subscription; Off(name) drops all of them. Go handlers run on the goroutine that emitted (or the binding goroutine for events coming from JS), so re-enter the UI thread with Dispatch if a handler touches the window. The showcase Events card shows both directions live.

File dialogs

Native open/save/directory dialogs live in the standalone dialog package (github.com/terva-sh/tuohi/dialog), which shows the panels without any window. A single entry point, dialog.Open, presents whatever panel dialog.Options.Type selects (TypeOpen, TypeOpenMultiple, TypeSave or TypeDirectory) from the program's main thread and returns the chosen paths (or nil when cancelled). The View exposes the same through one Dialog method, which dispatches onto the UI thread for you and blocks the calling goroutine:

paths, _ := w.Dialog(dialog.Options{
	Type:    dialog.TypeOpen,
	Title:   "Open an image",
	Filters: []dialog.FileFilter{{Name: "Images", Extensions: []string{"png", "jpg"}}},
})
paths, _ = w.Dialog(dialog.Options{Type: dialog.TypeOpenMultiple})     // multi-select
paths, _ = w.Dialog(dialog.Options{Type: dialog.TypeSave, Filename: "untitled.txt"})
paths, _ = w.Dialog(dialog.Options{Type: dialog.TypeDirectory})

Backends: NSOpenPanel/NSSavePanel (macOS), IFileOpenDialog/ IFileSaveDialog (Windows), GtkFileChooserNative (Linux). Each shows the modal dialog, blocks the calling goroutine, and returns the chosen path(s) or nil on cancel. Call the View method from Bind callbacks (a background goroutine), never from the UI thread. The showcase Dialogs card drives all four panel kinds; examples/dialog shows the standalone package on its own.

System tray

A tray icon with a menu is the tray package's job (github.com/terva-sh/tuohi/tray). Standalone, tray.Run owns the process's UI event loop and blocks until tray.Stop (see examples/tray); macOS, Windows and Linux are implemented. Linux runs over a D-Bus StatusNotifierItem + com.canonical.dbusmenu export, so no desktop is excluded.

The Config is fully declarative and read once by Run/Set - PNG icon (plus a DarkModeIcon for Windows theme switching and a TemplateIcon for macOS menu-bar recoloring), tooltip, tray-level OnClick/OnDoubleClick/ OnRightClick, and the whole menu tree with Checkbox, Disabled, Separator, Submenu and per-item Icon entries:

app := &tuohi.App{
	Name: "my app",
	Exit: true, // end the process when the last window closes
}
cfg := tray.Config{
	Icon:    trayPNG,
	Tooltip: "my app",
	Items: []tray.Item{
		{Label: "Open", OnClick: openUI},
		{Separator: true},
		{Label: "Quit", OnClick: app.Quit},
	},
}
app.Start = func() error { return tray.Set(cfg) } // on the UI thread, before the loop
view := &tuohi.View{
	Width:  1024,
	Height: 768,
}
if err := app.Show(view); err != nil {
	log.Fatal(err)
}
view.Navigate("https://example.com")
err := app.Wait() // runs the loop, which dispatches the tray's menu events
tray.Remove()
if err != nil {
	log.Fatal(err)
}

tray.Set/tray.Remove show and hide the icon without owning the loop: call Set from the UI thread and let your own loop dispatch the menu events, which is what App.Start and App.Wait give you. A menu item's OnClick runs on the UI thread; keep it short or hand the work to a goroutine.

The tray does not take its icon from App.Icon: set tray.Config.Icon.

Only one tray may be active per process; a second Set/Run returns ErrAlreadyRunning. tray.Bounds reports the icon's on-screen rectangle where the OS exposes one (Windows). See tray/README.md for the full API table, threading rules and per-platform behavior, and examples/tray for a runnable example.

Desktop notifications

OS-level notifications are the notify package's job (github.com/terva-sh/tuohi/notify): title + message, with no window and no tray icon required. macOS uses NSUserNotificationCenter, Windows a Shell_NotifyIconW balloon, Linux org.freedesktop.Notifications over D-Bus (with a notify-send/kdialog fallback). The standalone package goes beyond the plain notification: ShowOpts attaches a custom icon and an urgency, Alert posts a critical notification with the platform's attention sound, and Beep sounds a tone directly (PC speaker on Linux, kernel beep on Windows, system beep on macOS). App has no notification method; call the package directly. It names the source per call, notify.Show(name, title, message), where an empty name falls back to the executable's name, and it is safe from any goroutine:

app := &tuohi.App{Name: "backup tool"}
if err := notify.Show(app.Name, "Backup finished", "Snapshot complete"); err != nil {
	// errors.Is(err, notify.ErrUnsupported) on unsupported platforms
}

Reach for notify.ShowOpts/notify.Alert/notify.Beep when you need icons, urgency or a sound. See notify/README.md and examples/notify for a runnable example.

Examples

examples/ holds tuohi's reference programs. They are a Go module of their own, so they use tuohi the way a consumer does, from outside and through its public API only; make check-examples refuses an example that reaches into a tuohi internal package. CI builds them for every target and, wherever a window can open, runs them as programs, so a change that breaks how a consumer uses tuohi fails in tuohi's own CI. Run them from examples/:

cd examples
go run ./loopback                  # an existing net/http interface in a window
go run ./showcase                  # every feature in one borderless window
go run ./showcase -http            # same, served over a per-view loopback
                                   # http://localhost server (App.HTTP) -
                                   # Linux/Windows opt in; macOS always does
go run ./showcase -tray            # same + a tray menu (Show / Hide / Quit)
go run ./showcase --framed         # same, with the OS window frame
go run ./showcase --selftest       # showcase + automated self test (exit 0/1)
go run ./tray                      # tray icons, menus, checkboxes
go run ./notify                    # notifications
go run ./managednotify             # managed activation, replacement and dismissal
go run ./dialog                    # all four native file panels

loopback is the shape terva uses: the program keeps its net/http server on 127.0.0.1, points the window at it, and binds what the page needs from Go. go run ./loopback -check loads the page, checks that it reached both the server and Go, and exits 0 or 1.

For consumer tests, set App.OpenExternal func(url string) error before the first App method. It replaces the OS opener for App.Open and outside links after the existing URL policy checks. App.Open returns the hook's error; navigation handoffs run it on a goroutine and log failures. Calls may overlap. The loopback example's -check mode records both handoff paths through this hook, so its native GUI test opens no browser. Leaving it nil keeps the default system browser/mail behavior.

showcase is a single borderless, cross-platform window: custom chrome whose maximize button toggles into a restore button, its UI served through the one app-scoped App.FS, the JS bridge, events, clipboard, autostart, native dialogs, notifications, an opt-in tray (-tray) that hides, un-minimizes and shows the window, and open/reveal. The page is loaded from app://app/index.html on every platform - scheme-first on Windows and Linux (Linux's scheme is not crossOriginIsolated, but SharedArrayBuffer works via the JSC option), macOS via the loopback origin (WKWebView SAB bug), with SharedArrayBuffer available everywhere.

The tray is opt-in via -tray: by default the windowed showcase keeps its Dock/taskbar icon. Configuring a tray runs the app as a menu-bar "accessory" app (no Dock icon) on macOS, so pass -tray only when you want that hide/show-from-menu example.

--selftest drives a real view and is the project's UI-automation hook: the page exposes stable ids and a #selftest suite whose verdicts are reported back to Go (it prints selftest N/N passed and exits 0/1). The suite covers the bridge add/echo, every binding form (constant, function, accessor pair), the events round trip, clipboard, autostart, the drag region, stable ids, the isolated context (SharedArrayBuffer) and the maximize toggle.

Each example opens real platform UI (WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS). The showcase and loopback tests run their program only when TUOHI_REQUIRE_GUI=1 says a window can open, as just test-gui and the GUI jobs in CI do, and skip otherwise.

Testing

go test ./...

This runs the pure-logic unit tests (binding marshalling, single-instance, events) plus the per-platform GUI smoke tests, which drive a real view (WKWebView / WebKitGTK / WebView2). Those GUI tests skip themselves when the system view cannot run here - no display, or the libraries are not installed (WebKitGTK on Linux, the Edge WebView2 Runtime on Windows) - so the command above stays green on a headless or minimal box instead of failing.

For a fast, headless run, -short skips the GUI scenarios on every platform (each drives a real run loop and can take a few seconds):

go test -short ./...

To actually exercise the GUI tests on Linux, install WebKitGTK and run under a virtual display:

xvfb-run -a go test ./...

Building on Windows

Use windowsgui to hide the console window:

go build -ldflags="-H windowsgui" .

Project layout

  • lib_darwin.go / lib_unix.go / lib_windows.go - the per-platform engine layer: every direct platform-API call (WKWebView and WebKitGTK through the purego objc/GTK bindings, WebView2 and Win32/COM), per-OS view init (ensureInit on macOS/Linux, ensureWinInit/ensureCOMInit on Windows) and the per-backend bridgePostFn; the per-OS newView(v *View, serve) window constructor (which registers the app scheme serving the app's App.FS, starts the per-view loopback server for HTTP-served views and applies the window settings inline) lives here. Nothing engine-independent lives here
  • view.go - the view/window API surface: the declarative View struct (window configuration + post-spawn handle and methods), the App.Show entry point, geometry + State, the Bind maps, binding/JS-bridge marshalling, the View Dialog method (over dialog/), the internal content request/response types, and the CSS drag-region machinery
  • app.go (+ app_{darwin,unix,windows}.go) - the whole application scope and app-scoped code: the App type (configuration + runtime scope with lazy commit and one-time ensureInit), App.Wait/Show and the App.Start hook, Open/Reveal, the runtime icon, the serveAppFS content resolver for App.FS and the remaining framework glue (the per-view events bridge On/Off/Emit, the app-wide App.Bind map)
  • examples/ - the reference programs, a module of their own (see Examples): showcase (every feature in one window, with a --selftest UI-automation hook), loopback (an existing net/http interface in a window), and tray, notify and dialog
  • tray/ - the standalone declarative system-tray package; macOS/Windows/ Linux backends
  • notify/ - the standalone desktop-notification package: plain Show, ShowOpts with icon/urgency, Alert and Beep
  • instance/ - single instance: Acquire, Lock.Release and Send
  • autostart/ - launch at login: New(id) and the per-platform backends
  • clipboard/ - text Copy and Paste, and PasteImage
  • dialog/ - the standalone native file-dialog package

tuohi loads the OS view framework directly and bundles or extracts no native library, so there is no extracted file to verify or swap.

Documentation

Overview

Package tuohi is a pure-Go foundation for web-based desktop applications: it embeds the platform View (WKWebView on macOS, WebKitGTK on Linux, WebView2 on Windows) behind a single Go API - windowing and app windows, drag regions, custom URL schemes, bindings and events, opening URLs and native file dialogs - all cgo-free.

The package is the window. The desktop services live in subpackages that do not import it: tuohi/instance (single instance), tuohi/autostart (launch at login), tuohi/clipboard, tuohi/notify and tuohi/tray. A service that needs the UI thread, such as the tray, is set up in App.Start.

The main thread

Call App.Show and App.Wait from main, or from functions main calls, and run everything else in goroutines. macOS requires it: AppKit accepts UI work only on the process's main thread, and the package keeps the main goroutine there from its init on. Off the main thread, Show and Wait return ErrNotMainThread unless a run loop is already running on the main thread to hand the work to. Linux and the BSDs require it too: WebKitGTK 2.54 aborts the process when it is first used off the main thread, so the package keeps the main goroutine there as well, and a first Show elsewhere returns ErrNotMainThread. Windows has no such rule, but a program written this way runs on all of them. A program that serves its interface itself, over loopback HTTP, starts the server in a goroutine and opens the window on main:

func main() {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		log.Fatal(err)
	}
	go http.Serve(ln, handler)

	app := &tuohi.App{Exit: true}
	view := &tuohi.View{URL: "http://" + ln.Addr().String() + "/"}
	if err := app.Show(view); err != nil {
		log.Fatal(err)
	}
	if err := app.Wait(); err != nil {
		log.Fatal(err)
	}
}

Other View methods, such as Eval, Navigate and Close, are safe from any goroutine; they are marshalled to the UI thread.

Source layout: app*.go holds the application scope - the App type (configuration + runtime context), its app-scope methods (Show, Wait, Open/Reveal, Backend) and the per-OS app internals (app icon, Open/Reveal); view*.go holds the view/window API surface (the define-first View struct and its methods, geometry and State, scheme types, App.Show glue, the View Dialog method, drag regions); bind.go (registry model + value conversion), bind_gen.go (the generated JS) and bind_evt.go (the events bridge) hold the binding and events machinery; engine.go holds the engine interface every platform satisfies and the code they share; and lib_darwin.go, lib_unix.go and lib_windows.go are the per-platform engine layer, which talks to WKWebView, WebKitGTK and WebView2 and the platform APIs.

Platform code lives in *_unix.go / *_windows.go / *_darwin.go files; a capability that a platform cannot provide returns an Err* sentinel or is a documented best-effort no-op rather than failing at compile time.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotMainThread = errors.New("tuohi: the UI runs on the main thread; call App.Show and App.Wait from main")

ErrNotMainThread is returned by App.Show and App.Wait called off the process's main thread when nothing there can take the work. On macOS that is when no run loop is running on the main thread: AppKit runs only there. On Linux and the BSDs it is a first Show, before any window exists: WebKitGTK 2.54 aborts the process when it is first used off the main thread. Call Show and Wait from main and run anything else, such as an HTTP server, in a goroutine (see the package doc). It is never returned on Windows.

View Source
var ErrScheme = errors.New("tuohi: refused URL scheme")

ErrScheme is returned by Open when the URL's scheme is not in the allow-list.

Functions

This section is empty.

Types

type App

type App struct {
	// Debug turns the platform web inspector / developer tools on for every
	// window of this app (the app-wide default for View.Debug): set it once
	// for "every window is debuggable". A view's own View.Debug ORs over it,
	// and so does the TUOHI_DEBUG=1 environment variable when AllowEnvDebug
	// is set.
	//
	// Like every App field it is committed when the app scope opens (later
	// edits have no effect) and it is read exactly once per view, at window
	// creation. Backend mapping - WebView2 AreDevToolsEnabled (Windows),
	// WebKitGTK enable-developer-extras (Linux), WKPreferences
	// developerExtrasEnabled (macOS).
	Debug bool

	// AllowEnvDebug lets whoever launches the program turn the developer
	// tools on for every view by setting the TUOHI_DEBUG=1 environment
	// variable. It is off by default, so a release build opens its web
	// inspector only when the application itself sets Debug or View.Debug:
	// the environment belongs to the person launching the program, and the
	// inspector reaches every page and binding. Set it in a development
	// build, or when debugging an installed build is worth that. It is
	// committed when the app scope opens, like every App field.
	AllowEnvDebug bool

	// Events names the JavaScript global the tuohi events bridge installs on
	// every page of this app: window.<Events> with on/off/emit (see
	// View.On/Off/Emit). Empty (the default) uses the name "events". The name
	// is fixed when each view is created.
	Events string

	// Name is the application name, used where the OS asks for one: as the
	// title of a window that has no other (see View.Title), and under GTK3 on
	// Wayland as the name of the desktop entry DesktopEntry installs.
	Name string

	// Icon is a PNG image for the running application, applied on a
	// best-effort basis wherever the platform supports it at runtime (macOS
	// Dock, Linux GTK3/GTK4 window icons, ...). It is applied once, when the
	// app scope opens and before the first window exists. When Icon is unset
	// the embedded tuohi mark is used instead (unexported; tuohi applies it
	// itself), so a tuohi application always has a process face unless it
	// brings its own. Unlike the per-window page icon it sets the face of the PROCESS; a
	// stable, runtime process icon is intentionally a best-effort feature
	// because it is hard to keep identical across all platforms. An unset icon
	// is silently ignored; so are environments that cannot take a runtime icon
	// (Windows reads the icon from the executable's own resources). Under an
	// X11 window manager the icon is pushed per window; under Wayland, where
	// the protocol has no per-window icons, GTK4 (>= 4.20) sends pixels via
	// the xdg-toplevel-icon protocol. A GTK3 window on Wayland shows the icon
	// only through a desktop entry: see DesktopEntry. A PNG that cannot be
	// decoded is never fatal.
	Icon []byte

	// DesktopEntry lets tuohi install a hidden desktop entry and a themed
	// copy of Icon under the user's data directory (XDG_DATA_HOME, by default
	// ~/.local/share), named after Name, so that a GTK3 window on Wayland
	// shows the icon. Wayland has no per-window icons, and the compositor
	// finds a window's icon through the desktop entry that matches its
	// app_id; GTK3, unlike GTK4, cannot send the pixels itself.
	//
	// It is false by default, and then opening a window writes nothing under
	// the user's home directory. It has no effect anywhere but GTK3 on
	// Wayland. An entry of that name that tuohi did not write, in the user's
	// data directory or a system one (XDG_DATA_DIRS), such as one an
	// installed package ships, is left alone and used as it is; once such an
	// entry appears, the one tuohi wrote is removed so that it is the one used.
	// The files stay after the application exits.
	DesktopEntry bool

	// Bind holds the application's declarative bindings: every entry is bound
	// onto each view App.Show creates, so one entry here covers all windows.
	// Keys and values follow the rules [View.Bind] documents: a dotted key
	// names a nested page variable, and the value's kind decides whether it
	// becomes a function, an accessor property or a frozen constant.
	//
	// What differs from View.Bind: a nil entry here binds nothing, and a view
	// overrides an app-wide name - or unbinds it with a nil entry - through
	// its own View.Bind map. The entries are applied to every view in
	// alphabetical key order, before the view's own (see App.Show). Like every
	// App field, Bind is committed when the app scope opens (later edits have
	// no effect) and is read once per shown view, at window creation.
	Bind map[string]any

	// FS is the filesystem the application serves to its views - the app's
	// content: HTML, CSS, scripts and anything else the page loads. When it is
	// set (it is read once, when the app scope opens, like every App setting;
	// later edits have no effect), every view shown by App.Show is served
	// from it. The consumer never picks a serving mechanism: navigate the view
	// to the uniform "app://" origin -
	//
	//	w.Navigate("app://app/index.html")
	//
	// - and tuohi serves the file at that path in the filesystem on every
	// platform. A URL with no host, such as a bare "app://" or
	// "app://#route", is read as the root of the "app" host,
	// "app://app/#route", so the page keeps its bridge. The serving is scheme-first on Windows and Linux: WebView2's
	// https vhost for the custom "app" scheme (whose responses carry the
	// isolation headers), the registered custom scheme on Linux (WebKitGTK
	// cannot attach the headers to scheme responses, so a scheme-served Linux
	// page is not crossOriginIsolated - SharedArrayBuffer still works through
	// the JSC_useSharedArrayBuffer option). macOS always serves over a
	// per-view loopback http://localhost server (WKWebView cannot make a
	// custom scheme a secure context, and a long-standing WebKit bug keeps
	// SharedArrayBuffer off plain WKWebView pages); App.HTTP opts Linux and
	// Windows into that same loopback origin. SharedArrayBuffer is available
	// on every platform. A path without a file answers "not found". A nil FS serves
	// no content - the window shows whatever the consumer navigates it to
	// itself.
	//
	// What FS holds is readable by the views it is served to, not private to
	// the process. The loopback server answers only its own view (see HTTP),
	// but another process running as the same user can read FS anyway, from
	// the binary or from memory; keep secrets out of it.
	FS fs.FS

	// HTTP serves the app's content over a per-view loopback http://localhost
	// server instead of the platform's custom "app" scheme. On Linux and
	// Windows it is an opt-in fallback - the native scheme serves them (see
	// the FS doc), so only HTTP-opted windows load their app:// content from
	// the loopback origin. Each such view has a server of its own, listening
	// on 127.0.0.1 only, from the view's creation until it is destroyed. The
	// server answers only requests that name its own origin and carry its
	// per-server token, which the view's first navigation turns into a
	// cookie, so neither a web page reaching the port by DNS rebinding nor
	// another user's process gets anything from it. macOS always serves over the loopback origin
	// (WKWebView cannot make a custom scheme a secure context and cannot
	// provide SharedArrayBuffer on plain pages - a long-standing WebKit
	// bug). Either
	// way the consumer still navigates to the uniform "app://" origin, so
	// HTTP is a purely internal serving choice. Every served response -
	// loopback and vhost alike - carries the cross-origin-isolation headers
	// (COOP/COEP + CORP), so every app page is cross-origin isolated and can
	// use SharedArrayBuffer. Ignored when FS is nil.
	HTTP bool

	// Exit ends the application process when its last window closes: with
	// Exit true, Wait returns as soon as the last window created with
	// App.Show is gone. The default (false) keeps the process alive after the
	// windows close - menu-bar/tray/background applications - and Wait then
	// returns only when App.Quit is called.
	Exit bool

	// Start, when set, runs on the UI thread when Wait starts, before its
	// loop dispatches any event. It is where a service that needs the UI
	// thread is set up, such as a tray icon:
	//
	//	app.Start = func() error { return tray.Set(cfg) }
	//
	// and tear it down after Wait returns (tray.Remove). An error from
	// Start ends Wait, which returns it.
	//
	// Like every App field it is committed when the app scope opens (later
	// edits have no effect).
	Start func() error

	// OpenExternal replaces the system browser/mail opener for App.Open and
	// URLs a view hands outside after applying its navigation policy. Nil
	// uses the platform opener. Set it before the first App method, like
	// other app settings; later assignments do not affect the scope.
	//
	// App.Open calls it synchronously and returns its error. Navigation
	// handoffs call it on a goroutine and log failures; calls may overlap,
	// so the function must be safe for concurrent use. It may call View
	// methods. Disallowed URL schemes never reach it, and a configured hook
	// completely replaces the system call, including when it returns an
	// error. Consumer tests can record handoffs without starting a browser.
	OpenExternal func(url string) error
	// contains filtered or unexported fields
}

App configures a tuohi application and carries its runtime scope.

It is the single application-scoped object: the exported fields hold the application settings (content filesystem, icon, the Start hook, ...) and unexported fields hold the state of the scope (committed settings and the one-time platform initialization). It is conceptually similar to how http.Server holds configuration and context together.

The exported settings are read once - when the first App method is called - and are then committed: later edits to the fields do not affect the running application. That first call also performs the one-time platform initialization (ensureInit) before the requested action runs, so the call that opens the scope should come from the goroutine that will own the UI (the main goroutine).

The app-scoped operations are methods on *App (Show, Wait, Quit, Open, Reveal, Backend); App.Bind is a declarative map instead of a method - see its field doc. This is a deliberate design choice: the App scope is never hidden from the consumer. The desktop services - single instance, autostart, clipboard, notifications and the tray - are not part of App; they live in their own subpackages (see the package doc).

func (*App) Backend

func (a *App) Backend() string

Backend reports which web-engine backend the App scope uses for its views, after the one-time platform initialization has run:

  • "webkitgtk-6.0" or "webkit2gtk-4.1" on Linux - the stack that was actually loaded, honoring the TUOHI_BACKEND environment variable (see README "Linux shared libraries");
  • "WKWebView" on macOS and "WebView2" on Windows, whose single built-in backend ignores the variable.

Like every App method it opens the scope first, so the returned name always matches the loaded backend rather than the requested one. It returns an empty string when the platform could not be initialized.

func (*App) Open

func (a *App) Open(rawurl string) error

Open opens rawurl with the user's default handler: the browser or the mail client. Only http, https, and mailto URLs are allowed. Anything else, including file: and a bare hostname or path with no scheme, returns ErrScheme. To show a local file, use Reveal.

file: is refused because the platform opener runs what it is given: a file: URL for an executable, a .app, a .lnk, or a .desktop file launches it through ShellExecute, NSWorkspace, or xdg-open. Open is also where a view's external links are sent, so its argument may come from a page.

func (*App) Quit

func (a *App) Quit()

Quit asks a running application to terminate: Wait returns and the process may finish. It is safe to call from any goroutine (the UI loop is woken). Calling Quit before Wait is harmless - Wait then returns immediately.

func (*App) Reveal

func (a *App) Reveal(path string) error

Reveal opens the platform file manager with path's location shown: Finder selects the file on macOS, Explorer selects it on Windows, and on Linux the containing folder is opened (selecting the file itself is file-manager specific and not portable). The path must exist.

func (*App) Show

func (a *App) Show(view *View) error

Show presents a configured View: the first time a View is shown, App.Show creates its window and web view (opens/commits the App scope, runs the one-time platform initialization) and registers the View with the App, which manages it from then on. Calling Show again on the SAME View while its window is alive brings it back instead of recreating it: the window is un-minimized, shown and focused (Focus(true)). After View.Close the View is unregistered and reset, so the same View can be Show'n again later.

The View's exported fields (Debug/FirstMouse, URL, Ready, the geometry fields Left/Top/Width/Height/State and the Bind map) are read once, exactly at the first Show; keep the *View afterwards - it is the handle to the shown window.

Show is safe to call from any goroutine. The window is always created on the UI thread: the main thread on macOS, Linux and the BSDs, and on Windows the thread of the goroutine whose Show first created a window, which Show pins to it. On macOS, Linux and the BSDs the first window must be created on the main thread, and a first Show elsewhere returns ErrNotMainThread. A call from any other goroutine hands the whole creation to the UI thread and waits for it, so the View is live when Show returns. That needs a loop running there (App.Wait, or on macOS one another owner runs); with none, Show returns an error and creates nothing. Revealing a View that is already shown is queued and never waits.

A Show of a View whose window is still being created by another Show cannot wait for it. That happens when the creation runs a loop that runs the second Show (WebView2 does while it installs scripts). It returns an error saying so, and the first Show finishes the window.

Every binding is applied while the window is created, deterministically: the app-wide App.Bind entries first, then the view's own View.Bind entries, each map iterated in alphabetical key order, so the outcome never depends on Go's map iteration order. Each entry is ONE name - a function value becomes a callable JS function, any other JSON-encodable value a frozen JS constant - and once the page's binding batch is installed the whole bound namespace is frozen (see makeBinding). A view entry overrides the same app name; a nil view entry unbinds it. The events bridge is installed before the page loads, so View.On/Off/Emit work immediately.

func (*App) Wait

func (a *App) Wait() error

Wait blocks until the application exits. It is the app-level run loop: it opens the app scope once (committing the settings and performing the one-time platform initialization) and then runs the platform UI loop until the application should exit:

  • App.Quit was called, or
  • the last window created with App.Show was closed and App.Exit is true.

With Exit false (the default) the process keeps running after its windows are gone - menu-bar/tray/background applications - and only App.Quit (or the process being killed) ends it.

Create all windows with App.Show and keep every UI call on the goroutine that calls Wait (the main goroutine). Every app, even one with a single window, runs its loop through Wait: View has no Run method of its own.

type CloseRequest added in v0.2.0

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

CloseRequest is one native or View.RequestClose request. Its methods are safe from any goroutine. Only the first Allow or Cancel wins. If the callback returns without a decision or Defer, closing is allowed. Defer keeps the existing window alive until a later decision; repeated requests coalesce. View.Close and App.Quit invalidate the request without waiting for a decision.

func (*CloseRequest) Allow added in v0.2.0

func (r *CloseRequest) Allow() bool

Allow accepts the close, marshaling a deferred decision to the UI thread.

func (*CloseRequest) Cancel added in v0.2.0

func (r *CloseRequest) Cancel() bool

Cancel keeps the existing window and renderer alive. A hide-to-tray handler can Cancel then call View.Hide without rebuilding its page.

func (*CloseRequest) Defer added in v0.2.0

func (r *CloseRequest) Defer() bool

Defer keeps the request pending after CloseRequested returns. The consumer must eventually Allow or Cancel, unless teardown/quit invalidates it first.

func (*CloseRequest) Done added in v0.2.0

func (r *CloseRequest) Done() <-chan struct{}

Done closes when decided or invalidated. It carries no decision; a stale request's Allow, Cancel and Defer return false and cannot affect a new view.

type Permission

type Permission int

Permission names something a page may use only when the application allows it for the view, in View.Permissions. Everything else a page can ask the web engine for, such as geolocation or notifications, is always denied.

const (
	// PermissionCamera lets a trusted page open the camera with
	// navigator.mediaDevices.getUserMedia.
	PermissionCamera Permission = iota + 1

	// PermissionMicrophone lets a trusted page open the microphone with
	// navigator.mediaDevices.getUserMedia.
	PermissionMicrophone

	// PermissionClipboard lets a trusted page read the clipboard from
	// script, with navigator.clipboard.readText or read, without the user
	// confirming each read. WebKitGTK allows a read only on a user gesture.
	// WKWebView never reads silently: it shows the system's Paste button,
	// and a read the user confirms there is the user's own paste, like
	// Command-V, whatever the view lists. So on macOS this permission changes
	// nothing, and an empty list does not stop a paste the user confirms.
	// Copying on a click needs no permission on any engine.
	PermissionClipboard
)

func (Permission) String

func (p Permission) String() string

String returns the permission's name.

type State

type State int

State values configure window sizing and resizing at creation time.

const (
	// StateNone lets the window size freely; zero Width/Height pick the
	// backend default.
	StateNone State = iota

	// StateMin makes Width and Height the minimum bounds.
	StateMin

	// StateMax makes Width and Height the maximum bounds.
	StateMax

	// StateFixed prevents the user from resizing the window.
	StateFixed
)

type View

type View struct {
	// Debug turns the platform web inspector / developer tools on for this
	// window. App.Show ORs it with the app-wide App.Debug - a true on either
	// side opens the tools, and so does the TUOHI_DEBUG=1 environment
	// variable when the App sets AllowEnvDebug.
	//
	// Backend mapping - WebView2 AreDevToolsEnabled (Windows), WebKitGTK
	// enable-developer-extras (Linux), WKPreferences developerExtrasEnabled
	// (macOS).
	Debug bool

	// FirstMouse makes a click on an INACTIVE window reach the page instead
	// of only bringing the window forward.
	//
	// macOS only; ignored elsewhere, where a click on an inactive window
	// already reaches the content. AppKit's default is the opposite of what
	// most web UIs want: the first click is swallowed as activation, so a
	// user who clicks a button in a window that lost focus has to click twice
	// - and the first click looks broken. Turn this on for control panels,
	// dashboards, players and anything else the user clicks in passing.
	//
	// It is OPT-IN because the default protects destructive interfaces: in a
	// drawing tool, an editor, or any window with a delete button, a click
	// that merely raises the window must NOT also press what happens to be
	// under the cursor. Leave it off when a stray first click could destroy
	// something.
	FirstMouse bool

	// URL is the page the window loads first. App.Show navigates the view
	// here once the window exists (Navigate). The URL may be the loopback
	// address of a server the program runs itself, such as
	// http://127.0.0.1:8080/, a uniform "app://" URL served by App.FS, an
	// https:// URL, or a data: URI. Empty (the default) starts a blank window
	// and no navigation happens - and because Ready fires on a completed
	// load, it will not fire until a later Navigate completes.
	URL string

	// Origins lists origins, besides the ones the view is navigated to from
	// Go, whose pages may call the view's bindings and use its events, such
	// as "https://auth.example.com". Every URL given to Navigate, including
	// URL, is trusted already. A page on any other origin, reached through a
	// link, a redirect, or a frame, cannot reach Go. An about:blank page is
	// never trusted, because any page can create one.
	//
	// These are also the only pages the view shows. When a page leads the
	// whole view elsewhere, by a link, a redirect, a script, or a new window,
	// the view stays where it was and an http, https, or mailto URL opens in
	// the system browser or mail client instead; data:, blob:, file:, and
	// other schemes are dropped. about:blank is shown, and frames are left
	// alone. A new window (target=_blank, window.open) never opens: one to a
	// trusted origin loads in this view instead, one to about:blank is
	// dropped, and any other follows the rule above. So a URL that
	// redirects to a sign-in page on another origin
	// needs that origin listed here, or the view stays blank while the
	// browser opens it. A redirect to another scheme or host, such as http
	// to https, or 127.0.0.1 to localhost, counts as another origin.
	//
	// Origins controls top-level documents and bridge access, not all network
	// traffic. On Windows, WebView2 may send a GET before a cancelled
	// NavigationStarting completes; on Linux, response-time decisions can
	// also follow a request. Redirects and meta refresh can therefore contact
	// an untrusted destination even though its page is never shown. Frames
	// and subresources can also contact other origins. See
	// docs/navigation-security.md for the platform limits.
	Origins []string

	// Permissions lists what a page in this view may use that the web engine
	// would otherwise ask the user for: the camera, the microphone, and
	// script reads of the clipboard (see Permission). Only a page on an
	// origin the view trusts (see Origins) receives one, and only if it is
	// listed here. Everything else a page asks for, such as geolocation or
	// notifications, is denied, and no engine shows a prompt of its own.
	// Empty, the default, denies everything. Pointer lock is not a
	// permission: every engine allows it on a user gesture. Element fullscreen
	// is also outside this list: tuohi enables it across engines where their
	// fullscreen API is available, subject to the engine's user-gesture rules.
	//
	// A frame on another origin can ask only when the trusted page delegates
	// the feature to it with the iframe's allow attribute. WebView2 and
	// WKWebView then decide by the frame's own origin and deny it. WebKitGTK
	// does not say which frame asks, so on Linux such a frame asks as the
	// trusted page: delegating a listed permission to a frame grants it
	// there. Delegate only to frames you would trust with the permission.
	//
	// On macOS, a program that lists the camera or the microphone also needs
	// NSCameraUsageDescription or NSMicrophoneUsageDescription in its
	// Info.plist.
	Permissions []Permission

	// Title is the window's title. It names the window in its title bar,
	// the taskbar or Dock, the window switcher, and accessibility tools, so
	// it matters for a frameless window too. SetTitle changes it after Show.
	//
	// When Title is empty the window follows the page instead: it takes the
	// document.title of the page it shows, and App.Name while that page has
	// none. Only a page on a trusted origin (see Origins) can set it, so a
	// page the view does not trust, such as about:blank, leaves the title
	// as it was. A window embedded from the host's own takes only a
	// non-empty Title; the page and App.Name leave it alone.
	Title string

	// Ready, when non-nil, is called exactly once, on the UI thread, the
	// first time a page finishes loading after Show (the initial Navigate to URL
	// or the first later navigation). It is the "the window is fully up"
	// callback: bindings and the events bridge are live by then.
	//
	// Pair it with Eval to run JavaScript on init: Ready fires only after the
	// first page load completed, so the DOM and the page's own scripts are in
	// place and a single Eval reaches them reliably. Injecting script before
	// the document exists is not reliable across the three engines, so tuohi
	// has no declarative JS/CSS injection API - call Eval from Ready to run
	// code when the page comes up.
	Ready func()

	// CloseRequested intercepts user/keyboard close and RequestClose. It runs
	// on the native UI thread and must return promptly. Nil allows closing.
	// Use Cancel then Hide to preserve this view, or Defer and decide later
	// from a worker. View.Close and App.Quit bypass this policy. Configure
	// before Show; changing the field later has no effect on the live window.
	CloseRequested func(*CloseRequest)

	// WindowChanged receives an initial native state after engine publication,
	// subsequent visibility/activation changes, and a terminal Closed state
	// on destruction. It runs on the UI thread without internal locks and
	// must return promptly. Calls may be nested if the callback changes native
	// state (for example Hide). State is updated before each call. Configure
	// before Show. Occlusion and workspace visibility are not reported.
	WindowChanged func(WindowState)

	// Bind holds this view's declarative bindings: every entry is bound onto
	// the web view when App.Show runs. A key is a DOTTED path - dots
	// separate nested variables on the page, so a value bound at
	// "app.someAPI.call" appears as window.app.someAPI.call. What a value
	// becomes is decided by its kind alone:
	//
	//   - a function becomes a JS function the page calls. Its arity decides
	//     whether it ALSO works as a variable: a zero-argument function is a
	//     callable GETTER - call it (`window.name()`), or read it as a value
	//     (`await window.name`, which calls it with no arguments); a
	//     one-argument function is a callable SETTER - call it
	//     (`window.name(v)`), or ASSIGN to it (`window.name = v`, which runs
	//     it with the assigned value; the assignment expression yields that
	//     value, so await the CALL form for the result);
	//   - a length-2 array of two functions ([2]any{getter, setter}) becomes
	//     a readable AND writable property: reading it runs the getter over
	//     the bridge (`const v = await window.name`), assigning to it runs
	//     the setter (`window.name = v`);
	//   - any other value - a bool, a number, a string, or any JSON-encodable
	//     value such as a struct, map or slice - becomes an immutable JS
	//     constant bound wholesale under that name.
	//
	// The page's calls are dispatched to Go in the order it makes them, so a
	// read issued after a write observes the write
	// (`window.count = 1; await window.count`).
	//
	// No part of a Go type is ever bound separately: structs and maps are
	// never walked. The namespace the bindings of a page are installed into
	// is frozen once the batch finishes, so the page cannot mutate the
	// functions, constants or accessor objects it was given.
	//
	// A name set here overrides the same name in the app-wide App.Bind map;
	// a nil entry under a name UNBINDS that name again, removing an app-wide
	// binding this view does not want. Entries are applied in alphabetical
	// key order, after the app-wide entries (see App.Show). A nil entry in
	// App.Bind itself binds nothing.
	Bind map[string]any

	// Frame creates the window with the OS frame - the title bar and system
	// buttons - and an opaque background. The default (false) is a frameless
	// window: NO OS decoration of any kind and a fully transparent background,
	// so the desktop shows through everywhere the page does not paint. The
	// page is then responsible for the window chrome and marks the movable
	// pieces with the "-app-region" CSS attribute ("drag" / "no-drag");
	// see the package documentation. Resizing still works from the window
	// edges unless State is StateFixed.
	//
	// Only meaningful for windows tuohi owns; an embedded window keeps its
	// host's frame and background.
	Frame bool

	// Left/Top optionally place the window on the screen, in pixels. They are
	// best effort, because not every window system lets a client pick its
	// position: supported on Windows and on the GTK3/X11 stack; ignored on
	// GTK4 and on Wayland (the compositor places windows). macOS treats the
	// coordinates as AppKit screen coordinates (origin at the bottom-left).
	// Both zero mean "let the platform decide".
	Left, Top int

	// Width/Height set the initial window size in pixels. Both zero mean the
	// backend default (640x480), matching the behavior of a window that was
	// never sized. With State StateMin/StateMax they are the respective
	// minimum/maximum bounds instead.
	Width, Height int

	// State is the initial resize state (StateNone/StateMin/StateMax/
	// StateFixed): StateFixed makes the window non-resizable (and disables the
	// frameless edge resize), StateMin/StateMax turn Width/Height into bounds.
	//
	// Every geometry field above is applied once, when the window is created -
	// see the note on the View type about window control.
	State State
	// contains filtered or unexported fields
}

View describes one window and its embedded web view. It is tuohi's define-first window object, the same pattern App uses for the application: the exported fields are the configuration of a window that does not exist yet, and App.Show(view) turns it into a live window. Configure a View, hand it to App.Show, and keep the pointer - after Show the same View is the handle to its window (Navigate, On/Off/Emit, Show/Hide, ...), so there is no separate window object to track.

Show reads the fields exactly once, when the window is created; later edits have no effect on the running window (matching how App commits its own settings). Re-showing the same View while its window is alive brings the window back (see App.Show); after View.Close the fields are read again, so the View can be reconfigured and shown anew. Before Show the imperative methods below have no engine behind them and either return a clear error/zero value or panic with "View is not shown".

func (*View) Close

func (v *View) Close()

Close tears the view down for good: it terminates the view's run loop (if one is running) and destroys the native window and web view, then UNREGISTERS the View from the App that showed it and resets its internal state (engine handle, App reference) so the same View can be App.Show'n again later. It is idempotent and safe to call from any goroutine, concurrently too: exactly one call tears the window down. It is a no-op before the View was ever shown - closing an unshown View is not an error. Off the UI thread the teardown is queued and Close does not wait for it.

func (*View) Dialog

func (v *View) Dialog(opts dialog.Options) ([]string, error)

Dialog presents a native, application-modal file panel chosen by opts.Type (open, multi-open, save or directory) and built on the github.com/terva-sh/tuohi/dialog package. Unlike the other View methods it BLOCKS the calling goroutine until the user dismisses the dialog. Call it from a Bind callback - which runs on a background goroutine - or any other goroutine; on the UI thread it runs the panel in place. Off the UI thread it needs a running loop (App.Wait): when none is running, or the loop stops before the panel opens, it returns an error and no panel is shown.

A cancelled dialog - and a dialog that could not be presented (no backend, no display) - returns an empty result and a nil error, per the dialog package contract.

func (*View) Emit

func (v *View) Emit(name string, data ...any) error

Emit publishes an event to every listener on both sides. Each value in data becomes one argument delivered to the handlers (Go handlers receive it as raw JSON, JS handlers as a decoded value). It is safe to call from any goroutine; the JS-side listeners are notified on the UI thread. The page receives the event only while it is a trusted one (see View.Origins): a page on any other origin, and about:blank, receives nothing. Emit returns an error only if a value in data cannot be JSON-encoded, in which case nothing is published.

func (*View) Eval

func (v *View) Eval(js string)

Eval evaluates arbitrary JavaScript asynchronously; the result is ignored. It is the supported way to run code on init: call it from the View.Ready callback, once the first page load completed, so the DOM and the page's own scripts are in place. Injecting script before the document exists is not reliable across the three engines, so tuohi exposes no declarative JS/CSS injection API.

func (*View) Focus

func (v *View) Focus(raise bool)

Focus moves keyboard focus into the web content - so typing, and a screen reader's cursor, land inside the page - and, when raise is true, FIRST brings the window to the front and gives the application focus (the case a program that took focus away from itself needs: it launched a window that activates, finished a job that raised something else). Use Focus(true) sparingly - stealing focus from someone typing in another application is worse than the extra click it saves.

func (*View) Hide

func (v *View) Hide()

Hide removes the window from the screen AND from the taskbar / window list - the classic "hide to tray" behavior: the process keeps running and the window stays alive until Show brings it back. On macOS a fullscreen window exits fullscreen before hiding; Show cancels that pending hide. Safe to call from any goroutine.

func (*View) Maximize

func (v *View) Maximize()

Maximize enlarges the window to fill the available screen area. On macOS it performs the native zoom, which is a TOGGLE: calling Maximize on an already-zoomed window restores its previous size. Safe to call from any goroutine.

func (*View) Minimize

func (v *View) Minimize()

Minimize shrinks the window to the taskbar / Dock (on macOS it is miniaturized into the Dock). Show restores it. Safe to call from any goroutine.

func (*View) Navigate

func (v *View) Navigate(url string)

Navigate loads the given URL in the view. The URL may be an "app://" URL served by App.FS, an https:// URL, a properly encoded data URI, or any other URL the platform engine accepts. An "app://" URL with no host loads from the "app" host: "app://#route" is "app://app/#route". Examples:

v.Navigate("https://github.com/terva-sh/tuohi")
v.Navigate("app://app/index.html")
v.Navigate("data:text/html,%3Ch1%3EHello%3C%2Fh1%3E")

func (*View) Off

func (v *View) Off(name string)

Off removes every Go handler subscribed to the named event. It does not affect the page's own JS listeners.

func (*View) On

func (v *View) On(name string, handler func(args ...json.RawMessage)) (cancel func())

On subscribes handler to the named event and returns a function that cancels just this subscription. The handler receives the event's arguments, each as the raw JSON the emitter sent, to unmarshal into whatever type it expects. Handlers for a JS-originated event run on the binding goroutine; handlers for a Go-originated event run on the goroutine that called Emit. Re-enter the UI thread with Dispatch if a handler touches the window. Before Show the returned cancel is a no-op.

func (*View) RequestClose added in v0.2.0

func (v *View) RequestClose()

RequestClose asks to close through CloseRequested, for example from custom window chrome. It is safe from any goroutine and queues onto the UI thread. It does nothing before Show or after Close. Close tears down unconditionally.

func (*View) SetTitle

func (v *View) SetTitle(title string)

SetTitle sets the window's title, as View.Title does at Show. An empty title hands the window back to the page: it follows the page's document.title again, or App.Name while the page has none. Safe to call from any goroutine.

func (*View) Show

func (v *View) Show()

Show makes the window visible again and brings it to the front, putting it back into the taskbar / window list after Hide, or restoring it after Minimize. Safe to call from any goroutine (the backends marshal to the UI thread).

func (*View) Unmaximize

func (v *View) Unmaximize()

Unmaximize restores a maximized window to its previous normal size (the inverse of Maximize). It is a no-op when the window is not maximized. Safe to call from any goroutine.

func (*View) Unminimize

func (v *View) Unminimize()

Unminimize restores a minimized window to its normal on-screen state (the inverse of Minimize). It is a no-op when the window is not minimized. Safe to call from any goroutine.

func (*View) Window

func (v *View) Window(f func(wnd unsafe.Pointer))

Window marshals f to the UI thread and calls it with the view's native window handle (a GtkWindow* / NSWindow* / HWND). Use it instead of touching the platform from a background goroutine: it is the re-enter-the-UI-thread entry point (the former Dispatch) with the handle handed to you. f runs on the UI thread; keep it short. Before Show it panics with "View is not shown".

func (*View) WindowState added in v0.2.0

func (v *View) WindowState() WindowState

WindowState returns the last delivered native snapshot, safe from any goroutine. An unshown or programmatically closed View returns Closed=true.

type WindowState added in v0.2.0

type WindowState struct {
	Visible bool
	Active  bool
	Closed  bool
}

WindowState describes the native top-level window, independent of page focus. Visible means mapped/shown and not miniaturized where the native toolkit exposes that state. It does not imply that pixels are unobscured, on the current workspace, or visible to the user. Closed is terminal for this window lifetime. Close resets the View before creating a new lifetime.

Directories

Path Synopsis
Package autostart registers an application to launch when the user logs in, cgo-free.
Package autostart registers an application to launch when the user logs in, cgo-free.
Package clipboard reads and writes the system clipboard's text, and reads its image.
Package clipboard reads and writes the system clipboard's text, and reads its image.
Package dialog shows the operating system's native open, save and choose-directory panels, cgo-free.
Package dialog shows the operating system's native open, save and choose-directory panels, cgo-free.
Package instance keeps one process of an application running per user and hands the command line of every later launch to it, cgo-free.
Package instance keeps one process of an application running per user and hands the command line of every later launch to it, cgo-free.
internal
desktopentry
Package desktopentry writes and reads values of a freedesktop.org Desktop Entry file, for the two places tuohi writes one: the GTK3 Wayland identity in the root package and the XDG backend of tuohi/autostart.
Package desktopentry writes and reads values of a freedesktop.org Desktop Entry file, for the two places tuohi writes one: the GTK3 Wayland identity in the root package and the XDG backend of tuohi/autostart.
toolkit
Package toolkit hands what the root package knows about the native toolkit it has started to the desktop-service packages, which must not import the root.
Package toolkit hands what the root package knows about the native toolkit it has started to the desktop-service packages, which must not import the root.
Package notify displays OS-level desktop notifications without cgo or bundled libraries.
Package notify displays OS-level desktop notifications without cgo or bundled libraries.
Package tray puts an icon with a menu in the system tray / menu bar, with no cgo and no bundled libraries.
Package tray puts an icon with a menu in the system tray / menu bar, with no cgo and no bundled libraries.
Package updater verifies, stages and installs explicitly selected desktop updates.
Package updater verifies, stages and installs explicitly selected desktop updates.

Jump to

Keyboard shortcuts

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