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

679 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.