Wails Desktop Shell for a Managed Search Service

Frontend Engineering intermediate Building with AI ~20 min read

Overview

Desktop applications that wrap backend services present a unique engineering challenge: they must expose server-side functionality through a native-feeling UI while managing process lifecycles, local networking, and cross-platform builds. Wails is a Go framework that solves this by embedding a platform-native webview (WebKit on macOS, WebView2 on Windows) and binding Go functions directly to the JavaScript frontend, eliminating the need for REST endpoints between the UI and the backend logic running in the same process.

As a concrete example, consider a desktop search client built with Wails v2 that manages a local database engine instance, exposes MCP (Model Context Protocol) configuration, and handles authentication and sync workflows. The Go backend starts the local engine, spins up a local HTTP server for MCP endpoints, and exposes bound methods that the React frontend calls to trigger login, logout, sync, and status checks. The React UI polls a GetStatus method to display endpoint URLs, engine health, and sync progress.

This architecture is compelling because it eliminates the deployment complexity of a client-server split. The user downloads a single binary, double-clicks it, and gets a fully functional search engine with a native window. Under the hood, Go manages all the heavy lifting—starting the database engine, managing auth tokens, syncing data—while React handles the presentation layer. Wails bridges the two with type-safe bindings that feel like calling a local function from JavaScript.

Core Concepts

Wails Go-to-JS Bindings

Wails generates TypeScript bindings from Go struct methods at build time. A Go method like func (a *App) GetStatus() StatusResponse becomes callable from JavaScript as GetStatus() with full type information. This eliminates manual serialization, HTTP routing, and API versioning between the backend and frontend layers of the desktop app.

Embedded Webview Architecture

Unlike Electron, which bundles a full Chromium instance (~150MB), Wails uses the platform’s native webview. On macOS this is WebKit via WKWebView; on Windows it’s Microsoft Edge WebView2. This dramatically reduces binary size (typically 10–15MB vs 150MB+) and memory usage, while still providing a modern rendering engine for the React frontend.

Local Service Lifecycle Management

The desktop app manages the full lifecycle of embedded services: starting the local database engine on app launch, binding a local HTTP server to a random available port for MCP endpoints, gracefully shutting down on window close, and recovering from crashes. The Go backend uses Wails lifecycle hooks (OnStartup, OnShutdown, OnDomReady) to coordinate initialization and teardown.

MCP Configuration Exposure

The MCP (Model Context Protocol) integration exposes local endpoints that AI agents can connect to. The desktop app generates a JSON configuration object containing the local server URL, available tools, and authentication tokens. Users can copy this configuration from the UI and paste it into their AI agent’s MCP settings to connect the agent to their local database instance.

How It Works

Application Bootstrap

When the user launches the app, Wails initializes the native window and calls the Go OnStartup hook. This hook starts the local database engine, loads any persisted authentication tokens from the OS keychain, and binds the local MCP HTTP server to localhost:0 (letting the OS assign an available port). Once all services are running, Wails loads the React frontend into the webview and calls OnDomReady, which triggers the initial status poll.

type App struct {
    ctx        context.Context
    engine     *dbengine.Engine
    mcpServer  *http.Server
    mcpPort    int
    authToken  string
    syncStatus SyncStatus
}

func (a *App) OnStartup(ctx context.Context) {
    a.ctx = ctx

    engine, err := dbengine.NewEngine(dbengine.Config{
        DataDir: a.dataDir(),
    })
    if err != nil {
        wails.LogFatal(ctx, "failed to start engine: "+err.Error())
        return
    }
    a.engine = engine

    listener, _ := net.Listen("tcp", "127.0.0.1:0")
    a.mcpPort = listener.Addr().(*net.TCPAddr).Port
    a.mcpServer = &http.Server{Handler: a.mcpHandler()}
    go a.mcpServer.Serve(listener)
}

Status Polling from React

The React frontend polls GetStatus() every 2 seconds to update the UI. This method returns a struct containing the MCP endpoint URL, database engine health, sync progress (percentage and current operation), and whether the user is authenticated. The frontend renders this as a dashboard with live-updating indicators.

// Go binding method
func (a *App) GetStatus() StatusResponse {
    return StatusResponse{
        MCPEndpoint:  fmt.Sprintf("http://127.0.0.1:%d", a.mcpPort),
        EngineHealth: a.engine.Health(),
        IsLoggedIn:   a.authToken != "",
        SyncProgress: a.syncStatus.Progress,
        SyncMessage:  a.syncStatus.Message,
    }
}

// React frontend (TypeScript)
import { GetStatus } from '../wailsjs/go/main/App';

function Dashboard() {
    const [status, setStatus] = useState<StatusResponse | null>(null);

    useEffect(() => {
        const interval = setInterval(async () => {
            const s = await GetStatus();
            setStatus(s);
        }, 2000);
        return () => clearInterval(interval);
    }, []);

    return (
        <div className="dashboard">
            <HealthIndicator healthy={status?.EngineHealth === "ok"} />
            <EndpointDisplay url={status?.MCPEndpoint} />
            <SyncProgress progress={status?.SyncProgress} />
        </div>
    );
}

Authentication Flow

Login triggers an OAuth2 PKCE flow. The Go backend opens the system browser to the auth provider’s login page and starts a temporary local HTTP server to receive the callback. Once the user authenticates, the callback delivers an authorization code, which the Go backend exchanges for tokens. The access token is stored in the OS keychain (via keyring package) and used for subsequent sync operations.

func (a *App) Login() error {
    verifier := oauth2.GenerateVerifier()
    authURL := a.oauthConfig.AuthCodeURL(
        a.state,
        oauth2.S256ChallengeOption(verifier),
    )

    runtime.BrowserOpenURL(a.ctx, authURL)

    code := <-a.callbackChan // blocks until OAuth callback

    token, err := a.oauthConfig.Exchange(a.ctx, code,
        oauth2.VerifierOption(verifier),
    )
    if err != nil {
        return fmt.Errorf("token exchange failed: %w", err)
    }

    keyring.Set("search-app", "auth_token", token.AccessToken)
    a.authToken = token.AccessToken
    return nil
}

Data Sync

Sync pulls the latest data from the cloud into the local database instance. The Go backend uses the auth token to call the sync API, streams incremental changes, and applies them to the local engine. Progress is reported through the SyncStatus struct, which the frontend polls via GetStatus(). Sync is designed to be resumable: if interrupted, it picks up from the last checkpoint rather than restarting from scratch.

Implementation Patterns

Wails Events for Push Updates

While polling works for periodic status checks, some events (like sync completion or auth token expiration) need immediate notification. Wails provides an event system where the Go backend can emit events that the React frontend subscribes to. This avoids polling latency for time-sensitive state changes.

// Go: emit event to frontend
runtime.EventsEmit(a.ctx, "sync:complete", SyncResult{
    ItemsSynced: 1423,
    Duration:    elapsed,
})

// React: subscribe to event
import { EventsOn } from '../wailsjs/runtime/runtime';

EventsOn("sync:complete", (result: SyncResult) => {
    showNotification(`Synced ${result.ItemsSynced} items`);
});

Graceful Shutdown Coordination

When the user closes the window, the OnShutdown hook coordinates teardown: it cancels the sync context (stopping any in-progress sync), shuts down the MCP HTTP server with a 5-second timeout, flushes the database engine’s write-ahead log, and closes the database. The order matters—stopping the HTTP server before flushing the WAL prevents new writes from arriving during shutdown.

Cross-Platform Data Directory

The app stores its data in platform-appropriate locations: ~/Library/Application Support/SearchApp on macOS, %APPDATA%\SearchApp on Windows, and ~/.local/share/search-app on Linux. The Go backend uses os.UserConfigDir() and os.UserCacheDir() to determine these paths, ensuring the app follows platform conventions for data storage and cache management.

Common Pitfalls

Further Reading