Files
oikos/plans/done/2026-07-12-wails-desktop-app.md
dtoro aca6b8bcc2
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Update plan status with final iteration details
2026-07-14 00:28:13 +02:00

36 KiB
Raw Blame History

2026-07-12 — Wails desktop application

Status: Done — Phases 0.00.6 deployed to production (mac-mini, commit 0c0f35a, 2026-07-12). Phases 1.01.4 implemented and iterated (commits through eeb78ed, 2026-07-14). App installed to /Applications on dev Mac, end-to-end OIDC login via lokal HTTP server + system browser confirmed working.

Production deploy (2026-07-12): merged to main, picked up by the 2-minute deploy poller (scripts/deploy.sh: pg_dump backup → rebuild → rolling restart → health check), healthy after 1s. Verified post-deploy: unauthenticated /api/v1/* now 401s (the dev-open bypass was live in production before this — OIKOS_ENV=dev with no token set — so this closed a real, currently-exploitable hole, not just future prep); /healthz stayed open; nomos reconnected its MCP session with the new OIKOS_MCP_BEARER_TOKEN and a real tool call round-tripped end to end (get_health_summary via /query). A real random token was generated and added to mac-mini's .env (not committed — gitignored) before deploy, so the ${OIKOS_MCP_BEARER_TOKEN:-dev-token} fallback in docker-compose.yml never activated with the weak literal default.

Deliberately not done as part of this deploy (out of scope — a different host/repo than "mac-mini", not touched): the Caddy LXC (121) and dtoro/caddy-conf. Checked the real production Caddyfile directly — there is no oikos.hubris.network site block at all yet, so the Authentik-bypass risk (gap 1 below) doesn't apply yet; there's no public UI exposed to break. mcp.hubris.network exists but still reverse-proxies to the old pre-consolidation service on LXC 105 (192.168.8.205:9810), unrelated to this stack — stale, but pre-existing and out of scope here. Exposing oikos.hubris.network publicly (with the @api bypass this plan's Caddyfile.oikos reference copy already has) is unstarted follow-up work, not a regression from this deploy.

Plan review — gaps found before starting Phase 0

Reviewed against the current codebase and the live Caddy topology (compose/caddy/Caddyfile.oikos) before writing any code. Six gaps, each with the resolution taken:

  1. Authentik forward-auth vs. bearer-token clients. The deployed oikos.hubris.network site gates every route (including /agent/* and, after this plan, /api/v1/*) with import authentik — a browser-session forward-auth check, not a header a non-browser client can supply. Closing the dev-open gate (0.4) makes every API route require a bearer token, but says nothing about how a bearer-token client (Wails, curl, a future mobile client) gets past Authentik's login redirect in front of it. Same shape as the existing @enroll bypass for /api/v1/clients/enroll. Resolution: updated the reference copy (Caddyfile.oikos) with an @api path /api/v1/* /mcp /agent/* bypass around import authentik, same pattern as @enroll, and moved static-SPA serving into the handle {} fallback (0.6). This repo's copy is not what's deployed — the real file lives in dtoro/caddy-conf and auto-deploys from there — so the equivalent change still needs to land there before a Wails client (or anything else that can't complete Authentik's browser login) can actually reach the API in production. Flagged explicitly as risk #6 below so it isn't discovered the hard way.
  2. Nomos's own gateway (C1) is a parallel, unauthenticated path to the same backend. 2026-07-11-nomos-agent-code-review.md's C1 finding — nomos's port 8092 has zero auth of its own — is still open. Phase 0.3's CORS/auth work only touches internal/httpapi (the api process); cmd/nomos is untouched. The architecture diagram in this plan shows Caddy's handle_path /agent/* proxying straight to :8092, bypassing api's combinedAuth entirely and relying solely on Authentik. Closing the API's dev-open gate does nothing for this path — nomos's direct mesh-published port (docker-compose.yml:133) and nomos.hubris.network remain reachable with no credential check at all. Resolution: not fixed by this plan — flagged as a pre-existing, independent gap (already tracked as C1) that the Wails desktop app inherits rather than introduces. Added as risk #6 below so it isn't mistaken for something Phase 0 closes.
  3. github.com/go-chi/cors isn't a dependency yet, and the plan's sample CORS config (AllowCredentials: true with a default "*" origin) is spec-invalid — browsers and webviews reject a wildcard Access-Control-Allow-Origin when credentials are requested. This API authenticates via Authorization: Bearer, not cookies, so credentialed CORS mode isn't needed at all. Resolution: drop AllowCredentials from the middleware config in 0.3 rather than ship a setting that silently breaks the first time an origin other than * is configured.
  4. Closing dev-open (0.4) breaks local docker compose --profile dev up out of the box — none of the compose services currently set a token, and today they rely entirely on OIKOS_ENV=dev + devOpen. Worse: cmd/nomos itself is an unauthenticated client of api's /mcp endpoint and /api/v1/approvals/{id}/decision (chat-assent approvals) — grep -rn "Authorization" cmd/nomos/*.go returned nothing before this fix. Closing dev-open without touching nomos would have broken nomos's own connection to the API, not just local dev ergonomics; this wasn't called out anywhere in the original plan text. Resolution: added a token field threaded through mcpClient/mcpClientPool and agent.apiToken, both reading OIKOS_MCP_BEARER_TOKEN (the same shared secret api already validates static tokens against) and sent as Authorization: Bearer ... on every request nomos makes to api. docker-compose.yml sets OIKOS_MCP_BEARER_TOKEN (default dev-token) on both the api and nomos services so local dev keeps working.
  5. 0.2's const API = apiBase('/api/v1') pattern bakes in a stale origin. Module-level constants evaluate once, at import time — before main.ts's initConfig() runs (ES module imports are hoisted ahead of a file's own top-level statements) and before Config.svelte or a Wails-injected window.__OIKOS_CONFIG__ can set apiUrl. A first-launch Wails webview would resolve API to a relative path and try to fetch wails://.../api/v1/..., which doesn't exist. Resolution: api.ts keeps BASE/API as bare path prefixes (/agent, /api/v1, never resolved to a URL) and lets fetchWithAuth call apiBase() fresh on every request — the same fix pattern as gap 4's SSE snippet: resolve at call time, not at module-load time.
  6. api's own /agent reverse-proxy mount (to nomos) was never behind combinedAuth — found while auditing every route for the dev-open removal. server.go's r.Mount("/agent", ...) was registered directly on the base router, unlike every other custom route (/mcp, /api/v1/knowledge/recent, etc.), which all use r.With(combinedAuth(cfg, false)). Harmless while dev-open made the whole API open anyway; a real hole the moment 0.4 closes it — any request to api's /agent/* would reach nomos with no credential check at all, independent of C1 (nomos's own gateway on :8092, still open) and independent of gap 1 (Caddy/Authentik). Resolution: wrapped the mount in combinedAuth(cfg, false), matching every other route.

Also: 0.4's local-dev token delivery ended up simpler than described — "Vite injects it into window.__OIKOS_CONFIG__ at dev time" isn't needed at all for the relative-path dev case. The Vite proxy (0.2) already injects Authorization: Bearer $OIKOS_API_TOKEN server-side on every proxied /api//agent request, so relative-path fetches during npm run dev are authenticated before they leave the dev server — no client-side config needed. window.__OIKOS_CONFIG__ injection is still exactly what Phase 1's Wails shell needs (absolute URL, no dev proxy to lean on).

Also: 0.3's SSE-auth snippet checks GetActor(r.Context()) == nil inside serveSSE and validates the query token there — but serveSSE only runs after combinedAuth has already accepted or rejected the request, and combinedAuth requires a header today, so EventSource requests (no custom headers) never reach serveSSE at all; they 401 in the middleware first. Actual implementation: combinedAuth itself takes an allowQueryToken bool; when set (only for the /api/v1/events/stream route) it falls back to ?token= when the Authorization header is absent, before running the same OIDC/static validation as every other route. This reuses all existing auth logic instead of duplicating a static-token-only path inside serveSSE, and keeps the gate at the middleware layer rather than half-open inside the handler. The static-token comparison itself was extracted into staticTokenActor(cfg, raw), shared between the header and query-param paths.

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.

  6. Deploy-time Caddy changes this plan does not make. Two changes are required outside this repo before Phase 0's auth tightening actually protects anything in production, both in dtoro/caddy-conf:

    • Add a bearer-token bypass around import authentik for /api/v1/* and /mcp on oikos.hubris.network, mirroring the existing @enroll bypass — otherwise closing the dev-open gate just adds a second, redundant auth layer behind Authentik's browser-session check, and non-browser clients (Wails, curl) can never get past the first one.
    • Nomos's gateway (port 8092) has no auth of its own (C1, tracked in 2026-07-11-nomos-agent-code-review.md). Phase 0 does not fix this — the mesh-published port and nomos.hubris.network remain open regardless of anything done here. Treat C1 as a co-requisite for a production Wails rollout, not something this plan's auth work incidentally covers.