package main import ( "crypto/rand" "crypto/sha256" "embed" "encoding/base64" "encoding/json" "fmt" "io" "io/fs" "log" "net" "net/http" "net/url" "os" "os/exec" "os/user" "path/filepath" "runtime" "strings" "sync" "time" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" "github.com/zalando/go-keyring" ) //go:embed frontend/dist var assets embed.FS //go:embed icon.png var iconPNG []byte const ( keyringService = "com.hubris.oikos-desktop" keyringUser = "oikos" updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases" pollInterval = 30 * time.Second updateInterval = 6 * time.Hour oidcCallbackPort = 18901 ) // version is injected at link time via -ldflags "-X main.version=$(cat VERSION)" // (Makefile desktop target). The default keeps a non-empty fallback for // `go build ./cmd/desktop` without ldflags. var version = "0.1.0-dev" type OikosConfig struct { ApiUrl string `json:"apiUrl"` Token string `json:"token,omitempty"` IsDesktop bool `json:"isDesktop"` } // ---- ConfigService ---- type ConfigService struct{} 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) GetStoredConfig() *OikosConfig { return loadConfig() } 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) } // ---- Local OIDC server (runs alongside the webview) ---- type oidcSession struct { apiUrl string verifier string state string ch chan string } var ( oidcSessionsMu sync.Mutex oidcSessions = make(map[string]*oidcSession) ) func startOIDCServer() *http.Server { mux := http.NewServeMux() cors := func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) } } h := func(path string, handler func(http.ResponseWriter, *http.Request)) { mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { cors(w, r) if r.Method == "OPTIONS" { return } handler(w, r) }) } h("/oidc/start", func(w http.ResponseWriter, r *http.Request) { apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/") returnURL := r.URL.Query().Get("ret") if apiUrl == "" { http.Error(w, "apiUrl required", http.StatusBadRequest) return } if returnURL == "" { returnURL = "/?desktop=1" } oidcCfg, err := fetchOIDCConfig(apiUrl) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } verifier, challenge, _ := pkceParams() state := randomString(32) redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort) ch := make(chan string, 1) oidcSessionsMu.Lock() sessionID := randomString(16) oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch} oidcSessionsMu.Unlock() authURL := fmt.Sprintf("%s?%s", oidcCfg.AuthorizationEndpoint, url.Values{ "response_type": {"code"}, "client_id": {oidcCfg.ClientID}, "redirect_uri": {redirectURI}, "code_challenge": {challenge}, "code_challenge_method": {"S256"}, "state": {state}, "scope": {"openid profile email"}, }.Encode(), ) exec.Command("open", authURL).Start() select { case token := <-ch: if token != "" { c := &ConfigService{} c.SaveConfig(apiUrl, token) returnURL += "&token=" + url.QueryEscape(token) } case <-time.After(5 * time.Minute): } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `Oikos

Connected

Redirecting back to Oikos…

`, returnURL) }) h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") gotState := r.URL.Query().Get("state") w.Header().Set("Content-Type", "text/html; charset=utf-8") oidcSessionsMu.Lock() var session *oidcSession var sessionID string for id, s := range oidcSessions { if s.state == gotState { session = s sessionID = id break } } oidcSessionsMu.Unlock() if session == nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("Invalid state.")) return } token, err := exchangeCode( session.apiUrl, code, session.verifier, fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort), ) oidcSessionsMu.Lock() delete(oidcSessions, sessionID) oidcSessionsMu.Unlock() if err != nil { w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, "Token exchange failed: %v", err) session.ch <- "" return } w.Write([]byte(`Oikos

Connected

You can close this window and return to Oikos.

`)) session.ch <- token }) mux.HandleFunc("/oidc/config", func(w http.ResponseWriter, r *http.Request) { apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/") if apiUrl == "" { http.Error(w, "apiUrl required", http.StatusBadRequest) return } cfg, err := fetchOIDCConfig(apiUrl) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(cfg) }) mux.HandleFunc("/update/check", func(w http.ResponseWriter, r *http.Request) { latest := fetchLatestRelease() w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") if latest == nil { json.NewEncoder(w).Encode(map[string]string{"current": version}) return } hasAsset := false for _, a := range latest.Assets { if strings.Contains(a.Name, "darwin") { hasAsset = true updater.mu.Lock() updater.latestURL = a.BrowserDownloadURL updater.mu.Unlock() break } } json.NewEncoder(w).Encode(map[string]string{ "current": version, "latest": latest.Version, "has_asset": fmt.Sprintf("%t", hasAsset), }) }) listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort)) if err != nil { log.Printf("OIDC server: %v", err) return nil } log.Printf("OIDC server listening on %s", listener.Addr()) srv := &http.Server{Handler: mux} go srv.Serve(listener) return srv } // ---- 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) } 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 } type oidcConfig struct { Issuer string `json:"issuer"` ClientID string `json:"client_id"` AuthorizationEndpoint string `json:"authorization_endpoint"` } func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) { resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config") if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("server returned %d", resp.StatusCode) } var cfg oidcConfig if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { return nil, err } return &cfg, nil } func pkceParams() (verifier, challenge string, _ error) { v := randomString(64) h := sha256.Sum256([]byte(v)) return v, base64.RawURLEncoding.EncodeToString(h[:]), nil } func randomString(n int) string { b := make([]byte, n) rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) { body, _ := json.Marshal(map[string]string{ "grant_type": "authorization_code", "code": code, "code_verifier": verifier, "redirect_uri": redirectURI, }) resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body))) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { b, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b)) } var tokens struct { AccessToken string `json:"access_token"` } if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil { return "", err } if tokens.AccessToken == "" { return "", fmt.Errorf("no access_token in response") } return tokens.AccessToken, nil } // ---- 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"` } type updateState struct { mu sync.Mutex latestURL string } var updater = &updateState{} // CheckForUpdates checks Gitea releases for a newer version. If found, stores // the download URL and returns the latest version string (empty if current). func (c *ConfigService) CheckForUpdates() string { latest := fetchLatestRelease() if latest == nil || latest.Version == version { return "" } for _, a := range latest.Assets { if strings.Contains(a.Name, "darwin") { updater.mu.Lock() updater.latestURL = a.BrowserDownloadURL updater.mu.Unlock() return latest.Version } } return "" } // InstallUpdate downloads the stored update, replaces the app, and restarts. func (c *ConfigService) InstallUpdate() error { updater.mu.Lock() url := updater.latestURL updater.mu.Unlock() if url == "" { return fmt.Errorf("no update available") } return doUpdate(url) } type latestRelease struct { Version string Assets []struct { Name string BrowserDownloadURL string } } func fetchLatestRelease() *latestRelease { resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1") if err != nil { return nil } defer resp.Body.Close() var releases []giteaRelease if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 { return nil } r := releases[0] v := strings.TrimPrefix(r.TagName, "v") if v == version { return nil } lr := &latestRelease{Version: v} for _, a := range r.Assets { lr.Assets = append(lr.Assets, struct { Name string BrowserDownloadURL string }{a.Name, a.BrowserDownloadURL}) } return lr } func doUpdate(downloadURL string) error { tmp, err := os.CreateTemp("", "oikos-update-*.zip") if err != nil { return err } defer os.Remove(tmp.Name()) resp, err := http.Get(downloadURL) if err != nil { return err } defer resp.Body.Close() if _, err := io.Copy(tmp, resp.Body); err != nil { return err } tmp.Close() extractDir, err := os.MkdirTemp("", "oikos-extract") if err != nil { return err } defer os.RemoveAll(extractDir) cmd := exec.Command("unzip", "-o", tmp.Name(), "-d", extractDir) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("unzip: %w: %s", err, out) } newApp := filepath.Join(extractDir, "Oikos.app") if _, err := os.Stat(newApp); err != nil { return fmt.Errorf("extracted app not found: %w", err) } currentApp := "/Applications/Oikos.app" if _, err := os.Stat(currentApp); os.IsNotExist(err) { if exe, err := os.Executable(); err == nil { currentApp = filepath.Dir(filepath.Dir(filepath.Dir(exe))) } } script := fmt.Sprintf(`#!/bin/bash sleep 2 rm -rf "%s" mv "%s" "%s" open "%s" rm "$0" `, currentApp, newApp, currentApp, currentApp) scriptPath := filepath.Join(os.TempDir(), "oikos-update.sh") if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { return err } app := application.Get() exec.Command("open", scriptPath).Start() if app != nil { app.Quit() } return nil } func checkUpdates() { for { time.Sleep(updateInterval) latest := fetchLatestRelease() if latest == nil { continue } for _, a := range latest.Assets { if strings.Contains(a.Name, "darwin") { updater.mu.Lock() updater.latestURL = a.BrowserDownloadURL updater.mu.Unlock() app := application.Get() if app == nil { continue } msg := fmt.Sprintf("Version %s is available (you have %s).", latest.Version, version) app.Dialog.Info(). SetTitle("Update Available"). SetMessage(msg). Show() break } } } } // ---- Main ---- func main() { oidcSrv := startOIDCServer() defer oidcSrv.Close() distFS, err := fs.Sub(assets, "frontend/dist") if err != nil { log.Fatalf("embedded assets: %v", err) } app := application.New(application.Options{ Name: "Oikos", Description: "Homelab Control Room", Services: []application.Service{ application.NewService(&ConfigService{}), }, Assets: application.AssetOptions{ Handler: application.AssetFileServerFS(distFS), }, Mac: application.MacOptions{ ApplicationShouldTerminateAfterLastWindowClosed: false, }, }) systemTray := app.SystemTray.New() systemTray.SetTooltip("Oikos") systemTray.SetIcon(iconPNG) trayMenu := application.NewMenu() trayMenu.Add("Open Oikos").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 func() { latest := fetchLatestRelease() if latest == nil { app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show() return } for _, a := range latest.Assets { if strings.Contains(a.Name, "darwin") { updater.mu.Lock() updater.latestURL = a.BrowserDownloadURL updater.mu.Unlock() msg := fmt.Sprintf("Version %s is available (you have %s). Install now?", latest.Version, version) d := app.Dialog.Question().SetTitle("Update Available").SetMessage(msg) yes := d.AddButton("Install") yes.OnClick(func() { doUpdate(updater.latestURL) }) no := d.AddButton("Later") d.SetDefaultButton(yes) d.SetCancelButton(no) d.Show() return } } app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show() }() }) trayMenu.AddSeparator() trayMenu.Add("Quit").OnClick(func(ctx *application.Context) { app.Quit() }) systemTray.SetMenu(trayMenu) ws := loadWindowState() width, height := 1400, 900 if ws != nil { width = ws.Width height = ws.Height } window := app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "Oikos", Width: width, Height: height, MinWidth: 1024, MinHeight: 700, URL: "/?desktop=1", }) if ws != nil { window.SetPosition(ws.X, ws.Y) } else { window.Center() } window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { window.Hide() e.Cancel() }) window.Show() systemTray.AttachWindow(window) systemTray.Run() app.OnShutdown(func() { saveWindowState(window) }) go pollDashboard(loadConfig()) go checkUpdates() err = app.Run() if err != nil { log.Fatal(err) } }