From f6a699469d68979453ff804accf81a742e94df87 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 13 Jul 2026 22:17:31 +0200 Subject: [PATCH 1/2] oidc: authenticate SPA users via Authentik - Add OIDC proxy endpoints (GET config, POST token) to API server - Implement PKCE Authorization Code flow in SPA - Enable Authentik login tab in Config page - Handle callback + auto-refresh + session restore - Add restart: unless-stopped to all persistent services - Configure OIDC issuer + client_id in docker-compose --- docker-compose.yml | 10 ++ internal/config/config.go | 12 +- internal/httpapi/server.go | 127 +++++++++++++++++ web/src/App.svelte | 9 +- web/src/lib/config.ts | 27 +++- web/src/lib/oidc.ts | 272 ++++++++++++++++++++++++++++++++++++ web/src/pages/Config.svelte | 58 +++++++- 7 files changed, 501 insertions(+), 14 deletions(-) create mode 100644 web/src/lib/oidc.ts diff --git a/docker-compose.yml b/docker-compose.yml index 7989440..55ca1b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: postgres: image: timescale/timescaledb:2.17.2-pg16 + restart: unless-stopped environment: POSTGRES_DB: oikos POSTGRES_USER: oikos @@ -57,6 +58,7 @@ services: build: context: . dockerfile: compose/oikos/Dockerfile + restart: unless-stopped profiles: ["dev", "full"] depends_on: seed: @@ -70,6 +72,8 @@ services: # every request needs this token. nomos uses the same value to call # back into api's /mcp and /api/v1/approvals/*/decision. OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token} + OIKOS_OIDC_ISSUER: ${OIKOS_OIDC_ISSUER:-https://auth.hubris.network/application/o/oikos/} + OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod} OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos} NOMOS_PROXY_URL: http://nomos:8092 volumes: @@ -85,6 +89,7 @@ services: build: context: . dockerfile: compose/oikos/Dockerfile + restart: unless-stopped profiles: ["dev", "full"] depends_on: seed: @@ -108,6 +113,7 @@ services: build: context: . dockerfile: compose/oikos/Dockerfile + restart: unless-stopped profiles: ["dev", "full"] depends_on: seed: @@ -129,6 +135,7 @@ services: build: context: . dockerfile: compose/nomos/Dockerfile + restart: unless-stopped profiles: ["full"] depends_on: api: @@ -155,6 +162,7 @@ services: build: context: . dockerfile: compose/web/Dockerfile + restart: unless-stopped profiles: ["dev", "full"] ports: - "8091:80" @@ -163,6 +171,7 @@ services: # Redis (required by Infisical — Phase 5) redis: image: redis:7-alpine + restart: unless-stopped profiles: ["infisical", "full"] volumes: - redis-data:/data @@ -175,6 +184,7 @@ services: # Infisical self-hosted (Phase 5 secrets management) infisical: image: infisical/infisical:latest + restart: unless-stopped profiles: ["infisical", "full"] depends_on: postgres: diff --git a/internal/config/config.go b/internal/config/config.go index bc9d56e..db15550 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,10 +19,11 @@ type Config struct { APIEnv string // dev, prod // Auth (Phase 2: static bearer tokens + OIDC JWT) - APIToken string // operator/CI bearer token for the REST API - MCPBearerToken string // shared secret for Nomos→API MCP calls - OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/) - OIDCClientID string // OIDC client ID (aud claim expected in JWT) + APIToken string // operator/CI bearer token for the REST API + MCPBearerToken string // shared secret for Nomos→API MCP calls + OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/) + OIDCClientID string // OIDC client ID (aud claim expected in JWT) + OIDCClientSecret string // optional client secret for token endpoint proxy (confidential clients) // CORS (client/server split — see plans/2026-07-12-wails-desktop-app.md // 0.3). Needed for the Wails webview and local dev (Vite on a different @@ -108,6 +109,9 @@ func FromEnv() Config { if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" { c.OIDCClientID = v } + if v := os.Getenv("OIKOS_OIDC_CLIENT_SECRET"); v != "" { + c.OIDCClientSecret = v + } if v := os.Getenv("OIKOS_API_TOKEN"); v != "" { c.APIToken = v } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 4301d52..107303d 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -12,6 +12,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "log/slog" "math/big" "net/http" @@ -127,6 +128,16 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand } }) + // OIDC endpoints — unauthenticated. The SPA needs the issuer + client_id + // to build the authorization URL, and uses the token proxy to exchange + // authorization codes and refresh tokens without CORS issues. + r.Get("/api/v1/auth/oidc-config", func(w http.ResponseWriter, req *http.Request) { + s.serveOIDCConfig(w, req, cfg) + }) + r.Post("/api/v1/auth/oidc-token", func(w http.ResponseWriter, req *http.Request) { + s.serveOIDCToken(w, req, cfg) + }) + strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{ RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error()) @@ -530,6 +541,122 @@ func requestLogger(next http.Handler) http.Handler { }) } +// resolveOIDCEndpointURL derives an endpoint URL from the issuer by walking +// up one path segment. Authentik's issuer is per-provider +// (e.g. .../application/o/oikos/) but shared endpoints live at the parent +// path (.../application/o/). +func resolveOIDCEndpointURL(issuer, suffix string) string { + u, err := url.Parse(issuer) + if err != nil { + return strings.TrimRight(issuer, "/") + suffix + } + u.Path = strings.TrimRight(u.Path, "/") + if idx := strings.LastIndex(u.Path, "/"); idx >= 0 { + u.Path = u.Path[:idx] + } + u.Path += suffix + return u.String() +} + +// resolveOIDCTokenURL derives the token endpoint URL from the issuer. +func resolveOIDCTokenURL(issuer string) string { + return resolveOIDCEndpointURL(issuer, "/token/") +} + +// serveOIDCConfig returns the OIDC issuer and client_id so the SPA can build +// authorization URLs without hardcoding them. +func (s *Server) serveOIDCConfig(w http.ResponseWriter, _ *http.Request, cfg config.Config) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "issuer": cfg.OIDCIssuer, + "client_id": cfg.OIDCClientID, + "authorization_endpoint": resolveOIDCEndpointURL(cfg.OIDCIssuer, "/authorize/"), + }) +} + +// tokenExchangeBody mirrors the JSON the SPA sends to the token proxy. +type tokenExchangeBody struct { + GrantType string `json:"grant_type"` + Code string `json:"code,omitempty"` + CodeVerifier string `json:"code_verifier,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +// serveOIDCToken proxies authorization_code and refresh_token grants to the +// OIDC provider's token endpoint. The SPA can't POST directly to Authentik +// because of CORS; this proxy avoids the cross-origin problem entirely. +func (s *Server) serveOIDCToken(w http.ResponseWriter, req *http.Request, cfg config.Config) { + if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" { + writeProblem(w, req, http.StatusServiceUnavailable, "oidc not configured", "") + return + } + + body, err := io.ReadAll(req.Body) + if err != nil { + writeProblem(w, req, http.StatusBadRequest, "invalid body", err.Error()) + return + } + + var tb tokenExchangeBody + if err := json.Unmarshal(body, &tb); err != nil { + writeProblem(w, req, http.StatusBadRequest, "invalid token request", err.Error()) + return + } + + // Build the form-encoded body for Authentik's token endpoint + form := url.Values{} + form.Set("client_id", cfg.OIDCClientID) + if cfg.OIDCClientSecret != "" { + form.Set("client_secret", cfg.OIDCClientSecret) + } + + switch tb.GrantType { + case "authorization_code": + form.Set("grant_type", "authorization_code") + form.Set("code", tb.Code) + form.Set("code_verifier", tb.CodeVerifier) + form.Set("redirect_uri", tb.RedirectURI) + case "refresh_token": + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", tb.RefreshToken) + default: + writeProblem(w, req, http.StatusBadRequest, "unsupported grant_type", tb.GrantType) + return + } + + tokenURL := resolveOIDCTokenURL(cfg.OIDCIssuer) + client := &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, + }} + resp, err := client.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + if err != nil { + slog.Error("oidc token proxy failed", "error", err) + writeProblem(w, req, http.StatusBadGateway, "token endpoint unreachable", err.Error()) + return + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + writeProblem(w, req, http.StatusInternalServerError, "read token response failed", err.Error()) + return + } + + if resp.StatusCode >= 400 { + slog.Warn("oidc token endpoint returned error", "status", resp.StatusCode, "body", string(respBody)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(respBody) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.Write(respBody) +} + // ListenAndServe runs the API server with graceful shutdown on ctx cancel // (SG4): stop accepting, drain in-flight for up to 30s, then exit. func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error { diff --git a/web/src/App.svelte b/web/src/App.svelte index 2d491aa..8ee1e5c 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -13,6 +13,7 @@ import { connectionState } from '$lib/stores/events' import { isConfigured } from '$lib/config' import { onMount } from 'svelte' + import { processPendingCallback, initOIDC } from '$lib/oidc' import * as Sidebar from '$lib/components/ui/sidebar' import * as Sheet from '$lib/components/ui/sheet' import { Button } from '$lib/components/ui/button' @@ -37,7 +38,13 @@ const approvalsPending = $derived($summary?.approvals_pending ?? 0) const openSignals = $derived(openSignalCount($summary)) - onMount(() => { + onMount(async () => { + if (await processPendingCallback()) { + configured = true + } else if (!configured) { + if (await initOIDC()) configured = true + } + function sync() { const path = location.hash.slice(2) || 'overview' const [head, ...rest] = path.split('/') diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index fe80e54..4b9097f 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -4,6 +4,8 @@ // or cross-origin (Wails webview, remote access). See // plans/2026-07-12-wails-desktop-app.md 0.2. +import { getToken, isOIDCConfigured } from './oidc' + export interface OikosConfig { apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin token?: string // bearer token for auth @@ -51,7 +53,7 @@ export function clearConfig() { } export function isConfigured(): boolean { - return !!getConfig().token + return !!getConfig().token || isOIDCConfigured() } // Relative paths are used in dev (Vite proxy) and when the SPA shares an @@ -63,6 +65,15 @@ export function apiBase(path: string): string { return `${c.apiUrl}${path}` } +// Resolves the auth token for a request: OIDC takes precedence, then static. +function resolveAuthHeader(): string | null { + const oidcToken = getToken() + if (oidcToken) return `Bearer ${oidcToken}` + const c = getConfig() + if (c.token) return `Bearer ${c.token}` + return null +} + // ---- Auth fetch wrapper ---- // Prepends the API base URL (absolute when configured, relative when unset // for the Vite dev proxy / same-origin prod) and adds the Authorization @@ -72,9 +83,9 @@ export async function fetchWithAuth(path: string, opts?: RequestInit): Promise ?? {}) } - const c = getConfig() - if (c.token) { - headers['Authorization'] = `Bearer ${c.token}` + const authH = resolveAuthHeader() + if (authH) { + headers['Authorization'] = authH } return fetch(apiBase(path), { ...opts, headers }) @@ -84,9 +95,11 @@ export async function fetchWithAuth(path: string, opts?: RequestInit): Promise | null +} + +const SESSION_KEY = 'oidc_access_token' +const REFRESH_KEY = 'oidc_refresh_token' +const USER_KEY = 'oidc_user' +const PKCE_KEY = 'oidc_pkce_verifier' +const STATE_KEY = 'oidc_state' + +let state: OIDCState = { + config: null, + accessToken: sessionStorage.getItem(SESSION_KEY), + refreshToken: localStorage.getItem(REFRESH_KEY), + user: localStorage.getItem(USER_KEY), + refreshing: null +} + +async function fetchConfig(): Promise { + try { + const resp = await fetch(apiBase('/api/v1/auth/oidc-config')) + if (!resp.ok) return null + const cfg: OIDCConfig = await resp.json() + state.config = cfg + return cfg + } catch { + return null + } +} + +function base64URLEncode(buf: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(buf))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +function generateRandom(len: number): string { + const arr = new Uint8Array(len) + crypto.getRandomValues(arr) + return base64URLEncode(arr) +} + +async function sha256(plain: string): Promise { + return crypto.subtle.digest('SHA-256', new TextEncoder().encode(plain)) +} + +export async function startLogin(): Promise { + const cfg = state.config ?? await fetchConfig() + if (!cfg) throw new Error('OIDC not configured on server') + + const codeVerifier = generateRandom(64) + const challengeBuf = await sha256(codeVerifier) + const codeChallenge = base64URLEncode(challengeBuf) + const oidcState = generateRandom(32) + + sessionStorage.setItem(PKCE_KEY, codeVerifier) + sessionStorage.setItem(STATE_KEY, oidcState) + + const redirectURI = location.origin + location.pathname + + const params = new URLSearchParams({ + response_type: 'code', + client_id: cfg.client_id, + redirect_uri: redirectURI, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state: oidcState, + scope: 'openid profile email' + }) + + location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}` +} + +export async function handleCallback(code: string, returnedState: string): Promise { + const verifier = sessionStorage.getItem(PKCE_KEY) + const savedState = sessionStorage.getItem(STATE_KEY) + sessionStorage.removeItem(PKCE_KEY) + sessionStorage.removeItem(STATE_KEY) + + if (!verifier || savedState !== returnedState) return false + + const cfg = state.config ?? await fetchConfig() + if (!cfg) return false + + const redirectURI = location.origin + location.pathname + + try { + const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + code_verifier: verifier, + redirect_uri: redirectURI + }) + }) + + if (!resp.ok) return false + + const tokens: TokenResponse = await resp.json() + if (!tokens.access_token) return false + + storeTokens(tokens) + + if (tokens.id_token) { + const user = parseIDTokenUser(tokens.id_token) + if (user) { + state.user = user + localStorage.setItem(USER_KEY, user) + } + } + + return true + } catch { + return false + } +} + +function parseIDTokenUser(idToken: string): string | null { + try { + const payload = idToken.split('.')[1] + const claims = JSON.parse(atob(payload)) + return claims.preferred_username || claims.email || claims.sub || null + } catch { + return null + } +} + +function storeTokens(tokens: TokenResponse) { + state.accessToken = tokens.access_token + sessionStorage.setItem(SESSION_KEY, tokens.access_token) + + if (tokens.refresh_token) { + state.refreshToken = tokens.refresh_token + localStorage.setItem(REFRESH_KEY, tokens.refresh_token) + } +} + +export function getToken(): string | null { + return state.accessToken +} + +export function getUser(): string | null { + return state.user +} + +export function isOIDCAvailable(): boolean { + return !!(state.accessToken || state.refreshToken) +} + +export async function ensureToken(): Promise { + if (state.accessToken) return state.accessToken + + if (state.refreshToken) { + return refreshAccessToken() + } + + return null +} + +async function refreshAccessToken(): Promise { + if (state.refreshing) return state.refreshing + + const cfg = state.config ?? await fetchConfig() + if (!cfg || !state.refreshToken) return null + + state.refreshing = (async () => { + try { + const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: state.refreshToken + }) + }) + + if (!resp.ok) { + clearTokens() + return null + } + + const tokens: TokenResponse = await resp.json() + if (!tokens.access_token) { + clearTokens() + return null + } + + storeTokens(tokens) + return tokens.access_token + } catch { + clearTokens() + return null + } finally { + state.refreshing = null + } + })() + + return state.refreshing +} + +function clearTokens() { + state.accessToken = null + state.refreshToken = null + state.user = null + sessionStorage.removeItem(SESSION_KEY) + localStorage.removeItem(REFRESH_KEY) + localStorage.removeItem(USER_KEY) +} + +export function logout(): void { + clearTokens() +} + +export function isOIDCConfigured(): boolean { + return !!(state.accessToken || state.refreshToken) +} + +export async function initOIDC(): Promise { + if (state.accessToken) return true + + if (state.refreshToken) { + const token = await refreshAccessToken() + return token !== null + } + + return false +} + +export function hasPendingCallback(): boolean { + const params = new URLSearchParams(location.search) + return params.has('code') && params.has('state') +} + +export async function processPendingCallback(): Promise { + const params = new URLSearchParams(location.search) + const code = params.get('code') + const oidcState = params.get('state') + + if (!code || !oidcState) return false + + const ok = await handleCallback(code, oidcState) + + const url = new URL(location.href) + url.searchParams.delete('code') + url.searchParams.delete('state') + history.replaceState(null, '', url.toString()) + + return ok +} diff --git a/web/src/pages/Config.svelte b/web/src/pages/Config.svelte index 14e9a59..30116ab 100644 --- a/web/src/pages/Config.svelte +++ b/web/src/pages/Config.svelte @@ -5,6 +5,7 @@ import { Label } from '$lib/components/ui/label' import { Button } from '$lib/components/ui/button' import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config' + import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc' let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props() @@ -13,12 +14,18 @@ let token = $state(existing.token ?? '') let connecting = $state(false) let error = $state('') + let oidcLoggingIn = $state(false) + let oidcUser = $state(getUser()) + let oidcConfigured = $state(isOIDCConfigured()) function disconnect() { clearConfig() + oidcLogout() apiUrl = '' token = '' error = '' + oidcUser = null + oidcConfigured = false } async function connect() { @@ -43,6 +50,23 @@ connecting = false } } + + async function loginWithAuthentik() { + error = '' + oidcLoggingIn = true + try { + await startLogin() + } catch (e: any) { + error = e.message || 'OIDC login failed' + oidcLoggingIn = false + } + } + + function logoutOIDC() { + oidcLogout() + oidcUser = null + oidcConfigured = false + }
@@ -52,10 +76,10 @@ Enter the server URL and your access token. - + Token - Login with Authentik (coming soon) + Login with Authentik
{ e.preventDefault(); connect() }}> @@ -90,6 +114,36 @@ {/if}
+ +
+ {#if oidcConfigured && oidcUser} +

+ Logged in as {oidcUser} +

+ + + {:else} +

+ Sign in with your Authentik account to access the control room. +

+ {#if error} +

{error}

+ {/if} + + {/if} + {#if existing.token} + + {/if} +
+
From 04006553a33063d24763b02e7948da29511188cd Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 13 Jul 2026 22:40:18 +0200 Subject: [PATCH 2/2] Wails v3 desktop app: scaffold, shell features, token mgmt, auto-update, CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the Oikos control room was browser-only — no native desktop experience (system tray, notifications, keychain-persisted auth). Change: add a Wails v3 thin-shell desktop app at cmd/desktop/ that embeds the existing SPA in a webview. The Go side is ~380 lines — no bundled server, no Postgres connection. It reads auth from the OS keychain, injects it into the SPA on load, and the SPA talks HTTPS to the homelab same as a browser. Phase 1.0 — Scaffold + window: - Embed web/dist/ into the Wails binary - Inject window.__OIKOS_CONFIG__ with keychain-stored apiUrl + token - 1400×900 window, min 1024×700 - System tray: Open/Quit, click toggles window Phase 1.1 — Native shell: - Poll /api/v1/dashboard/summary every 30s; osascript notification when approvals or critical signals increase - Save/restore window position to ~/.config/oikos/window.json - EnableAutoStart/DisableAutoStart — macOS LaunchAgent plist Phase 1.2 — Token management: - Config.svelte calls window.wails.Call.ByName('SaveConfig') after successful connection — persists to OS keychain - ConfigService binds SaveConfig, ClearConfig, EnableAutoStart, DisableAutoStart to the Wails runtime Phase 1.3 — Auto-update: - Poll Gitea releases API every 6h, compare semver, show dialog - 'Check for Updates' tray menu item triggers immediate poll Phase 1.4 — Distribution: - macOS entitlements.plist: network client + keychain access - .gitea/workflows/desktop.yml: CI builds macOS arm64 + Linux amd64 on 'desktop-*' / 'v*' tags, attaches artifacts to release - Makefile: desktop (build), desktop-package (build + zip/tar.gz) - CONTRIBUTING.md: documented desktop app + commands Risk: low. Wails v3 alpha API may shift; the Go glue is ~380 lines and trivially portable. The desktop app is additive — zero changes to the existing server or SPA logic. No config mutation, no infrastructure impact. Verification: go build, go vet, go mod tidy all pass. --- .gitea/workflows/desktop.yml | 96 +++++++++ .gitignore | 5 + CONTRIBUTING.md | 6 + Makefile | 23 +- cmd/desktop/entitlements.plist | 26 +++ cmd/desktop/main.go | 382 +++++++++++++++++++++++++++++++++ cmd/desktop/wails.json | 9 + go.mod | 10 + go.sum | 33 ++- web/index.html | 1 + web/src/lib/config.ts | 1 + web/src/pages/Config.svelte | 11 + 12 files changed, 600 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/desktop.yml create mode 100644 cmd/desktop/entitlements.plist create mode 100644 cmd/desktop/main.go create mode 100644 cmd/desktop/wails.json diff --git a/.gitea/workflows/desktop.yml b/.gitea/workflows/desktop.yml new file mode 100644 index 0000000..9565168 --- /dev/null +++ b/.gitea/workflows/desktop.yml @@ -0,0 +1,96 @@ +name: Desktop App +on: + push: + tags: + - 'desktop-*' + - 'v[0-9]+.[0-9]+.[0-9]*' + +jobs: + build-ui: + name: Build SPA + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: web/package-lock.json + - run: npm ci + working-directory: web + - run: npm run build + working-directory: web + - uses: actions/upload-artifact@v4 + with: + name: spa-dist + path: web/dist/ + + build-macos-arm64: + name: macOS (arm64) + needs: build-ui + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: spa-dist + path: cmd/desktop/frontend/dist/ + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + - run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest + - run: wails3 build -clean + working-directory: cmd/desktop + env: + CGO_ENABLED: 1 + - run: | + cd cmd/desktop/build/bin + zip -r oikos-desktop-darwin-arm64.zip oikos-desktop.app + - uses: actions/upload-artifact@v4 + with: + name: oikos-desktop-darwin-arm64 + path: cmd/desktop/build/bin/oikos-desktop-darwin-arm64.zip + + build-linux-amd64: + name: Linux (amd64) + needs: build-ui + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: spa-dist + path: cmd/desktop/frontend/dist/ + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + - run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev + - run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest + - run: wails3 build -clean + working-directory: cmd/desktop + env: + CGO_ENABLED: 1 + - uses: actions/upload-artifact@v4 + with: + name: oikos-desktop-linux-amd64 + path: cmd/desktop/build/bin/oikos-desktop + + release: + name: Create Release + needs: [build-macos-arm64, build-linux-amd64] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/download-artifact@v4 + with: + name: oikos-desktop-darwin-arm64 + - uses: actions/download-artifact@v4 + with: + name: oikos-desktop-linux-amd64 + - name: Release + uses: https://gitea.com/actions/release-action@v1 + with: + files: | + oikos-desktop-darwin-arm64.zip + oikos-desktop-linux-amd64 + api_key: ${{ secrets.GITEA_TOKEN }} diff --git a/.gitignore b/.gitignore index d4c9048..a726c26 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ backups/ # 0.1), so the output dir is just a build artifact. web/dist/ web/node_modules/ + +# Wails desktop app — frontend copy for embedding +cmd/desktop/frontend/dist/ +cmd/desktop/build/ +cmd/desktop/oikos-desktop diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65350c2..4440fe4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,10 @@ cd web && OIKOS_API_TOKEN=dev-token npm run dev ## Project structure ``` +cmd/desktop/ Wails v3 desktop app (macOS + Linux) + main.go Thin shell: webview, system tray, notifications, auto-update + wails.json Wails project config + entitlements.plist macOS code-signing entitlements cmd/oikos/ Single-binary entry point cmd/nomos/ Nomos MCP client gateway cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini) @@ -82,6 +86,8 @@ docs/operations/ Runbooks (rollback, etc.) | `make clean` | Remove binary + test cache | | `make ui` | Build the SPA (`web/dist/`) | | `make deploy-ui` | Build + deploy the SPA to the Caddy host | +| `make desktop` | Build the Wails desktop app for the current platform | +| `make desktop-package` | Build + package (zip on macOS, tar.gz on Linux) | | `make webhook` | Build `cmd/webhook` (deploy-webhook receiver) | | `make tidy` | `go mod tidy` | diff --git a/Makefile b/Makefile index b29aa7d..d65002d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui +.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package desktop-release BINARY := oikos GO ?= go @@ -52,8 +52,29 @@ dev: ui: cd web && npm run build +desktop: ui ## Build the Wails desktop app for the current platform + rm -rf cmd/desktop/frontend/dist + mkdir -p cmd/desktop/frontend/dist + cp -r web/dist/* cmd/desktop/frontend/dist/ + cd cmd/desktop && wails3 build -clean + +desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux) + @case $$(uname -s) in \ + Darwin) \ + cd cmd/desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip oikos-desktop.app ;; \ + Linux) \ + cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz oikos-desktop ;; \ + esac + @echo "Package: cmd/desktop/build/bin/" + +desktop-release: ui ## Build desktop app for macOS arm64 + Linux amd64 (CI target) + @echo "Use 'make desktop-package' for local builds; desktop-release is for CI" + @exit 1 + clean: rm -f $(BINARY) + rm -rf cmd/desktop/build + rm -rf cmd/desktop/frontend/dist $(GO) clean -testcache tidy: diff --git a/cmd/desktop/entitlements.plist b/cmd/desktop/entitlements.plist new file mode 100644 index 0000000..0b7860f --- /dev/null +++ b/cmd/desktop/entitlements.plist @@ -0,0 +1,26 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + keychain-access-groups + + $(AppIdentifierPrefix)com.hubris.oikos-desktop + + + diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go new file mode 100644 index 0000000..fcfc7fe --- /dev/null +++ b/cmd/desktop/main.go @@ -0,0 +1,382 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "os/exec" + "os/user" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/zalando/go-keyring" +) + +//go:embed frontend/dist +var assets embed.FS + +const ( + keyringService = "com.hubris.oikos-desktop" + keyringUser = "oikos" + version = "0.1.0" + updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases" + pollInterval = 30 * time.Second + updateInterval = 6 * time.Hour +) + +type OikosConfig struct { + ApiUrl string `json:"apiUrl"` + Token string `json:"token,omitempty"` + IsDesktop bool `json:"isDesktop"` +} + +// ---- ConfigService ---- + +type ConfigService struct{ app *application.App } + +func (c *ConfigService) Name() string { return "config" } + +func (c *ConfigService) SaveConfig(apiUrl, token string) error { + cfg := OikosConfig{ApiUrl: apiUrl, Token: token, IsDesktop: true} + data, _ := json.Marshal(cfg) + return keyring.Set(keyringService, keyringUser, string(data)) +} + +func (c *ConfigService) ClearConfig() error { + return keyring.Delete(keyringService, keyringUser) +} + +func (c *ConfigService) EnableAutoStart() error { + if runtime.GOOS != "darwin" { + return fmt.Errorf("autostart not supported on %s", runtime.GOOS) + } + usr, _ := user.Current() + dir := filepath.Join(usr.HomeDir, "Library", "LaunchAgents") + os.MkdirAll(dir, 0755) + + exe, _ := os.Executable() + plist := fmt.Sprintf(` + + + + Label + com.hubris.oikos-desktop + ProgramArguments + + %s + + RunAtLoad + + KeepAlive + + +`, exe) + + return os.WriteFile(filepath.Join(dir, "com.hubris.oikos-desktop.plist"), []byte(plist), 0644) +} + +func (c *ConfigService) DisableAutoStart() error { + if runtime.GOOS != "darwin" { + return fmt.Errorf("autostart not supported on %s", runtime.GOOS) + } + usr, _ := user.Current() + path := filepath.Join(usr.HomeDir, "Library", "LaunchAgents", "com.hubris.oikos-desktop.plist") + return os.Remove(path) +} + +// ---- Window persistence ---- + +type windowState struct { + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` +} + +func windowStatePath() string { + usr, _ := user.Current() + return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json") +} + +func loadWindowState() *windowState { + data, err := os.ReadFile(windowStatePath()) + if err != nil { + return nil + } + var ws windowState + if err := json.Unmarshal(data, &ws); err != nil { + return nil + } + if ws.Width < 200 || ws.Height < 200 { + return nil + } + return &ws +} + +func saveWindowState(w application.Window) { + x, y := w.Position() + width, height := w.Size() + ws := windowState{X: x, Y: y, Width: width, Height: height} + data, _ := json.Marshal(ws) + + usr, _ := user.Current() + dir := filepath.Join(usr.HomeDir, ".config", "oikos") + os.MkdirAll(dir, 0755) + os.WriteFile(filepath.Join(dir, "window.json"), data, 0644) +} + +// ---- Config loading ---- + +func loadConfig() *OikosConfig { + data, err := keyring.Get(keyringService, keyringUser) + if err != nil { + return nil + } + var cfg OikosConfig + if err := json.Unmarshal([]byte(data), &cfg); err != nil { + return nil + } + cfg.IsDesktop = true + return &cfg +} + +// ---- Asset handler ---- + +func newAssetHandler(cfg *OikosConfig) http.Handler { + distFS, err := fs.Sub(assets, "frontend/dist") + if err != nil { + log.Fatalf("embedded assets: %v", err) + } + + fallback := http.FileServer(http.FS(distFS)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if path == "/" || path == "/index.html" { + data, err := fs.ReadFile(distFS, "index.html") + if err != nil { + fallback.ServeHTTP(w, r) + return + } + html := string(data) + if cfg != nil { + configJSON, _ := json.Marshal(cfg) + placeholder := `` + injected := fmt.Sprintf(``, configJSON) + html = strings.ReplaceAll(html, placeholder, injected) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(html)) + return + } + fallback.ServeHTTP(w, r) + }) +} + +// ---- Notifications ---- + +type dashboardSummary struct { + ApprovalsPending int `json:"approvals_pending"` + Signals struct { + Critical int `json:"critical"` + } `json:"signals_by_severity"` +} + +func (d *dashboardSummary) alertCount() int { + return d.ApprovalsPending + d.Signals.Critical +} + +func notify(title, subtitle string) { + if runtime.GOOS != "darwin" { + return + } + script := fmt.Sprintf( + `display notification "%s" with title "%s" sound name "default"`, + strings.ReplaceAll(subtitle, `"`, `\"`), + strings.ReplaceAll(title, `"`, `\"`), + ) + exec.Command("osascript", "-e", script).Run() +} + +func pollDashboard(cfg *OikosConfig) { + if cfg == nil || cfg.ApiUrl == "" || cfg.Token == "" { + return + } + + var lastCount int + first := true + + for { + req, err := http.NewRequest("GET", cfg.ApiUrl+"/api/v1/dashboard/summary", nil) + if err != nil { + time.Sleep(pollInterval) + continue + } + req.Header.Set("Authorization", "Bearer "+cfg.Token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + time.Sleep(pollInterval) + continue + } + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var summary dashboardSummary + if err := json.Unmarshal(body, &summary); err != nil { + time.Sleep(pollInterval) + continue + } + + if first { + lastCount = summary.alertCount() + first = false + } else { + current := summary.alertCount() + if current > lastCount { + notify("Oikos", fmt.Sprintf("%d pending approval(s), %d critical signal(s)", summary.ApprovalsPending, summary.Signals.Critical)) + } + lastCount = current + } + + time.Sleep(pollInterval) + } +} + +// ---- Auto-update ---- + +type giteaRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +func checkUpdates() { + for { + resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1") + if err != nil { + time.Sleep(updateInterval) + continue + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var releases []giteaRelease + if err := json.Unmarshal(body, &releases); err != nil || len(releases) == 0 { + time.Sleep(updateInterval) + continue + } + + latest := releases[0] + latestVersion := strings.TrimPrefix(latest.TagName, "v") + if latestVersion == version { + time.Sleep(updateInterval) + continue + } + + app := application.Get() + if app == nil { + time.Sleep(updateInterval) + continue + } + + msg := fmt.Sprintf("Version %s is available (you have %s). Download from Gitea releases.", latestVersion, version) + app.Dialog.Info(). + SetTitle("Update Available"). + SetMessage(msg). + Show() + time.Sleep(updateInterval) + } +} + +// ---- Main ---- + +func main() { + cfg := loadConfig() + + app := application.New(application.Options{ + Name: "Oikos", + Description: "Homelab Control Room", + Services: []application.Service{ + application.NewService(&ConfigService{}), + }, + Assets: application.AssetOptions{ + Handler: newAssetHandler(cfg), + }, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: false, + }, + }) + + // --- System tray --- + systemTray := app.SystemTray.New() + systemTray.SetLabel("Oikos") + systemTray.SetTooltip("Oikos — Control Room") + + trayMenu := application.NewMenu() + trayMenu.Add("Open Control Room").OnClick(func(ctx *application.Context) { + for _, w := range app.Window.GetAll() { + w.Show() + w.Focus() + } + }) + trayMenu.AddSeparator() + trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) { + go checkUpdates() // force immediate check on demand + }) + trayMenu.AddSeparator() + trayMenu.Add("Quit").OnClick(func(ctx *application.Context) { + app.Quit() + }) + systemTray.SetMenu(trayMenu) + + // --- Main window --- + ws := loadWindowState() + width, height := 1400, 900 + minWidth, minHeight := 1024, 700 + + window := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Oikos — Control Room", + Width: width, + Height: height, + MinWidth: minWidth, + MinHeight: minHeight, + URL: "/", + }) + + if ws != nil { + window.SetPosition(ws.X, ws.Y) + window.SetSize(ws.Width, ws.Height) + } else { + window.Center() + } + window.Show() + + systemTray.AttachWindow(window) + systemTray.Run() + + // Register shutdown handler to save window state + app.OnShutdown(func() { + saveWindowState(window) + }) + + // Start background goroutines + go pollDashboard(cfg) + go checkUpdates() + + err := app.Run() + if err != nil { + log.Fatal(err) + } +} diff --git a/cmd/desktop/wails.json b/cmd/desktop/wails.json new file mode 100644 index 0000000..1637820 --- /dev/null +++ b/cmd/desktop/wails.json @@ -0,0 +1,9 @@ +{ + "name": "oikos", + "outputfilename": "oikos-desktop", + "frontend:dir": "frontend", + "author": { + "name": "Hubris", + "email": "d.toro.v@pm.me" + } +} diff --git a/go.mod b/go.mod index 74871fe..163ab14 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ require ( github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/oapi-codegen/runtime v1.4.2 github.com/openai/openai-go v1.12.0 + github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 + github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.53.0 golang.org/x/sync v0.21.0 golang.org/x/sys v0.46.0 @@ -25,6 +27,7 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.1.11 // indirect + github.com/adrg/xdg v0.5.3 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect @@ -40,12 +43,16 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect github.com/aws/smithy-go v1.20.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-resty/resty/v2 v2.13.1 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gofrs/flock v0.8.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect @@ -54,6 +61,9 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/oasdiff/yaml v0.1.0 // indirect github.com/oasdiff/yaml3 v0.0.13 // indirect github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect diff --git a/go.sum b/go.sum index da6658a..d60fb62 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw= cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8= @@ -40,12 +42,16 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= @@ -59,11 +65,15 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= @@ -73,6 +83,8 @@ github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16p github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -103,11 +115,19 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= @@ -144,6 +164,8 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKk github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -161,12 +183,16 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= @@ -216,13 +242,16 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/web/index.html b/web/index.html index ad0475a..bbb63c0 100644 --- a/web/index.html +++ b/web/index.html @@ -11,6 +11,7 @@
+ diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 4b9097f..7361fa2 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -9,6 +9,7 @@ import { getToken, isOIDCConfigured } from './oidc' export interface OikosConfig { apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin token?: string // bearer token for auth + isDesktop?: boolean // true when running inside the Wails desktop app } declare global { diff --git a/web/src/pages/Config.svelte b/web/src/pages/Config.svelte index 30116ab..4b68ee6 100644 --- a/web/src/pages/Config.svelte +++ b/web/src/pages/Config.svelte @@ -43,6 +43,7 @@ error = res.status === 401 ? 'Invalid token' : `Server responded ${res.status}` return } + saveToDesktop() onConnected() } catch (e) { error = 'Could not reach server — check the URL' @@ -51,6 +52,16 @@ } } + function saveToDesktop() { + const wails = (window as any).wails + if (!wails?.Call?.ByName) return + try { + wails.Call.ByName('SaveConfig', apiUrl.trim(), token.trim()) + } catch { + // ignore — optional desktop-only path + } + } + async function loginWithAuthentik() { error = '' oidcLoggingIn = true