oikos-web: extract the client stack from dtoro/oikos
Phase 1 of the hexagonal-architecture plan (dtoro/oikos plans/2026-08-15-hexagonal-architecture.md). Moves the delivery stack for the control-room UI into its own repo with its own pipeline: - web/ — Svelte 5 SPA, verbatim (vendor/ included) - desktop/ — Wails v3 wrapper, updateURL repointed to dtoro/oikos-web releases - compose/ — Dockerfile + Caddyfile, verbatim (the /wails/* 404 and asset no-fallback quirks are load-bearing) - docker-compose.yml — single web service, same 8091:80 publish, mem/cpu limits, and restart policy as the oikos stack's web service - scripts/deploy.sh — mirrors oikos deploy essentials: CI-green gate, TOCTOU guard, version-tagged oikos-web:v$VERSION, prune to 3 - cmd/webhook + scripts/install-webhook.sh — standalone push-to-deploy receiver on :9798 (env-only secrets, no Infisical dependency) - CI: the web job from oikos's ci.yml + the desktop build/release workflow, path-adjusted Own VERSION (0.33.0) with the same bump-on-main rule; starts above oikos's 0.32.x so the desktop updater sees an upgrade.
This commit is contained in:
790
desktop/main.go
Normal file
790
desktop/main.go
Normal file
@@ -0,0 +1,790 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
_ "embed" // required by the //go:embed icon.png directive below
|
||||
"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"
|
||||
)
|
||||
|
||||
// assets is the embedded web SPA, defined in assets_embed.go (`desktop` build
|
||||
// tag, real //go:embed frontend/dist) and assets_stub.go (default build, empty
|
||||
// FS). frontend/dist is a gitignored artifact populated by `make desktop`; the
|
||||
// stub keeps `go build ./...` working on a clean checkout.
|
||||
|
||||
//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-web/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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.hubris.oikos-desktop</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>%s</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>`, 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, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
||||
<meta http-equiv="refresh" content="0;url=%s">
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
||||
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
||||
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
||||
</head><body><div class="card"><h1>Connected</h1><p class="ok">Redirecting back to Oikos…</p></div></body></html>`, 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(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
||||
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
||||
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
||||
</head><body><div class="card"><h1>Connected</h1><p class="ok">You can close this window and return to Oikos.</p></div></body></html>`))
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user