Files
oikos/plans/2026-07-12-wails-desktop-app.md
dtoro 3157e6102a
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
plans: Wails desktop app with client/server split
Phase 0 separates the SPA from the oikos binary (delete embed.go, add CORS,
make API base URL configurable, add auth interceptor, close dev-open gate).
Phase 1 builds a thin Wails v3 desktop wrapper — native window + tray +
notifications + auto-start + auto-update. SPA shared between browser and
desktop builds.
2026-07-12 11:49:27 +02:00

26 KiB
Raw Blame History

2026-07-12 — Wails desktop application

Status: Planned — not started

Goal

Transform the Oikos control room into a native desktop application using Wails, built on top of a clean client/server split. The server (API, MCP, scheduler, notifier, Nomos) stays on the homelab as a long-running service. The client (SPA) is separated from the server binary and deployed independently — any browser talks to the server over HTTP. The Wails app is a thin native client: it loads the same SPA in a webview, configured with the server URL and auth token, and adds system tray, native notifications, auto-start, and auto-update.


Architecture

┌────────────────────────────────────────────────────────────┐
│                   Server (homelab, permanent)               │
│                                                             │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐     │
│  │ oikos api    │  │ oikos sched  │  │ oikos notif  │     │
│  │ :8090        │  │ (observe)    │  │ (Matrix)     │     │
│  │ REST + SSE   │  └──────────────┘  └──────────────┘     │
│  │ MCP /mcp     │                                          │
│  └──────┬───────┘  ┌──────────────┐                        │
│         │          │ nomos serve  │                        │
│         ├──────────┤ :8092        │                        │
│         │  MCP     │ /agent/*     │                        │
│         │          └──────┬───────┘                        │
│         │                 │                                │
│         └────────┬────────┘                                │
│           ┌──────▼──────┐                                   │
│           │  Postgres   │                                   │
│           └─────────────┘                                   │
│                                                             │
│  Caddy: /api/* → :8090  /agent/* → :8092  /mcp → :8090    │
│         / → static SPA (web/dist/)                         │
└───────────────────────┬────────────────────────────────────┘
                        │ HTTPS (bearer auth or OIDC)
        ┌───────────────┼───────────────┐
        │               │               │
┌───────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│  Browser     │ │ Wails app   │ │  CLI/mobile │
│  (SPA at /)  │ │ (SPA in     │ │  (future)   │
│              │ │  webview)   │ │             │
└──────────────┘ └─────────────┘ └─────────────┘

Server

The existing server roles (oikos api, oikos scheduler, oikos notifier, nomos serve, Postgres) run on the homelab mac-mini as systemd services — unchanged. The SPA is no longer embedded in the oikos binary; it's a standalone static build served by Caddy at /. The API routes (/api/v1/*, /mcp, /healthz) don't conflict with root, and the old root redirect is removed, so no path prefix is needed.

Clients

Any HTTP client that speaks the REST API + bearer auth. The SPA is the canonical client, deployed as static files. The Wails app wraps the same SPA in a native webview. Future clients (CLI, mobile) use the same API.


Stack

Server: existing Go code in internal/ — no changes. cmd/oikos removes the SPA embed and /ui/* routes. Caddy serves web/dist/ at / with SPA fallback.

SPA: existing Svelte 5 + Vite + Tailwind 4 + shadcn-svelte in web/. API base URL and auth token become runtime-configurable. base: '/' — no path prefix needed since the SPA is served at root.

Desktop: Wails v3 (Go + webview). The Wails app is a thin shell:

  • Embeds the SPA as static assets (Wails's go:embed-based asset system)
  • Reads server URL + token from OS keychain at startup, injects into webview
  • SPA talks to the remote server over HTTPS — same as the browser
  • No Go backend, no Postgres connection, no bundled sidecars
  • Native shell: system tray, notifications, auto-start, auto-update, window persistence

Phase 0 — Client/server split

This phase separates the SPA from the oikos binary and makes it a standalone client. The Wails app depends on this split being done first.

0.1 — Remove SPA embed from the server

  • Delete web/embed.go — the server no longer embeds web/dist/.
  • cmd/oikos/main.go — remove uiHandler() (~35 lines). The httpapi.ListenAndServe() signature no longer takes a uiHandler param; pass nil and handle nil in server.go.
  • internal/httpapi/server.go — remove the /ui/* and /ui routes (~15 lines at server.go:175-182), and the root redirect to /ui/ (server.go:183-185).
  • web/dist/.gitkeep — delete (no longer needed to keep backend-only builds green).
  • Dockerfile — remove the node/ui-builder stage and COPY --from= of web/dist/. The 3-stage Dockerfile (node → go → runtime) becomes a 2-stage build (go → runtime). ~20 lines deleted.

~80 lines deleted. The oikos api binary is now API-only: REST, SSE, MCP, healthz.

0.2 — Make SPA API base URL configurable and add auth interceptor

The SPA currently hardcodes relative paths and has no auth headers:

// web/src/lib/api.ts
const BASE = '/agent'
const API = '/api/v1'

Replace with a runtime-configuration module (web/src/lib/config.ts):

// web/src/lib/config.ts

interface OikosConfig {
  apiUrl: string   // e.g. "https://oikos.hubris.network"
  token?: string   // bearer token for auth
}

declare global {
  interface Window {
    __OIKOS_CONFIG__?: OikosConfig
  }
}

let cfg: OikosConfig | undefined

export function initConfig(override?: OikosConfig) {
  cfg = override ?? window.__OIKOS_CONFIG__
  if (cfg?.token) {
    localStorage.setItem('oikos_token', cfg.token)
    if (cfg.apiUrl) localStorage.setItem('oikos_api_url', cfg.apiUrl)
  }
}

export function getConfig(): OikosConfig {
  if (!cfg) {
    const token = localStorage.getItem('oikos_token')
    const apiUrl = localStorage.getItem('oikos_api_url')
    if (token || apiUrl) {
      cfg = { apiUrl: apiUrl ?? '', token: token ?? undefined }
    }
  }
  return cfg ?? { apiUrl: '' }
}

export function isConfigured(): boolean {
  const c = getConfig()
  return !!c.apiUrl && !!c.token
}

// Relative paths are used in dev (Vite proxy) and when the SPA shares an
// origin with the API server (Caddy reverse proxy). Absolute paths are used
// when the API server is on a different origin (Wails webview, remote access).
function apiBase(path: string): string {
  const c = getConfig()
  if (!c.apiUrl) return path // relative — relies on same-origin or Vite proxy
  return `${c.apiUrl}${path}`
}

// ---- Auth fetch wrapper ----
// Replaces every raw fetch() call in api.ts. Prepends the API base URL
// (absolute when configured, relative when unset for Vite dev proxy) and
// adds the Authorization header.

async function fetchWithAuth(path: string, opts?: RequestInit): Promise<Response> {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    ...(opts?.headers as Record<string, string> ?? {}),
  }
  const c = getConfig()
  if (c.token) {
    headers['Authorization'] = `Bearer ${c.token}`
  }

  return fetch(apiBase(path), { ...opts, headers })
}

// SSE path builder — EventSource doesn't take headers, so pass the token
// as a query parameter (the SSE handler in server.go checks it alongside
// the Authorization header).
export function sseUrl(path: string): string {
  const c = getConfig()
  const url = apiBase(path)
  if (!c.token) return url
  const sep = url.includes('?') ? '&' : '?'
  return `${url}${sep}token=${encodeURIComponent(c.token)}`
}

// Export for api.ts to use throughout
export { fetchWithAuth, apiBase }

Then web/src/lib/api.ts — replace every fetch(...) call with fetchWithAuth(...). Example:

// Before:
// const res = await fetch(`${API}/entities?${params}`)
// After:
import { fetchWithAuth, apiBase } from './config'
const API = apiBase('/api/v1')
const BASE = apiBase('/agent')
// ...
const res = await fetchWithAuth(`/api/v1/entities?${params}`)

web/src/lib/stores/events.ts — replace new EventSource(...) with new EventSource(sseUrl(...)):

import { sseUrl } from '$lib/config'

// Before:
// source = new EventSource('/api/v1/events/stream')
// After:
source = new EventSource(sseUrl('/api/v1/events/stream'))

web/src/lib/stores/chat.ts — the chat SSE is POST + ReadableStream via fetch(), which already goes through streamChat in api.ts. When the plan says "replace every fetch(...) call", streamChat is included — the POST to /agent/chat becomes fetchWithAuth('/agent/chat', ...).

web/src/lib/stores/context.tsrefreshContext() calls fetchDashboardSummary() and fetchApprovals() from api.ts. Those already go through fetchWithAuth. No change needed here.

web/src/lib/stores/workspace.ts — calls fetchPlan() and fetchQuestions() from api.ts. No change needed.

web/src/main.ts — call initConfig() before mounting the app:

import { initConfig, isConfigured } from '$lib/config'

initConfig()

const app = mount(isConfigured() ? App : Setup, {
  target: document.getElementById('app')!
})
export default app

This is the single largest frontend change: ~40 fetch() calls spread across api.ts (all routes), stores/events.ts (EventSource), and the chat stream. Each gets replaced with fetchWithAuth() or sseUrl().

web/vite.config.tsbase: '/' (remove /ui/ prefix, since the SPA is served at root after the split). The dev proxy stays — same origin in dev means relative paths work. After 0.4 closes the dev-open auth gate, inject the token via a configure hook:

import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [tailwindcss(), svelte()],
  base: '/',
  resolve: {
    alias: { $lib: '/src/lib' }
  },
  build: {
    outDir: 'dist',
    emptyOutDir: true
  },
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8090',
        configure: (proxy) => {
          proxy.on('proxyReq', (proxyReq) => {
            const token = process.env.OIKOS_API_TOKEN
            if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`)
          })
        }
      },
      '/agent': {
        target: 'http://localhost:8092',
        rewrite: (path) => path.replace(/^\/agent/, ''),
        configure: (proxy) => {
          proxy.on('proxyReq', (proxyReq) => {
            const token = process.env.OIKOS_API_TOKEN
            if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`)
          })
        }
      }
    }
  }
})

0.3 — Add SSE query-param auth and CORS to the API server

SSE auth. The SSE handler at /api/v1/events/stream currently relies on combinedAuth middleware for bearer token validation. EventSource can't send custom headers, so the SPA passes the token as a query param (?token=...). The SSE handler needs to extract and validate it.

internal/httpapi/sse.go — in serveSSE, before using the context's actor, check for a query-param token:

func (s *Server) serveSSE(w http.ResponseWriter, r *http.Request) {
    // If combinedAuth didn't set an actor (no Authorization header —
    // EventSource can't send one), try the query param.
    if GetActor(r.Context()) == nil {
        token := r.URL.Query().Get("token")
        if token != "" {
            validateStaticToken(s.cfg, r, token)
        }
    }
    // ... rest of SSE handler
}

Extract the static-token validation from combinedAuth into a shared helper so both the middleware and the SSE handler use the same logic.

CORS middleware. Add CORS to the chi router. This is needed for Wails (webview origin differs from the remote server) and local dev (Vite on :5173 vs server on :8090). For the browser production deployment (Caddy serves both SPA and API from the same origin), it's a no-op.

internal/httpapi/server.go — add before the auth middleware:

r.Use(cors.Handler(cors.Options{
    AllowedOrigins:   []string{cfg.CORSAllowedOrigin},
    AllowedMethods:   []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
    AllowedHeaders:   []string{"Authorization", "Content-Type", "If-Match"},
    AllowCredentials: true,
    MaxAge:           86400,
}))

internal/config/config.go — add CORSAllowedOrigin string, populated from OIKOS_CORS_ORIGIN. Default: "*" in dev, the Caddy site URL in prod.

~30 lines added.

0.4 — Auth: close the dev-open gate

Currently combinedAuth opens the gate when OIKOS_ENV=dev and no tokens are set (server.go:228). After the split, a client from any origin can hit the API — the dev-open path is a security hole.

  • Remove the devOpen path from combinedAuth — every request must carry a valid bearer token (via Authorization header or ?token= query param for SSE).
  • For local dev: set OIKOS_API_TOKEN=dev-token and the SPA reads it from OIKOS_API_TOKEN env var (Vite injects it into window.__OIKOS_CONFIG__ at dev time, and the Vite proxy forwards it).
  • Browser (production): the SPA's Config.svelte page accepts a static token (stored in localStorage). OIDC login flows are a follow-on milestone.
  • Desktop (production): the Wails app reads the token from the OS keychain and injects it into window.__OIKOS_CONFIG__ before the webview loads.

0.5 — SPA config page (first-launch / setup)

The SPA needs a page for entering the server URL and auth token on first launch. This page also serves as the foundation for future OIDC login.

New file web/src/pages/Config.svelte:

  • Two fields: "Server URL" (text input) and "Token" (password input)
  • "Connect" button: calls fetchWithAuth('/api/v1/dashboard/summary') to validate, stores in localStorage on success, calls initConfig() to refresh runtime config, navigates to #/overview
  • Tabs placeholder for future OIDC flow: "Token", "Login with Authentik" (the second tab is disabled with "coming soon")
  • Error state: connection failed, wrong token, server unreachable

web/src/App.svelte — check isConfigured() at mount. If false, render Config.svelte instead of the sidebar. On successful config, transition to the full app.

web/src/main.ts — simplify to always mount App.svelte (the config check lives in App.svelte's mount hook, not in main.ts):

import { mount } from 'svelte'
import App from './App.svelte'
import './app.css'
import { initConfig } from '$lib/config'

initConfig()
mount(App, { target: document.getElementById('app')! })

0.6 — Deploy SPA as standalone static files

The SPA is built with base: '/' and served by Caddy at / with SPA fallback. API routes take priority (explicit handle_path blocks in Caddy).

Caddy config (add to the existing compose/caddy/Caddyfile.oikos):

handle {
    root * /var/www/oikos-ui
    file_server
    try_files {path} /index.html
}

The index.html doesn't need a placeholder (__OIKOS_API_URL__) in the Caddy deployment case — the SPA and API share an origin, so relative paths work and __OIKOS_CONFIG__ only needs apiUrl unset. The token is entered by the user on the Config page and stored in localStorage.

Build + deploy:

ui:     ## Build the SPA for standalone deployment
	cd web && npm run build

deploy-ui: ui  ## Deploy SPA to the Caddy host
	scp -r web/dist/* mac-mini:/var/www/oikos-ui/
	ssh mac-mini sudo systemctl reload caddy

Dockerfile (server image, no Node required):

# Stage 1: Build Go binary
FROM golang:1.26 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o oikos -tags timetzdata ./cmd/oikos

# Stage 2: Runtime
FROM debian:bookworm-slim
COPY --from=builder /app/oikos /usr/local/bin/oikos
EXPOSE 8090
ENTRYPOINT ["oikos"]

Drops the node builder stage entirely. The UI is built and deployed separately.

0.7 — Verification

# Server
oikos api                                    # API + SSE + MCP, no UI
curl localhost:8090/healthz                  # {"status":"ok"}
curl -H "Authorization: Bearer dev-token" \
  localhost:8090/api/v1/dashboard/summary    # data

# SPA (dev)
OIKOS_API_TOKEN=dev-token npm run dev        # Vite at :5173, proxy to :8090
open http://localhost:5173/                  # Config page (enter URL + token)
                                             # → Overview with live data

# SPA (production test)
cd web && npm run build
caddy file-server -root dist --listen :3000  # serve SPA locally
# Run oikos api separately, open http://localhost:3000,
# enter apiUrl=http://localhost:8090 + token on Config page

Phase 1 — Wails desktop app

Built on top of the split. The Wails app is a thin native wrapper around the same SPA, configured to talk to the deployed server over HTTPS. No bundled Go server, no Postgres connection, no nomos sidecar.

1.0 — Scaffold and window (1 session)

Create the Wails project with a native window loading the built SPA.

cmd/desktop/main.go — Wails v3 app:

  • On startup: read config from OS keychain (keyring package or Wails secrets plugin). Keys: oikos_server_url, oikos_token.
  • If no config in keychain: load the SPA anyway — Config.svelte handles first-launch setup.
  • If config exists: inject window.__OIKOS_CONFIG__ before the webview mounts. Wails v3's AssetsHandler can mutate index.html before serving:
    assetsHandler: func(ctx context.Context, name string) (string, []byte, error) {
        if name == "index.html" {
            b, _ := assets.ReadFile("index.html")
            html := strings.Replace(string(b),
                `<script>window.__OIKOS_CONFIG__ = {};</script>`,
                fmt.Sprintf(`<script>window.__OIKOS_CONFIG__ = %s;</script>`, configJSON),
                1)
            return "index.html", []byte(html), nil
        }
        b, _ := assets.ReadFile(name)
        return name, b, nil
    }
    
    index.html includes a placeholder <script> tag that gets replaced:
    <script>window.__OIKOS_CONFIG__ = {};</script>
    <script type="module" src="/src/main.ts"></script>
    
  • Window: title "Oikos — Control Room", 1400×900, min 1024×700, dark title bar (mac.TitleBarStyleHiddenInset or equivalent dark mode).
  • Wails embeds web/dist/ into the binary (via //go:embed all:dist in the Wails project or the standard Wails asset system).

cmd/desktop/wails.json — Wails project config:

{
  "name": "oikos-desktop",
  "frontend:dir": "../../web",
  "frontend:build": "npm run build",
  "frontend:dev:watcher": "npm run dev"
}

Makefile:

desktop: ui   ## Build the Wails desktop app
	wails build -clean -o oikos-desktop

Dev loop for the desktop app:

# Terminal 1: run the server locally (or point at remote)
OIKOS_API_TOKEN=dev-token oikos api

# Terminal 2: start Wails in dev mode (hot-reload, connects to Vite)
cd cmd/desktop && wails dev

web/vite.config.ts — add base: '/' (already done in 0.2). No Wails-specific Vite config needed since Wails v3 uses the standard Vite dev server.

Verify:

OIKOS_SERVER_URL=https://oikos.hubris.network OIKOS_DESKTOP_TOKEN=... make desktop
./oikos-desktop
# Window opens → Config page (if no keychain entry) or Overview with live data

1.1 — Native shell features (1 session)

  • System tray (Wails v3 application.NewSystemTray):

    • Oikos logo icon (from web/public/favicon.svg)
    • Menu: "Open Control Room" (focus/restore window), "Pending Approvals: N" (fetched via backchannel HTTP call from Go, not the SPA), separator, "Quit"
    • When window is closed: minimize to tray instead of quitting (set HideOnClose)
  • Native notifications (application.Notification):

    • A backchannel goroutine polls GET /api/v1/dashboard/summary every 30s (separate HTTPS client in Go, independent of the webview)
    • Fires OS notification when approvals_pending or signals_by_severity.critical increases since last poll
    • Click notification → Window.Restore() + send a message to the SPA via Wails events to navigate to the relevant page
  • Window persistence: remember size/position via Wails v3 window.PersistState or a JSON file in ~/.config/oikos/window.json

  • Auto-start on login (macOS):

    • During setup flow, offer a checkbox: "Start automatically on login"
    • Writes a LaunchAgent plist to ~/Library/LaunchAgents/com.hubris.oikos-desktop.plist that runs the binary on login
    • Linux equivalent: ~/.config/autostart/oikos-desktop.desktop

Verify: Close window → app stays in tray. New approval arrives → OS notification appears with count. Click notification → window opens to Ops page. Restart machine → app opens automatically on login.

1.2 — Token management (1 session)

  • First launch: Config.svelte prompts for server URL + token (same page as the browser SPA's setup)
  • On "Connect" success, Config.svelte calls a Wails binding SaveConfig(apiUrl, token) that stores in the OS keychain:
    func (a *App) SaveConfig(apiUrl string, token string) error {
        keyring.Set("oikos_server_url", apiUrl)
        keyring.Set("oikos_token", token)
        return nil
    }
    
  • Subsequent launches: Wails reads keychain, injects config, SPA skips Config page
  • Logout: "Log out" menu item in system tray clears keychain and refreshes the webview → Config.svelte appears

Verify: Enter URL + token on first launch, quit, reopen → skips setup and loads Overview.

1.3 — Auto-update (1 session)

  • Check Gitea releases (or a configured update URL) on startup and every 6 hours
  • Wails v3 update plugin or a custom Go goroutine: GET /releases/latest → compare semver → download binary → verify checksum → prompt restart
  • Update manifest published alongside each release: oikos-desktop-darwin-arm64.json with version, url, sha256

Verify: Build v1.0.0, publish v1.0.1 → app detects update, downloads, prompts restart. After restart, version is 1.0.1.

1.4 — Distribution and packaging (1 session)

  • macOS: .app bundle via wails build, code-sign with Apple Developer ID, notarize via xcrun notarytool
    • Bundle ID: com.hubris.oikos-desktop
    • Entitlements: network client, keychain access
  • Linux: .deb and AppImage via wails build + packaging scripts
  • CI: .gitea/workflows/desktop.yml — builds all targets on tag push, uploads artifacts to Gitea releases
  • AGENTS.md update: document the desktop app as a first-class client

Verify: Download .app on a fresh Mac, open → first-launch setup → connect to the homelab → full app works with zero dev tools.


What does NOT change

  • internal/ — every package imported as-is. Zero modifications.
  • cmd/oikos/ — minus the SPA embed (0.1), the oikos binary is unchanged.
  • cmd/nomos/ — unchanged. The desktop app talks to nomos through the server's /agent reverse proxy — same as the browser.
  • web/ — SPA source shared between browser and desktop builds. Gains config.ts (auth interceptor), Config.svelte (setup page), and vite.config.ts drops /ui/ prefix + adds proxy token injection. All existing pages, components, stores, hooks reused.
  • api/openapi.yaml — unchanged.
  • seeds/ — unchanged.
  • docker-compose.yml — server deployment unchanged (minus the Dockerfile losing the UI build stage).

Risks and open questions

  1. Wails v3 maturity. v3 is newer than v2. Fallback: Wails v2 — same architecture (Go + webview + embedded assets), different Go APIs. Scope of impact: one file (cmd/desktop/main.go). The thin-wrapper approach means the Wails API surface is ~50 lines of Go — trivially portable.

  2. OIDC login flow. The browser SPA needs an OIDC redirect flow via Authentik for production use (static tokens are fine for homelab dev but not for external access). The Config.svelte page has a tab placeholder for this. It's a separate milestone — for now, both browser and desktop clients use a static bearer token configured at first launch.

  3. SSE query-param token in logs. The token in ?token=... appears in Caddy access logs and server request logs. Mitigation: log redaction in Caddy (log { format filter { wrap json { fields { request>uri replace "token=[^&]*" "token=***" } } } }) and strip the query param from the request logger in server.go.

  4. Webview CORS for embedded assets. Wails loads the SPA from wails:// or asset:// origin, making cross-origin requests to the remote server. CORS middleware (0.3) handles this. The Access-Control-Allow-Origin must match the webview's origin, which may change between Wails versions. Mitigation: allow the configured origin explicitly; fall back to * for dev; Wails v3 document its asset origin.

  5. Multiple clients hitting the same SSE broker. Browser, Wails app, and Nomos all connect to /api/v1/events/stream. The SSE broker already handles multiple subscribers (fan-out via the subscriber list in sse.go). Each client gets its own connection and replay. No change needed.