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 //go:embed icon.png var iconPNG []byte 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") systemTray.SetIcon(iconPNG) systemTray.SetTemplateIcon(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 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", 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) } }