appkit

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 10, 2026 License: MIT Imports: 37 Imported by: 0

README

appkit Go Reference test Release License

appkit 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 the desktop services around it: windows and app windows, drag regions, custom URL schemes, notifications, clipboard, single-instance, URL/file opening and native file dialogs. 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.

appkit keeps cgo out entirely. Through pure 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: appkit 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; all app-scoped services are methods on it (View, Wait, Bind, Copy, Paste, Open, Reveal, ...) 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 (App.Tray, or the standalone tray/ package), desktop notifications (App.Notify / the standalone 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)
  • System clipboard helpers (App.Copy / App.Paste); Single Instance Mode - set App.Exec to enable it: one process per application, and a later launch with the same App.ID (required when App.Exec is set) hands its arguments to the running process and exits; launch with --new-instance to force a fresh instance anyway)
  • 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 TEMPORARY 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: App.Autostart() returns an *Autostart - Enable(args...) registers the running executable to start with the user session (identifier from App.ID or App.Name), 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 appkit (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

appkit stays focused on the window and the view; more platform-specific OS features live either on *App or in small subpackages on the same cgo-free foundation. The system tray is tray/, desktop notifications are notify/ (with the App.Notify method), and native dialogs are dialog/. Opening URLs or revealing files lives on the app scope (App.Open / App.Reveal), as does clipboard access (App.Copy / App.Paste, over github.com/atotto/clipboard). Single Instance Mode is enabled by App.Exec (keyed on the application's App.ID, which becomes required) and the runtime application icon is App.Icon. App.Backend() reports which web engine the app runs on - webkitgtk-6.0/webkit2gtk-4.1 on Linux, WKWebView on macOS, WebView2 on Windows. Where a platform cannot support something cleanly, the API returns a clear ErrUnsupported instead of shipping something flaky.

Install

go get github.com/malivvan/appkit@latest

Requirements

appkit 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; appkit detects which at runtime, or you can pin one with the APPKIT_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, appkit 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.
Supported platforms (build targets)

appkit binds the OS web engine through pure (runtime dlopen, no cgo), so compilation tracks pure'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*

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.

  • Runtime on the BSDs is not verified and depends on what the port provides: a desktop GTK/WebKitGTK with the sonames appkit 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 single-instance mode. On FreeBSD the vendored pure/internal/fakecgo no longer puts environ/__progname into the dynamic symbol table (that needed a -gcflags override), so a CGO_ENABLED=0 binary that dlopens libc may fail to resolve libc's references; the cgo build (CGO_ENABLED=1, FreeBSD's default) keeps the full runtime. 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 appkit 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, appkit 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)
Choosing a stack (APPKIT_BACKEND)

The APPKIT_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:

  • APPKIT_BACKEND=webkitgtk-6.0 - the GTK4 stack
  • APPKIT_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 - appkit 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 demo logs it on every start).

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/malivvan/appkit"
)

func main() {
	app := &appkit.App{}
	view := &appkit.View{
		Debug:  true, // inspector on (App.Debug turns it on for every view)
		Width:  800,
		Height: 600,
		URL:    "data:text/html,%3Ch1%3EHello%20from%20Appkit%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)
	}
}

appkit pins the goroutine that creates the first window to its current OS thread. Keep direct window calls on that goroutine, and use Window(func(unsafe.Pointer)) to re-enter 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, …), appkit's own internals (__webview__, anything starting __appkit) 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, Single Instance Mode when App.Exec is set (keyed on the required App.ID) 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.
  • 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 := &appkit.App{Name: "My App", Exit: true} // end when the window closes
view := &appkit.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)
}

The per-window knobs appkit 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, and the APPKIT_DEBUG=1 environment variable forces the tools on for every view no matter what. 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 := &appkit.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 TEMPORARY 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 is torn down once the view's first page load finishes; nothing is ever exposed beyond the loopback interface.

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 demo 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 appkit serves your content to every view from the uniform app:// origin:

//go:embed ui
var uiFS embed.FS

app := &appkit.App{
	Name: "My App",
	FS:   uiFS, // the whole UI: HTML, CSS, JS, assets
}
view := &appkit.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 TEMPORARY 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 demo app serves its own UI through this one App.FS on every platform.

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 := &appkit.App{}
view := &appkit.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 := &appkit.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 demo Events card shows both directions live.

File dialogs

Native open/save/directory dialogs live in the standalone dialog package (github.com/malivvan/appkit/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 demo Dialogs card drives all four panel kinds; dialog/demo shows the standalone package on its own.

System tray

A tray icon with a menu is the tray package's job (github.com/malivvan/appkit/tray). Standalone, tray.Run owns the process's UI event loop and blocks until tray.Stop (see tray/demo); 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 := &appkit.App{
	Name: "my app",
	Exit: true, // end the process when the last window closes
	Tray: &tray.Config{
		Icon:    appIconPNG,
		Tooltip: "my app",
		Items: []tray.Item{
			{Label: "Open", OnClick: openUI},
			{Separator: true},
			{Label: "Quit", OnClick: app.Quit},
		},
	},
}
view := &appkit.View{
	Width:  1024,
	Height: 768,
}
if err := app.Show(view); err != nil {
	log.Fatal(err)
}
view.Navigate("https://example.com")
if err := app.Wait(); err != nil { // App.Wait runs the loop; the tray lives for its whole duration
	log.Fatal(err)
}

tray.Set/tray.Remove are the same pair without a window: show the icon from the UI thread and let your own loop dispatch the menu events. A menu item's OnClick runs on the UI thread; keep it short or hand the work to a goroutine. App.Tray wires exactly this up around App.Wait.

When an app sets App.Tray but leaves the tray config's Icon unset, appkit fills it in at app init: it takes App.Icon - falling back to the embedded appkit mark, which is unexported and applied by appkit itself - and downscales it to a tray-sized PNG (the resize helper lives in the appkit package; apps do not need their own). Set an explicit tray.Config.Icon to override the glyph.

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 tray/demo for a runnable demo.

Desktop notifications

OS-level notifications are the notify package's job (github.com/malivvan/appkit/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). From the main package the entry point is the App.Notify method, named after App.Name and safe from any goroutine once the app scope is open:

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

The standalone notify package names the source per call: notify.Show(name, title, message), where an empty name falls back to the executable's name. App.Notify passes no options - reach for notify.ShowOpts/notify.Alert/notify.Beep when you need icons, urgency or a sound. See notify/README.md and notify/demo for a runnable example.

Running the demos

One application showcases the whole package: demo/ 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, JS bridge, events, clipboard, native dialogs, notifications, an opt-in tray (./demo -tray) that hides / un-minimizes / shows the window and open/reveal - every feature in one UI, see the comments in demo/main.go):

go run ./demo                      # windowed showcase (custom chrome)
go run ./demo -http                # same, served over a temporary loopback
                                   # http://localhost server (App.HTTP) -
                                   # Linux/Windows opt in; macOS always does
go run ./demo -tray                # same + a tray menu (Show / Hide / Quit)
go run ./demo --framed             # same, with the OS window frame
go run ./demo --selftest           # showcase + automated self test (exit 0/1)

The page is the demo's App.FS, loaded from the same uniform app://index.html URL 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 temporary 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.

./demo --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. Run it headlessly with xvfb-run -a go run ./demo --selftest.

The standalone subpackage demos remain: tray/demo (tray icons, menus, checkboxes), notify/demo (notifications) and dialog/demo (all four panel kinds via the dialog package).

Each demo spawns a real appkit window (which needs the platform view: WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS).

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 pure 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 temporary 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,linux,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, single-instance handling, the app services (Notify, Copy/Paste, Open/Reveal, icon, instance internals, Autostart), 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)
  • demo/ - the single showcase application: one borderless, cross-platform window (custom chrome, UI served by App.FS, JS bridge + events, clipboard, native dialogs, notifications, open/reveal) with a --selftest UI-automation hook; demo/assets/ holds its static page
  • tray/ - the standalone declarative system-tray package; macOS/Windows/ Linux backends, tray/demo/ inside
  • notify/ - the standalone desktop-notification package: plain Show, ShowOpts with icon/urgency, Alert and Beep (the App.Notify method lives in app.go); notify/demo/ inside
  • dialog/ - the standalone native file-dialog package; dialog/demo/ inside

appkit 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 appkit 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 and layers desktop services on top of it - windowing and app windows, drag regions, custom URL schemes, notifications, clipboard, single-instance, opening URLs and native file dialogs - all cgo-free.

Source layout: the package is split into three file families. app*.go holds the application scope - the App type (configuration + runtime context), its app-scope methods (Show, Wait, Copy/Paste, Open/Reveal, Notify) and the per-OS app internals (single-instance, app icon); view*.go holds the view/window API surface (the define-first View struct and its methods, the geometry + State/Config types, scheme types, App.Show glue, the View Dialog method, drag regions); the binding/events machinery may be split further into bind.go (registry model + value conversion), bind_gen.go (the generated JS) and bind_evt.go (the events bridge) - see AGENTS.md "Source layout"; lib*.go holds the pure per-platform engine layer (lib_{darwin,linux,windows}.go talk to WKWebView/WebKitGTK/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 ErrAutostartNotSupported = errors.New("appkit: autostart is not supported on this platform")

ErrAutostartNotSupported is returned by Autostart methods when the current platform has no autostart backend.

View Source
var ErrScheme = errors.New("appkit: 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 the APPKIT_DEBUG=1 environment variable forces the tools on for
	// every view no matter what.
	//
	// 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

	// Events names the JavaScript global the appkit 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

	// ID uniquely identifies this application - e.g.
	// "com.github.malivvan.appkit" - the key the single-instance rule locks
	// on. It is optional outside Single Instance Mode: with App.Exec nil it is
	// unused and several instances of the same binary run side by side. When
	// App.Exec is set (Single Instance Mode is on), ID is REQUIRED: a later
	// launch of an app whose ID matches an already-running primary forwards
	// its command-line arguments to that primary (see Exec) and exits
	// quietly. Launching the binary with "--new-instance" in its arguments
	// always starts a fresh instance, even in single-instance mode.
	ID string

	// Exec, when non-nil, ENABLES Single Instance Mode: only one process of
	// this application runs at a time, and every later launch is redirected to
	// it instead of starting a new process. App.ID must then uniquely identify
	// the application (see the ID doc). With Exec nil (the default),
	// single-instance mode is off and every launch runs its own process.
	//
	// Exec is invoked on the primary instance with the command-line arguments
	// of each redirected later launch. It runs on its own goroutine, so hand
	// the arguments to the UI thread if you touch UI state.
	Exec func(args []string)

	// Name is the application name, used where the OS asks for one - most
	// visibly as the source shown by desktop notifications (App.Notify).
	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 appkit mark is used instead (unexported; appkit applies it
	// itself), so an appkit application always has a process face unless it
	// brings its own. App.Icon is ALSO the source of the tray glyph when the
	// app configures a tray (App.Tray) whose own Icon is unset: appkit
	// downscales App.Icon (or the embedded mark) to a tray-sized PNG at app
	// init. 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, and the GTK3 stack installs a matching
	// per-user .desktop entry and themed icon keyed to App.Name so the
	// compositor's app_id lookup finds it. A PNG that cannot be decoded is
	// never fatal.
	Icon []byte

	// Bind holds the application's declarative bindings: every entry is bound
	// onto each view App.Show creates, so one entry here covers all windows.
	// 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`) - see makeAccessorBinding;
	//   - 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 nil entry binds nothing. A view may override an app-wide name - or
	// unbind it with a nil entry - through its own View.Bind map.
	//
	// Entries are applied to every view deterministically: in alphabetical
	// key order, before the view's own View.Bind entries (see App.Show), so
	// the result never depends on Go's map iteration order.
	//
	// Like every App field it 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 appkit serves the file at that path in the filesystem on every
	// platform. 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
	// temporary 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.
	FS fs.FS

	// HTTP serves the app's content over a TEMPORARY 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 temporary loopback origin; the server is torn down again once the
	// first page load finishes. 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

	// Tray optionally puts an icon with a menu in the system tray / menu bar
	// for the life of the application. When set, the icon appears as App.Wait
	// starts running the platform UI loop (that loop dispatches the tray's
	// menu events) and is removed when Wait returns. It is the app-scoped,
	// declarative form of the tray subpackage's Set/Remove pair; see the tray
	// package for the full API.
	//
	// The Config is read once when the app scope opens - icon (PNG, plus
	// dark-mode and macOS template variants), tooltip, tray-level click
	// handlers and the whole menu tree (checkboxes, submenus, separators,
	// per-item icons). When the config leaves its Icon unset, appkit derives
	// the tray glyph at app init from App.Icon - falling back to the embedded
	// appkit mark - downscaled to a tray-sized PNG (see the Icon doc). The
	// tray is a launcher, not a live dashboard: the menu
	// stays fixed for its lifetime, so there is no runtime update machinery.
	//
	// A menu item's OnClick runs on the UI thread; keep it short or hand the
	// work to a goroutine. A typical use ends the app from the menu:
	//
	//	Tray: &tray.Config{
	//		Icon:    iconPNG,
	//		Tooltip: "my app",
	//		Items: []tray.Item{
	//			{Label: "Open", OnClick: openUI},
	//			{Label: "Quit", OnClick: app.Quit},
	//		},
	//	}
	//
	// On macOS the tray switches the application to the accessory (menu-bar)
	// activation policy, so the app's Dock icon disappears while the tray is
	// up; that is the tray package's behavior for standalone use too. A
	// configured tray that cannot be created (for example a second tray in
	// the same process) makes Wait fail with the tray package's error.
	Tray *tray.Config
	// contains filtered or unexported fields
}

App configures an appkit application and carries its runtime scope.

It is the single application-scoped object: the exported fields hold the application settings (content filesystem, tray, icon, single-instance, ...) 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).

All app-scoped services are methods on *App (Show, Wait, Copy, Open, Paste, Reveal, Notify, ...); 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.

func (*App) Autostart

func (a *App) Autostart() *Autostart

Autostart returns the application's autostart controller. The identifier a registration is stored under derives from App.ID when set, otherwise from a filesystem-safe slug of App.Name (or of the executable name when App.Name is empty) - see Autostart.Enable.

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 APPKIT_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) Copy

func (a *App) Copy(b []byte) error

Copy puts b onto the system clipboard, replacing whatever was there. It is an App method over github.com/atotto/clipboard (which reads the platform clipboard through pbcopy/pbpaste on macOS, xclip/xsel/wl-copy on Linux and the Win32 API on Windows). Safe to call from any goroutine once the App scope is open.

The platform backends are text clipboards, so Copy is binary-safe only opportunistically: arbitrary bytes round-trip where the backend preserves them verbatim (the command-line tools treat the payload as opaque), while Windows maps the payload through its text clipboard and may not preserve non-text bytes. It returns an error when no clipboard backend is available (for example a headless Linux box without xclip/xsel/wl-copy).

func (*App) Notify

func (a *App) Notify(title, message string) error

Notify displays an OS-level notification with the given title and message, named after the app (App.Name). It delegates to the notify subpackage, which needs no window and no tray icon: each platform binds the notification service the OS ships - NSUserNotificationCenter on macOS, a Shell_NotifyIconW balloon on Windows, org.freedesktop.Notifications on Linux. The App method stays plain (title + message only, no options); for custom icons, urgency or an alert/beep use the notify subpackage's ShowOpts/Alert/Beep directly.

It is safe to call from any goroutine once the App scope is open and returns the notify package's ErrUnsupported on platforms without a backend (anything but macOS, Windows and Linux). On macOS it can return ErrUnavailable when the process has no notification center - the deprecated NSUserNotificationCenter needs a bundled .app the user granted Notification permission (see the notify package docs); check with errors.Is.

func (*App) Open

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

Open opens rawurl with the user's default handler (browser, mail client, ...). Only http, https, mailto and file URLs are allowed; anything else - including a bare hostname or path with no scheme - returns ErrScheme. For a local file use a file:// URL, or Reveal to show it in the file manager.

func (*App) Paste

func (a *App) Paste() ([]byte, error)

Paste returns the current clipboard content as raw bytes. Text copied from other applications arrives as its UTF-8 encoding. An empty clipboard yields an empty slice with a nil error; an error is returned only when no clipboard backend is available.

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.

The first successful call pins the calling goroutine to its OS thread; keep all direct UI calls on that goroutine and re-enter through Window(func) from background goroutines. Exception: when the application run loop is already running (started by a tray loop or another owner), Show may be called from any goroutine - creation and the UI-touching methods marshal themselves to the main thread.

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). A simple single-window app may skip Wait and call View.Run on its window instead; the two models must not be mixed.

type Autostart

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

Autostart controls whether the application starts at user login. Get it from App.Autostart.

A registration points at the running executable and takes effect on the next login, not immediately. Re-enabling overwrites the registration; each platform keeps at most one entry per executable, so a previous registration under a different identifier (e.g. after App.Name changed) is replaced, not duplicated.

Enabled, Path and Backend report the CURRENT registration: they scan the platform's registration store for an entry whose command is the running executable, so they work regardless of the identifier used at Enable time.

func (*Autostart) Backend

func (a *Autostart) Backend() string

Backend returns the name of the mechanism the current registration uses: "xdg-autostart", "registry-run", "launchagent" or "smappservice". Empty when nothing is registered.

func (*Autostart) Disable

func (a *Autostart) Disable() error

Disable removes the autostart registration for the running executable. It is a no-op (nil error) when nothing is registered.

func (*Autostart) Enable

func (a *Autostart) Enable(args ...string) error

Enable registers the application to launch at login with the given command line arguments (appended after the executable path). It is safe to call repeatedly: an existing registration is overwritten, and a stale entry pointing at this executable under a different identifier is removed first.

The registration identifier is App.ID when set; otherwise a slug of App.Name, or of the executable name when App.Name is empty. An App.ID containing characters outside A-Za-z0-9._- is rejected.

func (*Autostart) Enabled

func (a *Autostart) Enabled() bool

Enabled reports whether a registration for the running executable exists. It does not verify that a registered entry still points at the running binary; Disable and Enable always reconcile that themselves.

func (*Autostart) Path

func (a *Autostart) Path() string

Path returns the path of the registration artefact for the current registration: the .desktop file path on Linux, the registry sub-key path (HKCU\…\Run\<id>) on Windows, the LaunchAgent plist path on macOS, or the bundle identifier for an SMAppService registration. Empty when nothing is registered.

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 (or the APPKIT_DEBUG=1 environment variable) opens the tools;
	// nothing can turn them off while that environment variable is set.
	//
	// 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 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

	// 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 appkit
	// has no declarative JS/CSS injection API - call Eval from Ready to run
	// code when the page comes up.
	Ready func()

	// 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 appkit 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 appkit'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, and it is a no-op before the View was ever shown - closing an unshown View is not an error.

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/malivvan/appkit/dialog package. Unlike the other View methods it BLOCKS the calling goroutine until the user dismisses the dialog and therefore must NOT be called from the UI thread (doing so deadlocks). Call it from a Bind callback - which runs on a background goroutine - or any other goroutine. It requires the main loop to be running (Run has been called).

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. 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 appkit 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. Call it from the UI thread; the backends marshal to the UI thread when called from a background goroutine.

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. 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. Examples:

v.Navigate("https://github.com/malivvan/appkit")
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) 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".

Directories

Path Synopsis
Command demo is the single appkit showcase application.
Command demo is the single appkit showcase application.
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.
demo command
Command demo exercises the dialog subpackage end to end: it opens each of the four native panels - open, multi-select open, save-as and choose directory - one after another and prints what the user picked.
Command demo exercises the dialog subpackage end to end: it opens each of the four native panels - open, multi-select open, save-as and choose directory - one after another and prints what the user picked.
Package notify displays OS-level desktop notifications without cgo or bundled libraries.
Package notify displays OS-level desktop notifications without cgo or bundled libraries.
demo command
Example notification demonstrates OS-level notifications through the notify subpackage.
Example notification demonstrates OS-level notifications through the notify subpackage.
objc
Package objc is a low-level pure Go objective-c runtime.
Package objc is a low-level pure Go objective-c runtime.
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.
demo command
Basic tray example: one declarative tray icon with light/dark icon variants, tray-level click handlers, and a menu with a submenu, a checkbox and a notification item.
Basic tray example: one declarative tray icon with light/dark icon variants, tray-level click handlers, and a menu with a submenu, a checkbox and a notification item.

Jump to

Keyboard shortcuts

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