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)
}
}