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.
This commit is contained in:
678
plans/2026-07-12-wails-desktop-app.md
Normal file
678
plans/2026-07-12-wails-desktop-app.md
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
# 2026-07-12 — Wails desktop application
|
||||||
|
|
||||||
|
**Status:** Planned — not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Transform the Oikos control room into a native desktop application using
|
||||||
|
[Wails](https://wails.io), 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:
|
||||||
|
```ts
|
||||||
|
// web/src/lib/api.ts
|
||||||
|
const BASE = '/agent'
|
||||||
|
const API = '/api/v1'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with a runtime-configuration module (`web/src/lib/config.ts`):
|
||||||
|
|
||||||
|
```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:
|
||||||
|
```ts
|
||||||
|
// 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(...))`:
|
||||||
|
```ts
|
||||||
|
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.ts`** — `refreshContext()` 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:
|
||||||
|
```ts
|
||||||
|
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.ts`** — `base: '/'` (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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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:
|
||||||
|
```go
|
||||||
|
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:
|
||||||
|
```go
|
||||||
|
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):
|
||||||
|
|
||||||
|
```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:**
|
||||||
|
```makefile
|
||||||
|
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):
|
||||||
|
```dockerfile
|
||||||
|
# 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:
|
||||||
|
```go
|
||||||
|
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:
|
||||||
|
```html
|
||||||
|
<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:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "oikos-desktop",
|
||||||
|
"frontend:dir": "../../web",
|
||||||
|
"frontend:build": "npm run build",
|
||||||
|
"frontend:dev:watcher": "npm run dev"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`Makefile`**:
|
||||||
|
```makefile
|
||||||
|
desktop: ui ## Build the Wails desktop app
|
||||||
|
wails build -clean -o oikos-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dev loop for the desktop app:**
|
||||||
|
```sh
|
||||||
|
# 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:**
|
||||||
|
```sh
|
||||||
|
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:
|
||||||
|
```go
|
||||||
|
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.
|
||||||
@@ -14,6 +14,7 @@ went sideways, open an investigation.
|
|||||||
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
|
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
|
||||||
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open |
|
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open |
|
||||||
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
|
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
|
||||||
|
| 2026-07-12 | [Wails desktop application](2026-07-12-wails-desktop-app.md) | Planned — not started |
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user